Skip to content
歡迎來票選你最喜歡的元件! 也可以告訴我任何你想說的話喔!(*´∀`)~♥

教學:調皮的按鈕 tutorial

從零開始,一步步打造一個停用時會逃跑的調皮按鈕。ᕕ( ゚ ∀。)ᕗ

完成品請見 調皮的按鈕元件

前言

想像你有一個表單,使用者還沒填完就想按「送出」,如果按鈕自己跑掉呢? (ノ>ω<)ノ

這就是調皮按鈕的核心概念:當按鈕處於 disabled 狀態時,滑鼠一碰就跑

接下來我們將把這個元件的開發過程拆解為 10 個小步驟,每一步都只加入一個新概念。

Step 1:靜態按鈕

萬事起頭難,先從一個靜態按鈕開始吧。( ´ ▽ ` )ノ

這個步驟很單純,就是一個帶有基本樣式的按鈕。之後所有的互動行為都會在這個基礎上疊加。

查看範例原始碼
vue
<template>
  <div class="flex justify-center border border-gray-200 rounded-xl p-6">
    <div class="relative">
      <div class="content">
        <button class="btn select-none rounded p-3 px-6">
          調皮的按鈕
        </button>
      </div>
    </div>
  </div>
</template>

<style scoped lang="sass">
.btn
  border: 1px solid light-dark(#777, #AAA)
  background-color: light-dark(#FEFEFE, #333)
  cursor: pointer
  transition-duration: 0.2s
  &:active
    transition-duration: 0.1s
    transform: scale(0.98)
</style>

結構說明

整個元件的 HTML 骨架從這一步就定型了,由外到內分為三層:

  1. 外框 div.relative:作為整個元件的定位參考點。之後的拓印(Step 7)會用 absolute 定位在這一層,所以這裡的 relative 不可少。
  2. 移動容器 div.content:之後會透過 transform: translate 來移動這個容器,按鈕本體放在裡面,就會跟著一起跑。
  3. 按鈕本體 button.btn:實際的按鈕元素。

樣式細節

  • light-dark() 是原生 CSS 函式,能根據使用者的亮色/暗色偏好自動切換顏色,不需要額外的 class 切換邏輯
  • :active 搭配 scale(0.98) 提供點擊時微妙的「按下去」回饋,且 transition-duration0.2s 縮短為 0.1s,讓按下的反應更靈敏
  • select-none 防止使用者不小心選取到按鈕文字,避免拖曳時出現藍色選取框

Step 2:追蹤滑鼠位置

按鈕要逃跑,首先得知道滑鼠在哪裡。

如果用原生 JS,你可能會想到 mousemove 事件搭配 getBoundingClientRect() 來計算相對座標。但這樣要自己處理事件綁定、解除、座標換算⋯⋯有點麻煩。

VueUse 的 useMouseInElement 幫我們把這些全包了,只要傳入一個 ref 元素,就能拿到滑鼠相對於該元素的所有資訊。

滑鼠 X:0

滑鼠 Y:0

按鈕寬:0

按鈕高:0

在按鈕外:true

查看範例原始碼
vue
<template>
  <div class="flex flex-col items-center gap-4 border border-gray-200 rounded-xl p-6">
    <div class="info text-sm">
      <p>滑鼠 X:{{ elementX.toFixed(0) }}</p>
      <p>滑鼠 Y:{{ elementY.toFixed(0) }}</p>
      <p>按鈕寬:{{ elementWidth.toFixed(0) }}</p>
      <p>按鈕高:{{ elementHeight.toFixed(0) }}</p>
      <p>在按鈕外:{{ isOutside }}</p>
    </div>

    <div class="relative">
      <div
        ref="carrierRef"
        class="content"
      >
        <button class="btn select-none rounded p-3 px-6">
          移動滑鼠到我身上
        </button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useMouseInElement } from '@vueuse/core'
import { ref } from 'vue'

const carrierRef = ref<HTMLDivElement>()
const {
  elementX,
  elementY,
  elementWidth,
  elementHeight,
  isOutside,
} = useMouseInElement(carrierRef)
</script>

<style scoped lang="sass">
.info
  font-family: monospace

.btn
  border: 1px solid light-dark(#777, #AAA)
  background-color: light-dark(#FEFEFE, #333)
  cursor: pointer
</style>

回傳值說明

回傳值說明
elementXelementY滑鼠相對於元素左上角的座標。當滑鼠在元素左上角時為 (0, 0),右下角時為 (width, height)
elementWidthelementHeight元素本身的寬高。後續步驟會用這兩個值計算按鈕中心點,也會作為每次移動的距離單位
isOutside滑鼠是否在元素範圍之外。後續用來判斷「滑鼠移入」的時機

為什麼追蹤的是 carrierRef?

我們把 ref 綁定在「移動容器」上,而非按鈕本體。因為之後容器會透過 transform 移動,而 useMouseInElement 會自動追蹤元素移動後的新位置,確保座標計算始終正確。

📚 useMouseInElement 文件

Step 3:計算逃跑方向

知道滑鼠位置後,接下來要算出按鈕該往哪個方向跑。

核心想法:從滑鼠位置指向按鈕中心的方向,就是逃跑方向。把滑鼠想像成一隻手,按鈕就是那隻不想被摸的貓,手從左邊伸過來,貓就往右邊跑。(._.`)

移動滑鼠到下方按鈕上,觀察紅色箭頭的方向變化:

中心到滑鼠向量:(0.0, 0.0)

單位向量:(0.000, 0.000)

查看範例原始碼
vue
<template>
  <div class="flex flex-col items-center gap-4 border border-gray-200 rounded-xl p-6">
    <div class="info text-sm">
      <p>中心到滑鼠向量:({{ rawDirection.x.toFixed(1) }}, {{ rawDirection.y.toFixed(1) }})</p>
      <p>單位向量:({{ unitDirection.x.toFixed(3) }}, {{ unitDirection.y.toFixed(3) }})</p>
    </div>

    <div
      ref="carrierRef"
      class="relative"
    >
      <button class="btn select-none rounded p-3 px-6">
        觀察向量變化
      </button>

      <!-- 箭頭視覺化:SVG 直接覆蓋按鈕 -->
      <svg
        class="pointer-events-none absolute inset-0 overflow-visible"
        :width="elementWidth"
        :height="elementHeight"
      >
        <defs>
          <marker
            id="arrowhead"
            markerWidth="8"
            markerHeight="6"
            refX="8"
            refY="3"
            orient="auto"
          >
            <polygon
              points="0 0, 8 3, 0 6"
              fill="#ef4444"
            />
          </marker>
        </defs>

        <!-- 從按鈕中心射出的箭頭(逃跑方向) -->
        <line
          v-if="!isOutside"
          :x1="center.x"
          :y1="center.y"
          :x2="arrowEnd.x"
          :y2="arrowEnd.y"
          stroke="#ef4444"
          stroke-width="2.5"
          marker-end="url(#arrowhead)"
        />

        <!-- 按鈕中心點 -->
        <circle
          :cx="center.x"
          :cy="center.y"
          r="3"
          fill="#ef4444"
        />
      </svg>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useMouseInElement } from '@vueuse/core'
import { computed, ref } from 'vue'

const carrierRef = ref<HTMLDivElement>()
const {
  elementX,
  elementY,
  elementWidth,
  elementHeight,
  isOutside,
} = useMouseInElement(carrierRef)

/** 按鈕中心 */
const center = computed(() => ({
  x: elementWidth.value / 2,
  y: elementHeight.value / 2,
}))

/** 從滑鼠位置指向按鈕中心的向量(逃跑方向) */
const rawDirection = computed(() => ({
  x: center.value.x - elementX.value,
  y: center.value.y - elementY.value,
}))

/** 計算向量長度 */
function getVectorLength({ x, y }: { x: number; y: number }) {
  return Math.sqrt(x * x + y * y)
}

/** 轉換為單位向量 */
const unitDirection = computed(() => {
  const { x, y } = rawDirection.value
  const magnitude = getVectorLength({ x, y })

  if (magnitude === 0)
    return { x: 0, y: 0 }

  return {
    x: x / magnitude,
    y: y / magnitude,
  }
})

const ARROW_LENGTH = 60

/** 箭頭終點:中心 + 單位向量 × 固定長度 */
const arrowEnd = computed(() => ({
  x: center.value.x + unitDirection.value.x * ARROW_LENGTH,
  y: center.value.y + unitDirection.value.y * ARROW_LENGTH,
}))
</script>

<style scoped lang="sass">
.info
  font-family: monospace

.btn
  border: 1px solid light-dark(#777, #AAA)
  background-color: light-dark(#FEFEFE, #333)
  cursor: pointer
</style>

向量計算

整個計算分為兩步:

第一步,算出原始向量。用「按鈕中心座標 - 滑鼠座標」就能得到一個從滑鼠指向中心的向量。這個向量同時包含了方向距離資訊。

ts
// 原始向量 = (centerX - mouseX, centerY - mouseY)

例如:按鈕中心在 (75, 20),滑鼠在 (25, 20),原始向量就是 (50, 0),純粹的向右。

第二步,轉為單位向量。原始向量的長度會隨滑鼠位置改變(離中心越遠,向量越長)。但我們希望每次移動的距離是固定的,所以要把向量「標準化」成長度為 1 的單位向量,只保留方向。

ts
// 向量長度(畢氏定理)
function getVectorLength({ x, y }) {
  return Math.sqrt(x * x + y * y)
}

// 單位向量 = 原始向量 / 向量長度
function getUnitVector({ x, y }) {
  const magnitude = getVectorLength({ x, y })
  return { x: x / magnitude, y: y / magnitude }
}

為什麼需要單位向量?

假設不做標準化,直接用原始向量來移動按鈕:

  • 滑鼠在按鈕邊緣碰一下 → 原始向量很短 → 按鈕只移動一點點
  • 滑鼠從遠處快速劃過 → 原始向量很長 → 按鈕飛到天邊去

這樣的行為很不穩定。轉成單位向量後,不管滑鼠在哪裡碰到按鈕,移動距離都是一致的,之後只要乘上我們想要的固定距離(按鈕寬高)就好。

Step 4:讓按鈕動起來

有了方向,就可以讓按鈕移動了!每次滑鼠碰到按鈕,就往反方向移動一個按鈕尺寸的距離。

注意範例中的紅色虛線,它連接了按鈕的原始中心與目前中心,方便觀察每次移動的距離和方向:

偏移量:(0px, 0px)

查看範例原始碼
vue
<template>
  <div class="flex flex-col items-center gap-4 border border-gray-200 rounded-xl p-6">
    <div class="info text-sm">
      <p>偏移量:({{ carrierOffset.x.toFixed(0) }}px, {{ carrierOffset.y.toFixed(0) }}px)</p>
    </div>

    <div
      ref="wrapperRef"
      class="relative"
    >
      <div
        ref="carrierRef"
        class="content"
        :style="carrierStyle"
        @mouseenter="handleRun"
      >
        <button class="btn select-none rounded p-3 px-6">
          滑鼠碰我試試
        </button>
      </div>

      <!-- 原始中心到目前位置的虛線 -->
      <svg
        v-if="hasOffset"
        class="pointer-events-none absolute inset-0 overflow-visible"
        :width="elementWidth"
        :height="elementHeight"
      >
        <!-- 虛線 -->
        <line
          :x1="originCenter.x"
          :y1="originCenter.y"
          :x2="currentCenter.x"
          :y2="currentCenter.y"
          stroke="#ef4444"
          stroke-width="1.5"
          stroke-dasharray="6 4"
        />

        <!-- 原始中心點 -->
        <circle
          :cx="originCenter.x"
          :cy="originCenter.y"
          r="3"
          fill="#ef4444"
          opacity="0.5"
        />

        <!-- 目前中心點 -->
        <circle
          :cx="currentCenter.x"
          :cy="currentCenter.y"
          r="3"
          fill="#ef4444"
        />
      </svg>
    </div>

    <button
      class="reset-btn text-sm"
      @click="back"
    >
      重設位置
    </button>
  </div>
</template>

<script setup lang="ts">
import { useMouseInElement } from '@vueuse/core'
import { computed, ref } from 'vue'

const carrierRef = ref<HTMLDivElement>()
const {
  elementX,
  elementY,
  elementWidth,
  elementHeight,
} = useMouseInElement(carrierRef)

const wrapperRef = ref<HTMLDivElement>()
const carrierOffset = ref({ x: 0, y: 0 })

const hasOffset = computed(() =>
  carrierOffset.value.x !== 0 || carrierOffset.value.y !== 0,
)

const carrierStyle = computed(() => ({
  transform: `translate(${carrierOffset.value.x}px, ${carrierOffset.value.y}px)`,
}))

/** 按鈕原始中心(相對於外層容器) */
const originCenter = computed(() => ({
  x: elementWidth.value / 2,
  y: elementHeight.value / 2,
}))

/** 按鈕目前中心 */
const currentCenter = computed(() => ({
  x: originCenter.value.x + carrierOffset.value.x,
  y: originCenter.value.y + carrierOffset.value.y,
}))

function getVectorLength({ x, y }: { x: number; y: number }) {
  return Math.sqrt(x * x + y * y)
}

function getUnitVector({ x, y }: { x: number; y: number }) {
  const magnitude = getVectorLength({ x, y })
  if (magnitude === 0)
    return { x: 0, y: 0 }
  return { x: x / magnitude, y: y / magnitude }
}

function handleRun() {
  /** 計算逃跑方向的單位向量 */
  const direction = getUnitVector({
    x: elementWidth.value / 2 - elementX.value,
    y: elementHeight.value / 2 - elementY.value,
  })

  /** 往遠離滑鼠的方向移動一個按鈕尺寸 */
  carrierOffset.value.x += direction.x * elementWidth.value
  carrierOffset.value.y += direction.y * elementHeight.value
}

function back() {
  carrierOffset.value = { x: 0, y: 0 }
}
</script>

<style scoped lang="sass">
.info
  font-family: monospace

.btn
  border: 1px solid light-dark(#777, #AAA)
  background-color: light-dark(#FEFEFE, #333)
  cursor: pointer

.reset-btn
  padding: 0.25rem 1rem
  border: 1px dashed light-dark(#999, #666)
  border-radius: 0.25rem
  cursor: pointer
</style>

移動原理

移動的關鍵是維護一個 carrierOffset 響應式物件,記錄按鈕累積的 X、Y 偏移量,再透過 computed 轉換成 CSS transform

ts
const carrierOffset = ref({ x: 0, y: 0 })

const carrierStyle = computed(() => ({
  transform: `translate(${carrierOffset.value.x}px, ${carrierOffset.value.y}px)`,
}))

每次觸發逃跑時,將「單位向量 × 按鈕尺寸」累加到偏移量上:

ts
carrierOffset.value.x += direction.x * elementWidth.value
carrierOffset.value.y += direction.y * elementHeight.value

為什麼用 transform 而不是 top/left?

  • transform 不會觸發瀏覽器 layout(重排),只觸發 composite(合成),效能好很多
  • 外層容器的 position: relative 不會因為按鈕移動而影響其他元素的排版
  • 後續加上 transition 時,transform 的動畫也更流暢

為什麼是累加?

偏移量用 += 而非 =,代表每次移動都是在前一次的位置上繼續跑。這樣連續碰按鈕好幾次,它就會越跑越遠,跟你家的貓一樣。( ・ิω・ิ)

你會發現目前按鈕會「瞬移」,沒有動畫過渡。下一步來解決。

Step 5:加入平滑過渡

上一步的按鈕是瞬間跳到新位置的,看起來像是閃現。只要在移動容器上加上 CSS transition,瞬移就會變成流暢的滑動。✧*。

查看範例原始碼
vue
<template>
  <div class="flex flex-col items-center gap-4 border border-gray-200 rounded-xl p-6">
    <div class="relative">
      <div
        ref="carrierRef"
        class="content"
        :style="carrierStyle"
        @mouseenter="handleRun"
      >
        <button class="btn select-none rounded p-3 px-6">
          現在有過渡動畫了
        </button>
      </div>
    </div>

    <button
      class="reset-btn text-sm"
      @click="back"
    >
      重設位置
    </button>
  </div>
</template>

<script setup lang="ts">
import { useMouseInElement } from '@vueuse/core'
import { computed, ref } from 'vue'

const carrierRef = ref<HTMLDivElement>()
const {
  elementX,
  elementY,
  elementWidth,
  elementHeight,
} = useMouseInElement(carrierRef)

const carrierOffset = ref({ x: 0, y: 0 })

const carrierStyle = computed(() => ({
  transform: `translate(${carrierOffset.value.x}px, ${carrierOffset.value.y}px)`,
}))

function getVectorLength({ x, y }: { x: number; y: number }) {
  return Math.sqrt(x * x + y * y)
}

function getUnitVector({ x, y }: { x: number; y: number }) {
  const magnitude = getVectorLength({ x, y })
  if (magnitude === 0)
    return { x: 0, y: 0 }
  return { x: x / magnitude, y: y / magnitude }
}

function handleRun() {
  const direction = getUnitVector({
    x: elementWidth.value / 2 - elementX.value,
    y: elementHeight.value / 2 - elementY.value,
  })

  carrierOffset.value.x += direction.x * elementWidth.value
  carrierOffset.value.y += direction.y * elementHeight.value
}

function back() {
  carrierOffset.value = { x: 0, y: 0 }
}
</script>

<style scoped lang="sass">
.content
  transition-duration: 0.6s
  transition-timing-function: cubic-bezier(0.040, 0.430, 0.025, 1.070)

.btn
  border: 1px solid light-dark(#777, #AAA)
  background-color: light-dark(#FEFEFE, #333)
  cursor: pointer
  transition-duration: 0.2s
  &:active
    transition-duration: 0.1s
    transform: scale(0.98)

.reset-btn
  padding: 0.25rem 1rem
  border: 1px dashed light-dark(#999, #666)
  border-radius: 0.25rem
  cursor: pointer
</style>

transition 設定

sass
.content
  transition-duration: 0.6s
  transition-timing-function: cubic-bezier(0.040, 0.430, 0.025, 1.070)

這裡不需要指定 transition-property,因為我們只改了 transform,瀏覽器會自動對它套用過渡。

cubic-bezier 的魔法

cubic-bezier(0.040, 0.430, 0.025, 1.070) 是一條自訂的緩動曲線。注意最後一個值 1.070 超過了 1,這代表動畫會衝過目標位置再彈回來,產生微妙的彈性效果。

對比幾種常見的 timing function:

  • ease:平順但無趣,適合一般 UI
  • ease-out:開始快、結束慢,適合元素進場
  • 我們的曲線:結尾會「過頭」一點,讓按鈕看起來像是被彈走的,更有物理感

小工具

可以用 cubic-bezier.com 視覺化調整曲線,即時預覽效果。

Step 6:果凍彈跳動畫

按鈕滑過去了,但少了一點「活物感」。加上果凍般的形變動畫,讓按鈕每次被碰到時像 Q 彈的果凍一樣抖一下。(●'◡'●)

查看範例原始碼
vue
<template>
  <div class="flex flex-col items-center gap-4 border border-gray-200 rounded-xl p-6">
    <div class="relative">
      <div
        ref="carrierRef"
        class="content"
        :style="carrierStyle"
        @mouseenter="handleRun"
      >
        <!-- 用 key 觸發彈跳動畫重播 -->
        <div
          :key="counter"
          :style="bounceStyle"
          class="jelly-bounce"
        >
          <button class="btn select-none rounded p-3 px-6">
            QQ 彈彈的
          </button>
        </div>
      </div>
    </div>

    <button
      class="reset-btn text-sm"
      @click="back"
    >
      重設位置
    </button>
  </div>
</template>

<script setup lang="ts">
import { useMouseInElement } from '@vueuse/core'
import { computed, ref } from 'vue'

const carrierRef = ref<HTMLDivElement>()
const {
  elementX,
  elementY,
  elementWidth,
  elementHeight,
} = useMouseInElement(carrierRef)

const carrierOffset = ref({ x: 0, y: 0 })
const counter = ref(0)

const carrierStyle = computed(() => ({
  transform: `translate(${carrierOffset.value.x}px, ${carrierOffset.value.y}px)`,
}))

/** 初始化時不播放動畫 */
const bounceStyle = computed(() => ({
  animationPlayState: counter.value === 0 ? 'paused' : 'running',
}))

function getVectorLength({ x, y }: { x: number; y: number }) {
  return Math.sqrt(x * x + y * y)
}

function getUnitVector({ x, y }: { x: number; y: number }) {
  const magnitude = getVectorLength({ x, y })
  if (magnitude === 0)
    return { x: 0, y: 0 }
  return { x: x / magnitude, y: y / magnitude }
}

function handleRun() {
  const direction = getUnitVector({
    x: elementWidth.value / 2 - elementX.value,
    y: elementHeight.value / 2 - elementY.value,
  })

  carrierOffset.value.x += direction.x * elementWidth.value
  carrierOffset.value.y += direction.y * elementHeight.value
  counter.value += 1
}

function back() {
  carrierOffset.value = { x: 0, y: 0 }
  counter.value = 0
}
</script>

<style scoped lang="sass">
.content
  transition-duration: 0.6s
  transition-timing-function: cubic-bezier(0.040, 0.430, 0.025, 1.070)

.jelly-bounce
  animation: jelly-bounce 0.6s forwards

@keyframes jelly-bounce
  0%, 100%
    transform: scale(1, 1)
  50%
    transform: scale(1.2, 0.8)
  80%
    transform: scale(0.9, 1.1)

.btn
  border: 1px solid light-dark(#777, #AAA)
  background-color: light-dark(#FEFEFE, #333)
  cursor: pointer
  transition-duration: 0.2s
  &:active
    transition-duration: 0.1s
    transform: scale(0.98)

.reset-btn
  padding: 0.25rem 1rem
  border: 1px dashed light-dark(#999, #666)
  border-radius: 0.25rem
  cursor: pointer
</style>

keyframes 動畫設計

sass
@keyframes jelly-bounce
  0%, 100%
    transform: scale(1, 1)     // 原始大小
  50%
    transform: scale(1.2, 0.8) // 橫向壓扁
  80%
    transform: scale(0.9, 1.1) // 縱向拉長

動畫分三個階段模擬果凍的物理效果:

  1. 被碰到的瞬間先橫向壓扁(像果凍被拍了一下)
  2. 接著縱向回彈拉長(彈性恢復)
  3. 最後回到原始大小

整個動畫只有 0.6s,短而快速,搭配移動的 transition 同時播放,視覺上就像是被「彈飛」的。

用 :key 重播動畫

CSS 動畫只會在 DOM 節點建立時播放一次。如果按鈕連續被碰好幾次,要怎麼重播?

技巧是在彈跳容器上綁定 :key="counter"

vue
<div :key="counter" class="jelly-bounce">
  <!-- 按鈕 -->
</div>

每次 counter 改變,Vue 會認為這是一個「新節點」,銷毀舊的、建立新的,CSS 動畫就會從頭播放。這是 Vue 中重播動畫的經典技巧。

避免初始動畫

頁面載入時 counter 為 0,但 DOM 節點已經建立了,動畫會自動播放一次。用 animationPlayState 來阻止:

ts
const bounceStyle = computed(() => ({
  animationPlayState: counter.value === 0
    ? 'paused'
    : 'running',
}))

counter 為 0 時暫停動畫,第一次觸發後才開始播放,避免載入時莫名其妙抖一下。

Step 7:拓印效果

按鈕跑掉了,但使用者可能會困惑:「按鈕原本在哪裡?」

解法是在按鈕的原始位置留下一個虛線外框,就像蓋章留下的「拓印」一樣,讓使用者一眼就知道按鈕從哪裡跑走的。

查看範例原始碼
vue
<template>
  <div class="flex flex-col items-center gap-4 border border-gray-200 rounded-xl p-6">
    <div class="relative">
      <!-- 拓印:留在原位的虛線外框 -->
      <div class="pointer-events-none absolute inset-0">
        <div class="btn-rubbing h-full w-full rounded" />
      </div>

      <!-- 會移動的按鈕 -->
      <div
        ref="carrierRef"
        class="content"
        :style="carrierStyle"
        @mouseenter="handleRun"
      >
        <div
          :key="counter"
          :style="bounceStyle"
          class="jelly-bounce"
        >
          <button class="btn select-none rounded p-3 px-6">
            拓印留下了
          </button>
        </div>
      </div>
    </div>

    <button
      class="reset-btn text-sm"
      @click="back"
    >
      重設位置
    </button>
  </div>
</template>

<script setup lang="ts">
import { useMouseInElement } from '@vueuse/core'
import { computed, ref } from 'vue'

const carrierRef = ref<HTMLDivElement>()
const {
  elementX,
  elementY,
  elementWidth,
  elementHeight,
} = useMouseInElement(carrierRef)

const carrierOffset = ref({ x: 0, y: 0 })
const counter = ref(0)

const carrierStyle = computed(() => ({
  transform: `translate(${carrierOffset.value.x}px, ${carrierOffset.value.y}px)`,
}))

const bounceStyle = computed(() => ({
  animationPlayState: counter.value === 0 ? 'paused' : 'running',
}))

function getVectorLength({ x, y }: { x: number; y: number }) {
  return Math.sqrt(x * x + y * y)
}

function getUnitVector({ x, y }: { x: number; y: number }) {
  const magnitude = getVectorLength({ x, y })
  if (magnitude === 0)
    return { x: 0, y: 0 }
  return { x: x / magnitude, y: y / magnitude }
}

function handleRun() {
  const direction = getUnitVector({
    x: elementWidth.value / 2 - elementX.value,
    y: elementHeight.value / 2 - elementY.value,
  })

  carrierOffset.value.x += direction.x * elementWidth.value
  carrierOffset.value.y += direction.y * elementHeight.value
  counter.value += 1
}

function back() {
  carrierOffset.value = { x: 0, y: 0 }
  counter.value = 0
}
</script>

<style scoped lang="sass">
.content
  transition-duration: 0.6s
  transition-timing-function: cubic-bezier(0.040, 0.430, 0.025, 1.070)

.jelly-bounce
  animation: jelly-bounce 0.6s forwards

@keyframes jelly-bounce
  0%, 100%
    transform: scale(1, 1)
  50%
    transform: scale(1.2, 0.8)
  80%
    transform: scale(0.9, 1.1)

.btn-rubbing
  border: 1px dashed rgba(black, 0.2)

.btn
  border: 1px solid light-dark(#777, #AAA)
  background-color: light-dark(#FEFEFE, #333)
  cursor: pointer
  transition-duration: 0.2s
  &:active
    transition-duration: 0.1s
    transform: scale(0.98)

.reset-btn
  padding: 0.25rem 1rem
  border: 1px dashed light-dark(#999, #666)
  border-radius: 0.25rem
  cursor: pointer
</style>

實作方式

拓印的巧妙之處在於它完全不需要 JavaScript,純粹靠 CSS 定位就能完成:

vue
<div class="relative">
  <!-- 拓印:固定在原位 -->
  <div class="pointer-events-none absolute inset-0">
    <div class="btn-rubbing h-full w-full rounded" />
  </div>

  <!-- 會移動的按鈕 -->
  <div class="content" :style="carrierStyle">
    ...
  </div>
</div>

為什麼拓印不會跟著移動?

外層 div.relative 是定位參考點。拓印用 absolute inset-0 鎖定在這個參考點上,大小完全等於外層容器。

而按鈕的移動是透過 transform: translate 實現的,transform 只改變元素的視覺呈現,不會影響外層容器的尺寸或位置。所以不管按鈕跑多遠,拓印永遠留在原地。

兩個重要的 CSS 細節

  • pointer-events-none:拓印疊在按鈕上方(DOM 順序在前),如果不加這個,滑鼠事件會被拓印攔截,按鈕就再也觸發不了 hover 了
  • border: 1px dashed:用虛線而非實線,視覺上暗示「這裡原本有東西,但現在空了」

Step 8:限制移動距離

目前的按鈕可以無限逃跑,跑到十萬八千里外都不會停。這對使用者來說很困擾,按鈕跑太遠就找不到了,等於整個 UI 壞掉。

所以我們需要設定一個「活動範圍」:按鈕跑超過這個範圍就自動返回原點,像是被隱形的繩子拉回來一樣。

目前距離:0px

最大距離:0px

查看範例原始碼
vue
<template>
  <div class="flex flex-col items-center gap-4 border border-gray-200 rounded-xl p-6">
    <div class="info text-sm">
      <p>目前距離:{{ currentDistance.toFixed(0) }}px</p>
      <p>最大距離:{{ maxDistance.toFixed(0) }}px</p>
    </div>

    <div class="relative">
      <div class="pointer-events-none absolute inset-0">
        <div class="btn-rubbing h-full w-full rounded" />
      </div>

      <div
        ref="carrierRef"
        class="content"
        :style="carrierStyle"
        @mouseenter="handleRun"
      >
        <div
          :key="counter"
          :style="bounceStyle"
          class="jelly-bounce"
        >
          <button class="btn select-none rounded p-3 px-6">
            跑太遠會回來
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useMouseInElement } from '@vueuse/core'
import { computed, ref } from 'vue'

const MAX_DISTANCE_MULTIPLE = 3

const carrierRef = ref<HTMLDivElement>()
const {
  elementX,
  elementY,
  elementWidth,
  elementHeight,
} = useMouseInElement(carrierRef)

const carrierOffset = ref({ x: 0, y: 0 })
const counter = ref(0)

const carrierStyle = computed(() => ({
  transform: `translate(${carrierOffset.value.x}px, ${carrierOffset.value.y}px)`,
}))

const bounceStyle = computed(() => ({
  animationPlayState: counter.value === 0 ? 'paused' : 'running',
}))

function getVectorLength({ x, y }: { x: number; y: number }) {
  return Math.sqrt(x * x + y * y)
}

function getUnitVector({ x, y }: { x: number; y: number }) {
  const magnitude = getVectorLength({ x, y })
  if (magnitude === 0)
    return { x: 0, y: 0 }
  return { x: x / magnitude, y: y / magnitude }
}

const currentDistance = computed(() =>
  getVectorLength(carrierOffset.value),
)

const maxDistance = computed(() =>
  getVectorLength({
    x: elementWidth.value * MAX_DISTANCE_MULTIPLE,
    y: elementHeight.value * MAX_DISTANCE_MULTIPLE,
  }),
)

function back() {
  carrierOffset.value = { x: 0, y: 0 }
  counter.value = 0
}

function handleRun() {
  const direction = getUnitVector({
    x: elementWidth.value / 2 - elementX.value,
    y: elementHeight.value / 2 - elementY.value,
  })

  carrierOffset.value.x += direction.x * elementWidth.value
  carrierOffset.value.y += direction.y * elementHeight.value
  counter.value += 1

  /** 超出限制距離就回歸原點 */
  const distance = getVectorLength(carrierOffset.value)
  const limit = getVectorLength({
    x: elementWidth.value * MAX_DISTANCE_MULTIPLE,
    y: elementHeight.value * MAX_DISTANCE_MULTIPLE,
  })

  if (distance > limit) {
    back()
  }
}
</script>

<style scoped lang="sass">
.info
  font-family: monospace

.content
  transition-duration: 0.6s
  transition-timing-function: cubic-bezier(0.040, 0.430, 0.025, 1.070)

.jelly-bounce
  animation: jelly-bounce 0.6s forwards

@keyframes jelly-bounce
  0%, 100%
    transform: scale(1, 1)
  50%
    transform: scale(1.2, 0.8)
  80%
    transform: scale(0.9, 1.1)

.btn-rubbing
  border: 1px dashed rgba(black, 0.2)

.btn
  border: 1px solid light-dark(#777, #AAA)
  background-color: light-dark(#FEFEFE, #333)
  cursor: pointer
  transition-duration: 0.2s
  &:active
    transition-duration: 0.1s
    transform: scale(0.98)
</style>

距離計算

最大距離以按鈕自身尺寸的倍數來定義,而非固定的像素值。這樣不管按鈕大小如何變化,活動範圍都是合理的。

ts
const MAX_DISTANCE_MULTIPLE = 3

倍數設為 3,代表按鈕最遠只能跑到「自身尺寸的 3 倍距離」。每次移動後都要檢查:

ts
// 最大允許距離
const limit = getVectorLength({
  x: elementWidth.value * MAX_DISTANCE_MULTIPLE,
  y: elementHeight.value * MAX_DISTANCE_MULTIPLE,
})

// 目前按鈕離原點的距離
const distance = getVectorLength(carrierOffset.value)

if (distance > limit) {
  back() // 超過了,回歸原點
}

這裡同樣用到 getVectorLength(畢氏定理)來計算二維平面上的距離,和 Step 3 的邏輯一脈相承。

為什麼是回歸原點,而不是停在邊界?

停在邊界的實作更複雜(要計算邊界上的投影座標),而且體驗上不一定更好,按鈕卡在邊界動彈不得反而會讓使用者更挫折。回歸原點後使用者可以「再試一次」,互動感更強。

在最終元件中,這個倍數會透過 maxDistanceMultiple prop 開放讓使用者自訂。

Step 9:畫面外自動返回

Step 8 限制了按鈕的最大移動距離,但有些情況距離限制管不到,例如按鈕本身就放在頁面邊緣,跑個一兩次就已經超出可視範圍了。

這時候需要另一道防線:偵測按鈕是否還在畫面中,如果跑出去了就自動回來。

查看範例原始碼
vue
<template>
  <div class="flex flex-col items-center gap-4 border border-gray-200 rounded-xl p-6">
    <div class="relative">
      <div class="pointer-events-none absolute inset-0">
        <div class="btn-rubbing h-full w-full rounded" />
      </div>

      <div
        ref="carrierRef"
        class="content"
        :style="carrierStyle"
        @mouseenter="handleRun"
      >
        <div
          :key="counter"
          :style="bounceStyle"
          class="jelly-bounce"
        >
          <button class="btn select-none rounded p-3 px-6">
            跑出畫面會回來
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useIntersectionObserver, useMouseInElement } from '@vueuse/core'
import { computed, ref } from 'vue'

const MAX_DISTANCE_MULTIPLE = 3

const carrierRef = ref<HTMLDivElement>()
const {
  elementX,
  elementY,
  elementWidth,
  elementHeight,
} = useMouseInElement(carrierRef)

const carrierOffset = ref({ x: 0, y: 0 })
const counter = ref(0)

const carrierStyle = computed(() => ({
  transform: `translate(${carrierOffset.value.x}px, ${carrierOffset.value.y}px)`,
}))

const bounceStyle = computed(() => ({
  animationPlayState: counter.value === 0 ? 'paused' : 'running',
}))

function getVectorLength({ x, y }: { x: number; y: number }) {
  return Math.sqrt(x * x + y * y)
}

function getUnitVector({ x, y }: { x: number; y: number }) {
  const magnitude = getVectorLength({ x, y })
  if (magnitude === 0)
    return { x: 0, y: 0 }
  return { x: x / magnitude, y: y / magnitude }
}

function back() {
  carrierOffset.value = { x: 0, y: 0 }
  counter.value = 0
}

function handleRun() {
  const direction = getUnitVector({
    x: elementWidth.value / 2 - elementX.value,
    y: elementHeight.value / 2 - elementY.value,
  })

  carrierOffset.value.x += direction.x * elementWidth.value
  carrierOffset.value.y += direction.y * elementHeight.value
  counter.value += 1

  const distance = getVectorLength(carrierOffset.value)
  const limit = getVectorLength({
    x: elementWidth.value * MAX_DISTANCE_MULTIPLE,
    y: elementHeight.value * MAX_DISTANCE_MULTIPLE,
  })

  if (distance > limit) {
    back()
  }
}

/** 按鈕被遮擋或跑出畫面時,自動返回原位 */
useIntersectionObserver(carrierRef, (entryList) => {
  if (entryList[0]?.isIntersecting)
    return
  back()
})
</script>

<style scoped lang="sass">
.content
  transition-duration: 0.6s
  transition-timing-function: cubic-bezier(0.040, 0.430, 0.025, 1.070)

.jelly-bounce
  animation: jelly-bounce 0.6s forwards

@keyframes jelly-bounce
  0%, 100%
    transform: scale(1, 1)
  50%
    transform: scale(1.2, 0.8)
  80%
    transform: scale(0.9, 1.1)

.btn-rubbing
  border: 1px dashed rgba(black, 0.2)

.btn
  border: 1px solid light-dark(#777, #AAA)
  background-color: light-dark(#FEFEFE, #333)
  cursor: pointer
  transition-duration: 0.2s
  &:active
    transition-duration: 0.1s
    transform: scale(0.98)
</style>

IntersectionObserver 是什麼?

IntersectionObserver 是瀏覽器原生 API,可以非同步地觀察一個元素與其祖先容器(或 viewport)的交叉狀態。白話說就是:告訴你某個元素是否還在畫面中

它和 scroll 事件監聽的差異在於:

  • scroll 事件在每次滾動時都會高頻觸發,需要自己做節流
  • IntersectionObserver 由瀏覽器內部排程,只在交叉狀態變化時才觸發 callback,效能好很多

搭配 VueUse 使用

VueUse 的 useIntersectionObserver 封裝了建立、綁定、銷毀的生命週期,只需關注 callback 邏輯:

ts
useIntersectionObserver(carrierRef, (entryList) => {
  // isIntersecting 為 true 代表元素還在畫面中
  if (entryList[0]?.isIntersecting)
    return
  // 不在畫面中了,自動回歸原點
  back()
})

entryList 是一個陣列,但因為我們只觀察一個元素,所以直接取 [0] 就好。

和 Step 8 的互補關係

這兩道防線各司其職:

  • Step 8(距離限制):處理「按鈕在頁面中央,可以跑很遠」的情況
  • Step 9(畫面偵測):處理「按鈕在頁面邊緣,跑一點就超出視窗」的情況

兩者結合才能確保按鈕無論如何都不會「失蹤」。

📚 IntersectionObserver MDN 文件

注意!Σ(ˊДˋ;)

父層容器不要設定 overflow: hidden,否則按鈕一移動就會被裁切,IntersectionObserver 會立刻偵測到「不在畫面中」,按鈕瞬間彈回,看起來就像完全不會動一樣。

Step 10:disabled 狀態控制

最後一步!前面九個步驟建立的按鈕「永遠都在逃跑」,但實際使用場景是:只有在 disabled 時才需要逃跑,正常狀態就是一顆乖巧的按鈕

勾選下方的 checkbox 來切換 disabled 狀態,觀察按鈕行為的變化:

查看範例原始碼
vue
<template>
  <div class="flex flex-col items-center gap-4 border border-gray-200 rounded-xl p-6">
    <label class="flex items-center gap-2 text-sm">
      <input
        v-model="disabled"
        type="checkbox"
      >
      停用按鈕(disabled)
    </label>

    <div class="relative">
      <div class="pointer-events-none absolute inset-0">
        <div class="btn-rubbing h-full w-full rounded" />
      </div>

      <div
        ref="carrierRef"
        class="content"
        :style="carrierStyle"
        @mouseenter="handleTrigger"
        @click="handleClick"
      >
        <div
          :key="counter"
          :style="bounceStyle"
          class="jelly-bounce"
        >
          <button
            class="btn select-none rounded p-3 px-6"
            :style="{ cursor: disabled ? 'not-allowed' : 'pointer' }"
          >
            {{ disabled ? '抓不到我~' : '正常按鈕' }}
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useIntersectionObserver, useMouseInElement } from '@vueuse/core'
import { computed, ref, watch } from 'vue'

const MAX_DISTANCE_MULTIPLE = 3

const disabled = ref(true)
const carrierRef = ref<HTMLDivElement>()
const {
  elementX,
  elementY,
  elementWidth,
  elementHeight,
  isOutside,
} = useMouseInElement(carrierRef)

const carrierOffset = ref({ x: 0, y: 0 })
const counter = ref(0)

const carrierStyle = computed(() => ({
  transform: `translate(${carrierOffset.value.x}px, ${carrierOffset.value.y}px)`,
}))

const bounceStyle = computed(() => ({
  animationPlayState: counter.value === 0 ? 'paused' : 'running',
}))

function getVectorLength({ x, y }: { x: number; y: number }) {
  return Math.sqrt(x * x + y * y)
}

function getUnitVector({ x, y }: { x: number; y: number }) {
  const magnitude = getVectorLength({ x, y })
  if (magnitude === 0)
    return { x: 0, y: 0 }
  return { x: x / magnitude, y: y / magnitude }
}

function back() {
  carrierOffset.value = { x: 0, y: 0 }
  counter.value = 0
}

function run() {
  const direction = getUnitVector({
    x: elementWidth.value / 2 - elementX.value,
    y: elementHeight.value / 2 - elementY.value,
  })

  carrierOffset.value.x += direction.x * elementWidth.value
  carrierOffset.value.y += direction.y * elementHeight.value
  counter.value += 1

  const distance = getVectorLength(carrierOffset.value)
  const limit = getVectorLength({
    x: elementWidth.value * MAX_DISTANCE_MULTIPLE,
    y: elementHeight.value * MAX_DISTANCE_MULTIPLE,
  })

  if (distance > limit) {
    back()
  }
}

/** 只有在 disabled 時才觸發逃跑 */
function handleTrigger() {
  if (!disabled.value)
    return
  run()
}

function handleClick() {
  if (!disabled.value)
    return
  run()
}

/** disabled 解除時回歸原位 */
watch(disabled, (value) => {
  if (value)
    return
  back()
})

/** 滑鼠已經在按鈕上,disabled 變化時觸發 */
watch(isOutside, (value) => {
  if (value || !disabled.value)
    return
  run()
})

useIntersectionObserver(carrierRef, (entryList) => {
  if (entryList[0]?.isIntersecting)
    return
  back()
})
</script>

<style scoped lang="sass">
.content
  transition-duration: 0.6s
  transition-timing-function: cubic-bezier(0.040, 0.430, 0.025, 1.070)

.jelly-bounce
  animation: jelly-bounce 0.6s forwards

@keyframes jelly-bounce
  0%, 100%
    transform: scale(1, 1)
  50%
    transform: scale(1.2, 0.8)
  80%
    transform: scale(0.9, 1.1)

.btn-rubbing
  border: 1px dashed rgba(black, 0.2)

.btn
  border: 1px solid light-dark(#777, #AAA)
  background-color: light-dark(#FEFEFE, #333)
  transition-duration: 0.2s
  &:active
    transition-duration: 0.1s
    transform: scale(0.98)
</style>

核心邏輯:事件守衛

所有觸發逃跑的事件(mouseenterclick)都加上 disabled 檢查,沒停用就不跑:

ts
function handleTrigger() {
  if (!disabled.value)
    return
  run()
}

function handleClick() {
  if (!disabled.value)
    return
  run()
}

狀態切換的邊界處理

除了基本的事件守衛,還有兩個容易忽略的邊界情況需要處理。

disabled 從 true 變 false

使用者填完表單了,按鈕解除停用。此時按鈕可能已經跑到很遠的地方,需要自動回歸原位:

ts
watch(disabled, (value) => {
  if (value)
    return
  back()
})

滑鼠已在按鈕上,disabled 才變 true

假設使用者的滑鼠一直放在按鈕上,然後透過其他操作(例如清空了表單欄位)讓 disabled 變成 true。

這時候 mouseenter 不會重新觸發(因為滑鼠根本沒離開過),所以要另外監聽 isOutside 的變化:

ts
watch(isOutside, (value) => {
  // 滑鼠在外面,或沒有 disabled,不處理
  if (value || !disabled.value)
    return
  run()
})

這個 watch 的觸發時機是:滑鼠從「外面」進到「裡面」。搭配 disabled 檢查就能涵蓋所有情境。

完成!🎉

恭喜你走完了所有步驟!讓我們回顧一下整個開發歷程:

步驟概念關鍵技術
1靜態按鈕HTML + CSS 基礎結構
2追蹤滑鼠useMouseInElement
3計算方向向量運算、單位向量
4移動按鈕transform: translate
5平滑過渡CSS transition + cubic-bezier
6果凍彈跳CSS @keyframes + :key 重播技巧
7拓印效果absolute 定位 + pointer-events-none
8距離限制向量長度比較
9畫面外返回IntersectionObserver
10disabled 控制watch + 事件判斷

最終的元件還加入了更多細節,例如 slot 自定義、事件 emit、throttle 節流等。

完整元件請見:調皮的按鈕 🐟

元件架構

最後,讓我們用一張架構圖來回顧整個元件的 DOM 結構。整個元件由外到內共有五層,各司其職:

按鈕 按鈕本體slot#default使用者自訂內容Step 1div.jelly-bounce:key · @keyframes彈跳容器Step 6div.contenttransform · transition移動容器Step 4, 5拓印容器div.absolute.inset-0pointer-events: noneStep 7div.relative定位參考點外框

各層的職責:

層級元素職責
外框div.relative定位參考點,拓印和按鈕都以此為基準
拓印容器div.absolute.inset-0固定在原位不動,放置拓印(按鈕跑掉後的虛線殘影)
移動容器div.content透過 transform: translate 移動,帶著按鈕一起跑。同時掛載 transition 動畫、事件監聽和 tabindex
彈跳容器div.jelly-bounce每次移動時播放果凍彈跳的 @keyframes 動畫。利用 :key 切換來觸發動畫重播
按鈕本體slot#default預設的按鈕或使用者自訂的內容

為什麼要分這麼多層?因為每一層只負責一件事:

  • 移動彈跳分開,是因為兩者的 transform 會互相覆蓋。移動用 translate、彈跳用 scale,放在不同層就能各自獨立運作
  • 拓印按鈕分開,是因為拓印要固定在原位,而按鈕要跟著移動容器跑

v0.59.1