教學:調皮的按鈕 tutorial
從零開始,一步步打造一個停用時會逃跑的調皮按鈕。ᕕ( ゚ ∀。)ᕗ
完成品請見 調皮的按鈕元件。
前言
想像你有一個表單,使用者還沒填完就想按「送出」,如果按鈕自己跑掉呢? (ノ>ω<)ノ
這就是調皮按鈕的核心概念:當按鈕處於 disabled 狀態時,滑鼠一碰就跑。
接下來我們將把這個元件的開發過程拆解為 10 個小步驟,每一步都只加入一個新概念。
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:追蹤滑鼠位置
按鈕要逃跑,首先得知道滑鼠在哪裡。
如果用原生 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?
我們把 ref 綁定在「移動容器」上,而非按鈕本體。因為之後容器會透過 transform 移動,而 useMouseInElement 會自動追蹤元素移動後的新位置,確保座標計算始終正確。
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 }
}為什麼需要單位向量?
假設不做標準化,直接用原始向量來移動按鈕:
- 滑鼠在按鈕邊緣碰一下 → 原始向量很短 → 按鈕只移動一點點
- 滑鼠從遠處快速劃過 → 原始向量很長 → 按鈕飛到天邊去
這樣的行為很不穩定。轉成單位向量後,不管滑鼠在哪裡碰到按鈕,移動距離都是一致的,之後只要乘上我們想要的固定距離(按鈕寬高)就好。
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.value
carrierOffset.value.y += direction.y * elementHeight.value為什麼用 transform 而不是 top/left?
transform不會觸發瀏覽器 layout(重排),只觸發 composite(合成),效能好很多- 外層容器的
position: relative不會因為按鈕移動而影響其他元素的排版 - 後續加上
transition時,transform的動畫也更流暢
為什麼是累加?
偏移量用 += 而非 =,代表每次移動都是在前一次的位置上繼續跑。這樣連續碰按鈕好幾次,它就會越跑越遠,跟你家的貓一樣。( ・ิω・ิ)
你會發現目前按鈕會「瞬移」,沒有動畫過渡。下一步來解決。
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 設定
.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:平順但無趣,適合一般 UIease-out:開始快、結束慢,適合元素進場- 我們的曲線:結尾會「過頭」一點,讓按鈕看起來像是被彈走的,更有物理感
小工具
可以用 cubic-bezier.com 視覺化調整曲線,即時預覽效果。
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 動畫設計
@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 重播動畫
CSS 動畫只會在 DOM 節點建立時播放一次。如果按鈕連續被碰好幾次,要怎麼重播?
技巧是在彈跳容器上綁定 :key="counter":
<div :key="counter" class="jelly-bounce">
<!-- 按鈕 -->
</div>每次 counter 改變,Vue 會認為這是一個「新節點」,銷毀舊的、建立新的,CSS 動畫就會從頭播放。這是 Vue 中重播動畫的經典技巧。
避免初始動畫
頁面載入時 counter 為 0,但 DOM 節點已經建立了,動畫會自動播放一次。用 animationPlayState 來阻止:
const bounceStyle = computed(() => ({
animationPlayState: counter.value === 0
? 'paused'
: 'running',
}))counter 為 0 時暫停動畫,第一次觸發後才開始播放,避免載入時莫名其妙抖一下。
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>為什麼拓印不會跟著移動?
外層 div.relative 是定位參考點。拓印用 absolute inset-0 鎖定在這個參考點上,大小完全等於外層容器。
而按鈕的移動是透過 transform: translate 實現的,transform 只改變元素的視覺呈現,不會影響外層容器的尺寸或位置。所以不管按鈕跑多遠,拓印永遠留在原地。
兩個重要的 CSS 細節
pointer-events-none:拓印疊在按鈕上方(DOM 順序在前),如果不加這個,滑鼠事件會被拓印攔截,按鈕就再也觸發不了 hover 了border: 1px dashed:用虛線而非實線,視覺上暗示「這裡原本有東西,但現在空了」
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 的邏輯一脈相承。
為什麼是回歸原點,而不是停在邊界?
停在邊界的實作更複雜(要計算邊界上的投影座標),而且體驗上不一定更好,按鈕卡在邊界動彈不得反而會讓使用者更挫折。回歸原點後使用者可以「再試一次」,互動感更強。
在最終元件中,這個倍數會透過 maxDistanceMultiple prop 開放讓使用者自訂。
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 是什麼?
IntersectionObserver 是瀏覽器原生 API,可以非同步地觀察一個元素與其祖先容器(或 viewport)的交叉狀態。白話說就是:告訴你某個元素是否還在畫面中。
它和 scroll 事件監聽的差異在於:
scroll事件在每次滾動時都會高頻觸發,需要自己做節流IntersectionObserver由瀏覽器內部排程,只在交叉狀態變化時才觸發 callback,效能好很多
搭配 VueUse 使用
VueUse 的 useIntersectionObserver 封裝了建立、綁定、銷毀的生命週期,只需關注 callback 邏輯:
useIntersectionObserver(carrierRef, (entryList) => {
// isIntersecting 為 true 代表元素還在畫面中
if (entryList[0]?.isIntersecting)
return
// 不在畫面中了,自動回歸原點
back()
})entryList 是一個陣列,但因為我們只觀察一個元素,所以直接取 [0] 就好。
和 Step 8 的互補關係
這兩道防線各司其職:
- Step 8(距離限制):處理「按鈕在頁面中央,可以跑很遠」的情況
- Step 9(畫面偵測):處理「按鈕在頁面邊緣,跑一點就超出視窗」的情況
兩者結合才能確保按鈕無論如何都不會「失蹤」。
注意!Σ(ˊДˋ;)
父層容器不要設定 overflow: hidden,否則按鈕一移動就會被裁切,IntersectionObserver 會立刻偵測到「不在畫面中」,按鈕瞬間彈回,看起來就像完全不會動一樣。
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>核心邏輯:事件守衛
所有觸發逃跑的事件(mouseenter、click)都加上 disabled 檢查,沒停用就不跑:
function handleTrigger() {
if (!disabled.value)
return
run()
}
function handleClick() {
if (!disabled.value)
return
run()
}狀態切換的邊界處理
除了基本的事件守衛,還有兩個容易忽略的邊界情況需要處理。
disabled 從 true 變 false
使用者填完表單了,按鈕解除停用。此時按鈕可能已經跑到很遠的地方,需要自動回歸原位:
watch(disabled, (value) => {
if (value)
return
back()
})滑鼠已在按鈕上,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,放在不同層就能各自獨立運作 - 拓印和按鈕分開,是因為拓印要固定在原位,而按鈕要跟著移動容器跑