每條游來游去的小魚,都是正在瀏覽這一頁的訪客,有一條是你自己 ( ´ ▽ ` )ノ 覺得干擾閱讀的話,可以把小魚關掉。
調皮的按鈕
step-by-step完成品請見調皮的按鈕元件。
停用時會越跑越遠的按鈕。ᕕ( ゚ ∀。)ᕗ
越想嚕他,就跑得越遠,和你家的貓一樣。(._.`)
我們將把這個元件的開發過程拆解為 10 個小步驟,每一步都只加入一個新概念。
Step 1:靜態按鈕
Section titled “Step 1:靜態按鈕”萬事起頭難,先從一個靜態按鈕開始吧。( ´ ▽ ` )ノ
這個步驟很單純,就是一個帶有基本樣式的按鈕。之後所有的互動行為都會在這個基礎上疊加。
<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 骨架從這一步就定型了,由外到內分為三層:
- 外框
div.relative:作為整個元件的定位參考點。之後的拓印(Step 7)會用absolute定位在這一層,所以這裡的relative不可少。 - 移動容器
div.content:之後會透過transform: translate來移動這個容器,按鈕本體放在裡面,就會跟著一起跑。 - 按鈕本體
button.btn:實際的按鈕元素。
light-dark()是原生 CSS 函式,能根據使用者的亮色/暗色偏好自動切換顏色,不需要額外的 class 切換邏輯:active搭配scale(0.98)提供點擊時微妙的「按下去」回饋,且transition-duration從0.2s縮短為0.1s,讓按下的反應更靈敏select-none防止使用者不小心選取到按鈕文字,避免拖曳時出現藍色選取框
Step 2:追蹤滑鼠位置
Section titled “Step 2:追蹤滑鼠位置”按鈕要逃跑,首先得知道滑鼠在哪裡。
如果用原生 JS,你可能會想到 mousemove 事件搭配 getBoundingClientRect() 來計算相對座標。但這樣要自己處理事件綁定、解除、座標換算⋯⋯有點麻煩。
VueUse 的 useMouseInElement 幫我們把這些全包了,只要傳入一個 ref 元素,就能拿到滑鼠相對於該元素的所有資訊。
滑鼠 X:0
滑鼠 Y:0
按鈕寬:0
按鈕高:0
在按鈕外:true
<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>| 回傳值 | 說明 |
|---|---|
elementX、elementY | 滑鼠相對於元素左上角的座標。當滑鼠在元素左上角時為 (0, 0),右下角時為 (width, height) |
elementWidth、elementHeight | 元素本身的寬高。後續步驟會用這兩個值計算按鈕中心點,也會作為每次移動的距離單位 |
isOutside | 滑鼠是否在元素範圍之外。後續用來判斷「滑鼠移入」的時機 |
為什麼追蹤的是 carrierRef?
Section titled “為什麼追蹤的是 carrierRef?”我們把 ref 綁定在「移動容器」上,而非按鈕本體。因為之後容器會透過 transform 移動,而 useMouseInElement 會自動追蹤元素移動後的新位置,確保座標計算始終正確。
Step 3:計算逃跑方向
Section titled “Step 3:計算逃跑方向”知道滑鼠位置後,接下來要算出按鈕該往哪個方向跑。
核心想法:從滑鼠位置指向按鈕中心的方向,就是逃跑方向。把滑鼠想像成一隻手,按鈕就是那隻不想被摸的貓,手從左邊伸過來,貓就往右邊跑。(._.`)
移動滑鼠到下方按鈕上,觀察紅色箭頭的方向變化:
中心到滑鼠向量:(0.0, 0.0)
單位向量:(0.000, 0.000)
<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>整個計算分為兩步:
第一步,算出原始向量。用「按鈕中心座標 - 滑鼠座標」就能得到一個從滑鼠指向中心的向量。這個向量同時包含了方向和距離資訊。
// 原始向量 = (centerX - mouseX, centerY - mouseY)例如:按鈕中心在 (75, 20),滑鼠在 (25, 20),原始向量就是 (50, 0),純粹的向右。
第二步,轉為單位向量。原始向量的長度會隨滑鼠位置改變(離中心越遠,向量越長)。但我們希望每次移動的距離是固定的,所以要把向量「標準化」成長度為 1 的單位向量,只保留方向。
// 向量長度(畢氏定理)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 }}為什麼需要單位向量?
Section titled “為什麼需要單位向量?”假設不做標準化,直接用原始向量來移動按鈕:
- 滑鼠在按鈕邊緣碰一下 → 原始向量很短 → 按鈕只移動一點點
- 滑鼠從遠處快速劃過 → 原始向量很長 → 按鈕飛到天邊去
這樣的行為很不穩定。轉成單位向量後,不管滑鼠在哪裡碰到按鈕,移動距離都是一致的,之後只要乘上我們想要的固定距離(按鈕寬高)就好。
Step 4:讓按鈕動起來
Section titled “Step 4:讓按鈕動起來”有了方向,就可以讓按鈕移動了!每次滑鼠碰到按鈕,就往反方向移動一個按鈕尺寸的距離。
注意範例中的紅色虛線,它連接了按鈕的原始中心與目前中心,方便觀察每次移動的距離和方向:
偏移量:(0px, 0px)
<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:
const carrierOffset = ref({ x: 0, y: 0 })
const carrierStyle = computed(() => ({ transform: `translate(${carrierOffset.value.x}px, ${carrierOffset.value.y}px)`,}))每次觸發逃跑時,將「單位向量 × 按鈕尺寸」累加到偏移量上:
carrierOffset.value.x += direction.x * elementWidth.valuecarrierOffset.value.y += direction.y * elementHeight.value為什麼用 transform 而不是 top/left?
Section titled “為什麼用 transform 而不是 top/left?”transform不會觸發瀏覽器 layout(重排),只觸發 composite(合成),效能好很多- 外層容器的
position: relative不會因為按鈕移動而影響其他元素的排版 - 後續加上
transition時,transform的動畫也更流暢
為什麼是累加?
Section titled “為什麼是累加?”偏移量用 += 而非 =,代表每次移動都是在前一次的位置上繼續跑。這樣連續碰按鈕好幾次,它就會越跑越遠,跟你家的貓一樣。( ・ิω・ิ)
你會發現目前按鈕會「瞬移」,沒有動畫過渡。下一步來解決。
Step 5:加入平滑過渡
Section titled “Step 5:加入平滑過渡”上一步的按鈕是瞬間跳到新位置的,看起來像是閃現。只要在移動容器上加上 CSS transition,瞬移就會變成流暢的滑動。✧*。
<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 設定
Section titled “transition 設定”.content transition-duration: 0.6s transition-timing-function: cubic-bezier(0.040, 0.430, 0.025, 1.070)這裡不需要指定 transition-property,因為我們只改了 transform,瀏覽器會自動對它套用過渡。
cubic-bezier 的魔法
Section titled “cubic-bezier 的魔法”cubic-bezier(0.040, 0.430, 0.025, 1.070) 是一條自訂的緩動曲線。注意最後一個值 1.070 超過了 1,這代表動畫會衝過目標位置再彈回來,產生微妙的彈性效果。
對比幾種常見的 timing function:
ease:平順但無趣,適合一般 UIease-out:開始快、結束慢,適合元素進場- 我們的曲線:結尾會「過頭」一點,讓按鈕看起來像是被彈走的,更有物理感
Step 6:果凍彈跳動畫
Section titled “Step 6:果凍彈跳動畫”按鈕滑過去了,但少了一點「活物感」。加上果凍般的形變動畫,讓按鈕每次被碰到時像 Q 彈的果凍一樣抖一下。(●'◡'●)
<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 動畫設計
Section titled “keyframes 動畫設計”@keyframes jelly-bounce 0%, 100% transform: scale(1, 1) // 原始大小 50% transform: scale(1.2, 0.8) // 橫向壓扁 80% transform: scale(0.9, 1.1) // 縱向拉長動畫分三個階段模擬果凍的物理效果:
- 被碰到的瞬間先橫向壓扁(像果凍被拍了一下)
- 接著縱向回彈拉長(彈性恢復)
- 最後回到原始大小
整個動畫只有 0.6s,短而快速,搭配移動的 transition 同時播放,視覺上就像是被「彈飛」的。
用 :key 重播動畫
Section titled “用 :key 重播動畫”CSS 動畫只會在 DOM 節點建立時播放一次。如果按鈕連續被碰好幾次,要怎麼重播?
技巧是在彈跳容器上綁定 :key="counter":
<div :key="counter" class="jelly-bounce"> <!-- 按鈕 --></div>每次 counter 改變,Vue 會認為這是一個「新節點」,銷毀舊的、建立新的,CSS 動畫就會從頭播放。這是 Vue 中重播動畫的經典技巧。
避免初始動畫
Section titled “避免初始動畫”頁面載入時 counter 為 0,但 DOM 節點已經建立了,動畫會自動播放一次。用 animationPlayState 來阻止:
const bounceStyle = computed(() => ({ animationPlayState: counter.value === 0 ? 'paused' : 'running',}))counter 為 0 時暫停動畫,第一次觸發後才開始播放,避免載入時莫名其妙抖一下。
Step 7:拓印效果
Section titled “Step 7:拓印效果”按鈕跑掉了,但使用者可能會困惑:「按鈕原本在哪裡?」
解法是在按鈕的原始位置留下一個虛線外框,就像蓋章留下的「拓印」一樣,讓使用者一眼就知道按鈕從哪裡跑走的。
<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 定位就能完成:
<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>為什麼拓印不會跟著移動?
Section titled “為什麼拓印不會跟著移動?”外層 div.relative 是定位參考點。拓印用 absolute inset-0 鎖定在這個參考點上,大小完全等於外層容器。
而按鈕的移動是透過 transform: translate 實現的,transform 只改變元素的視覺呈現,不會影響外層容器的尺寸或位置。所以不管按鈕跑多遠,拓印永遠留在原地。
兩個重要的 CSS 細節
Section titled “兩個重要的 CSS 細節”pointer-events-none:拓印疊在按鈕上方(DOM 順序在前),如果不加這個,滑鼠事件會被拓印攔截,按鈕就再也觸發不了 hover 了border: 1px dashed:用虛線而非實線,視覺上暗示「這裡原本有東西,但現在空了」
Step 8:限制移動距離
Section titled “Step 8:限制移動距離”目前的按鈕可以無限逃跑,跑到十萬八千里外都不會停。這對使用者來說很困擾,按鈕跑太遠就找不到了,等於整個 UI 壞掉。
所以我們需要設定一個「活動範圍」:按鈕跑超過這個範圍就自動返回原點,像是被隱形的繩子拉回來一樣。
目前距離:0px
最大距離:0px
<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>最大距離以按鈕自身尺寸的倍數來定義,而非固定的像素值。這樣不管按鈕大小如何變化,活動範圍都是合理的。
const MAX_DISTANCE_MULTIPLE = 3倍數設為 3,代表按鈕最遠只能跑到「自身尺寸的 3 倍距離」。每次移動後都要檢查:
// 最大允許距離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 的邏輯一脈相承。
為什麼是回歸原點,而不是停在邊界?
Section titled “為什麼是回歸原點,而不是停在邊界?”停在邊界的實作更複雜(要計算邊界上的投影座標),而且體驗上不一定更好,按鈕卡在邊界動彈不得反而會讓使用者更挫折。回歸原點後使用者可以「再試一次」,互動感更強。
在最終元件中,這個倍數會透過 maxDistanceMultiple prop 開放讓使用者自訂。
Step 9:畫面外自動返回
Section titled “Step 9:畫面外自動返回”Step 8 限制了按鈕的最大移動距離,但有些情況距離限制管不到,例如按鈕本身就放在頁面邊緣,跑個一兩次就已經超出可視範圍了。
這時候需要另一道防線:偵測按鈕是否還在畫面中,如果跑出去了就自動回來。
<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 是什麼?
Section titled “IntersectionObserver 是什麼?”IntersectionObserver 是瀏覽器原生 API,可以非同步地觀察一個元素與其祖先容器(或 viewport)的交叉狀態。白話說就是:告訴你某個元素是否還在畫面中。
它和 scroll 事件偵測的差異在於:
scroll事件在每次滾動時都會高頻觸發,需要自己做節流IntersectionObserver由瀏覽器內部排程,只在交叉狀態變化時才觸發 callback,效能好很多
搭配 VueUse 使用
Section titled “搭配 VueUse 使用”VueUse 的 useIntersectionObserver 封裝了建立、綁定、銷毀的生命週期,只需關注 callback 邏輯:
useIntersectionObserver(carrierRef, (entryList) => { // isIntersecting 為 true 代表元素還在畫面中 if (entryList[0]?.isIntersecting) return // 不在畫面中了,自動回歸原點 back()})entryList 是一個陣列,但因為我們只觀察一個元素,所以直接取 [0] 就好。
和 Step 8 的互補關係
Section titled “和 Step 8 的互補關係”這兩道防線各司其職:
- Step 8(距離限制):處理「按鈕在頁面中央,可以跑很遠」的情況
- Step 9(畫面偵測):處理「按鈕在頁面邊緣,跑一點就超出視窗」的情況
兩者結合才能確保按鈕無論如何都不會「失蹤」。
Step 10:disabled 狀態控制
Section titled “Step 10:disabled 狀態控制”最後一步!前面九個步驟建立的按鈕「永遠都在逃跑」,但實際使用場景是:只有在 disabled 時才需要逃跑,正常狀態就是一顆乖巧的按鈕。
勾選下方的 checkbox 來切換 disabled 狀態,觀察按鈕行為的變化:
<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>核心邏輯:事件守衛
Section titled “核心邏輯:事件守衛”所有觸發逃跑的事件(mouseenter、click)都加上 disabled 檢查,沒停用就不跑:
function handleTrigger() { if (!disabled.value) return run()}
function handleClick() { if (!disabled.value) return run()}狀態切換的邊界處理
Section titled “狀態切換的邊界處理”除了基本的事件守衛,還有兩個容易忽略的邊界情況需要處理。
disabled 從 true 變 false
Section titled “disabled 從 true 變 false”使用者填完表單了,按鈕解除停用。此時按鈕可能已經跑到很遠的地方,需要自動回歸原位:
watch(disabled, (value) => { if (value) return back()})滑鼠已在按鈕上,disabled 才變 true
Section titled “滑鼠已在按鈕上,disabled 才變 true”假設使用者的滑鼠一直放在按鈕上,然後透過其他操作(例如清空了表單欄位)讓 disabled 變成 true。
這時候 mouseenter 不會重新觸發(因為滑鼠根本沒離開過),所以要另外偵測 isOutside 的變化:
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 |
| 10 | disabled 控制 | watch + 事件判斷 |
最終的元件還加入了更多細節,例如 slot 自定義、事件 emit、throttle 節流等。
完整元件請見:調皮的按鈕 🐟
最後,讓我們用一張架構圖來回顧整個元件的 DOM 結構。整個元件由外到內共有五層,各司其職:
各層的職責:
| 層級 | 元素 | 職責 |
|---|---|---|
| 外框 | div.relative | 定位參考點,拓印和按鈕都以此為基準 |
| 拓印容器 | div.absolute.inset-0 | 固定在原位不動,放置拓印(按鈕跑掉後的虛線殘影) |
| 移動容器 | div.content | 透過 transform: translate 移動,帶著按鈕一起跑。同時掛載 transition 動畫、事件偵測和 tabindex |
| 彈跳容器 | div.jelly-bounce | 每次移動時播放果凍彈跳的 @keyframes 動畫。利用 :key 切換來觸發動畫重播 |
| 按鈕本體 | slot#default | 預設的按鈕或使用者自訂的內容 |
為什麼要分這麼多層?因為每一層只負責一件事:
- 移動和彈跳分開,是因為兩者的
transform會互相覆蓋。移動用translate、彈跳用scale,放在不同層就能各自獨立運作 - 拓印和按鈕分開,是因為拓印要固定在原位,而按鈕要跟著移動容器跑