跳到內容

每條游來游去的小魚,都是正在瀏覽這一頁的訪客,有一條是你自己 ( ´ ▽ ` )ノ 覺得干擾閱讀的話,可以把小魚關掉。

固執的滑動條

step-by-step

完成品請見 固執的滑動條元件。

停用時絕不妥協的滑動條。(。-`ω´-)

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

萬事起頭難,先從一條靜態軌道和一個圓形握把開始吧。( ´ ▽ ` )ノ

<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<div class="slider relative py-3">
<div class="track rounded-full" />
<div class="thumb" />
</div>
</div>
</template>
<style scoped lang="sass">
.track
height: 8px
background: #EEE
border-radius: 9999px
.thumb
width: 20px
height: 20px
border-radius: 50%
background: #34c6eb
position: absolute
top: 50%
left: 50%
transform: translate(-50%, -50%)
</style>

整個元件的 HTML 骨架從這一步就定型了,分為兩層:

  1. 外框 div.slider:作為整個滑動條的定位參考點(position: relative)。握把會用 absolute 定位在這一層。
  2. 軌道 div.track:灰色的橫條,視覺上代表可拖動的範圍。
  • 握把用 position: absolute + top: 50% + left: 50% 定位在軌道中央
  • transform: translate(-50%, -50%) 讓握把的中心點對齊位置,而非左上角
  • 軌道高度 8px 搭配 border-radius: 9999px 形成圓角膠囊形狀

握把要跟著滑鼠跑,首先得知道滑鼠在哪裡。

VueUse 的 useMouseInElement 幫我們追蹤滑鼠相對於元素的位置。只要傳入一個 ref 元素,就能拿到滑鼠在元素內的座標。

移動滑鼠到下方軌道上,觀察握把如何跟隨:

elementX: 0
elementWidth: 0
mouseRatio: NaN%
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<div class="info text-sm">
<div>elementX: {{ Math.round(mouseInSlider.elementX) }}</div>
<div>elementWidth: {{ Math.round(mouseInSlider.elementWidth) }}</div>
<div>mouseRatio: {{ mouseRatio.toFixed(1) }}%</div>
</div>
<div
ref="sliderRef"
class="slider relative cursor-pointer py-3"
>
<div class="track rounded-full" />
<div
class="thumb"
:style="{ left: `${mouseRatio}%` }"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useMouseInElement } from '@vueuse/core'
import { computed, reactive, ref } from 'vue'
const sliderRef = ref<HTMLDivElement>()
const mouseInSlider = reactive(useMouseInElement(sliderRef))
/** 滑鼠在 slider 中的百分比位置 */
const mouseRatio = computed(() => {
const ratio = mouseInSlider.elementX / mouseInSlider.elementWidth * 100
// 限制在 0~100 之間
return Math.max(0, Math.min(100, ratio))
})
</script>
<style scoped lang="sass">
.track
height: 8px
background: #EEE
border-radius: 9999px
.thumb
width: 20px
height: 20px
border-radius: 50%
background: #34c6eb
position: absolute
top: 50%
transform: translate(-50%, -50%)
transition: left 0.05s
pointer-events: none
.info
font-family: monospace
opacity: 0.7
</style>

整個滑動條最核心的概念就是比例(ratio)。我們將滑鼠的 X 座標轉換成 0~100 的百分比:

const mouseRatio = computed(() => {
const ratio = mouseInSlider.elementX / mouseInSlider.elementWidth * 100
return Math.max(0, Math.min(100, ratio))
})

Math.max(0, Math.min(100, ratio)) 確保比例不會超出 0~100 的範圍,即使滑鼠移到元素外面也不會出問題。

回傳值說明
elementX滑鼠相對於元素左邊界的 X 座標
elementWidth元素的寬度,用來計算百分比

📚 useMouseInElement 文件

上一步的握把只是跟著滑鼠跑,沒有「數值」的概念。這一步加入 modelValue,讓握把位置反映實際數值。

透過下方的 input 修改數值,觀察握把如何跟著移動:

目前數值:50
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<div class="text-sm">
目前數值:{{ modelValue }}
</div>
<div class="slider relative cursor-pointer py-3">
<div class="track rounded-full" />
<!-- 顯示數值位置的握把 -->
<div
class="thumb"
:style="{ left: `${ratio}%` }"
/>
</div>
<div class="border rounded">
<input
v-model.number="modelValue"
type="number"
class="w-full px-2 py-1 text-sm"
:min="0"
:max="100"
>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
const modelValue = ref(50)
const min = 0
const max = 100
/** 將數值轉換為百分比位置 */
const ratio = computed(() => {
const value = modelValue.value / (max - min) * 100
return Math.max(0, Math.min(100, value))
})
</script>
<style scoped lang="sass">
.track
height: 8px
background: #EEE
border-radius: 9999px
.thumb
width: 20px
height: 20px
border-radius: 50%
background: #34c6eb
position: absolute
top: 50%
transform: translate(-50%, -50%)
transition: left 0.2s
pointer-events: none
</style>

ratio 是把數值轉換成百分比位置的關鍵:

const ratio = computed(() => {
const value = modelValue.value / (max - min) * 100
return Math.max(0, Math.min(100, value))
})

例如 min=0, max=100, modelValue=50,ratio 就是 50%,握把會在軌道正中間。

這兩個比例容易搞混,但各司其職:

  • ratio:代表「目前數值」在軌道上的位置。由 modelValue 決定。
  • mouseRatio:代表「滑鼠」在軌道上的位置。由滑鼠座標決定。

正常拖動時,握把跟著 mouseRatio 走(即時回饋);放開後,握把回到 ratio 的位置(對齊數值)。

有了數值和位置的映射,接下來要讓使用者能夠拖動握把來改變數值。

VueUse 的 useMousePressed 偵測滑鼠是否按住,搭配 mouseRatio 就能實現拖動。

目前數值:50 | 拖動中:false
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<div class="text-sm">
目前數值:{{ modelValue }} | 拖動中:{{ isHeld }}
</div>
<div
ref="sliderRef"
class="slider relative py-3"
:class="isHeld ? 'cursor-grabbing' : 'cursor-grab'"
@mousedown="(e) => e.preventDefault()"
@touchstart="(e) => e.preventDefault()"
>
<div class="track rounded-full" />
<div
class="thumb"
:style="thumbStyle"
/>
</div>
</div>
</template>
<script setup lang="ts">
import {
useElementSize,
useMouseInElement,
useMousePressed,
} from '@vueuse/core'
import { computed, reactive, ref, watch } from 'vue'
const modelValue = ref(50)
const min = 0
const max = 100
const sliderRef = ref<HTMLDivElement>()
const mouseInSlider = reactive(useMouseInElement(sliderRef))
const sliderSize = reactive(useElementSize(sliderRef))
const { pressed: isHeld } = useMousePressed({
target: sliderRef,
})
/** 數值對應的百分比位置 */
const ratio = computed(() => {
const value = modelValue.value / (max - min) * 100
return Math.max(0, Math.min(100, value))
})
/** 滑鼠對應的百分比位置 */
const mouseRatio = computed(() => {
const value = mouseInSlider.elementX / mouseInSlider.elementWidth * 100
return Math.max(0, Math.min(100, value))
})
/** 根據滑鼠位置計算數值 */
function getValue(ratio: number) {
const rawValue = min + (ratio / 100) * (max - min)
return Math.round(rawValue)
}
/** 拖動時更新數值 */
watch([mouseRatio, isHeld], () => {
if (!isHeld.value)
return
modelValue.value = getValue(mouseRatio.value)
})
const thumbStyle = computed(() => ({
left: `${isHeld.value ? mouseRatio.value : ratio.value}%`,
transitionDuration: isHeld.value ? '0s' : '0.2s',
}))
</script>
<style scoped lang="sass">
.track
height: 8px
background: #EEE
border-radius: 9999px
.thumb
width: 20px
height: 20px
border-radius: 50%
background: #34c6eb
position: absolute
top: 50%
transform: translate(-50%, -50%)
transition-property: left
pointer-events: none
</style>

拖動的核心邏輯很簡單:當滑鼠按住時,將滑鼠位置換算成數值。

const { pressed: isHeld } = useMousePressed({
target: sliderRef,
})
watch([mouseRatio, isHeld], () => {
if (!isHeld.value)
return
modelValue.value = getValue(mouseRatio.value)
})

getValue 負責把百分比轉回實際數值:

function getValue(ratio: number) {
const rawValue = min + (ratio / 100) * (max - min)
return Math.round(rawValue)
}
const thumbStyle = computed(() => ({
left: `${isHeld.value ? mouseRatio.value : ratio.value}%`,
transitionDuration: isHeld.value ? '0s' : '0.2s',
}))
  • 按住時:握把跟著 mouseRatio(滑鼠位置),transition 設為 0s 確保零延遲
  • 放開後:握把回到 ratio(數值位置),transition 設為 0.2s 產生平滑過渡

這樣拖動時感覺是即時的,放開後又有優雅的對齊動畫。

@mousedown="(e) => e.preventDefault()"
@touchstart="(e) => e.preventDefault()"

不加這兩行的話,拖動時瀏覽器會觸發文字選取或頁面滾動,影響操作體驗。

📚 useMousePressed 文件

上一步的滑動條只有整數值。但實際需求可能需要不同的步進,例如音量每次跳 5,或是精確到小數點後一位。

切換不同的 step 值,觀察拖動時數值如何「吸附」:

目前數值:50
<template>
<div class="flex flex-col gap-4 border border-gray-200 rounded-xl p-6">
<div class="flex items-center gap-4 text-sm">
<label>
step:
<select
v-model.number="step"
class="example-select"
>
<option :value="1">
1
</option>
<option :value="5">
5
</option>
<option :value="10">
10
</option>
<option :value="0.1">
0.1
</option>
</select>
</label>
<span>目前數值:{{ modelValue }}</span>
</div>
<div
ref="sliderRef"
class="slider relative py-3"
:class="isHeld ? 'cursor-grabbing' : 'cursor-grab'"
@mousedown="(e) => e.preventDefault()"
@touchstart="(e) => e.preventDefault()"
>
<div class="track rounded-full" />
<div
class="thumb"
:style="thumbStyle"
/>
</div>
</div>
</template>
<script setup lang="ts">
import {
useMouseInElement,
useMousePressed,
} from '@vueuse/core'
import { computed, reactive, ref, watch } from 'vue'
const modelValue = ref(50)
const min = 0
const max = 100
const step = ref(1)
const sliderRef = ref<HTMLDivElement>()
const mouseInSlider = reactive(useMouseInElement(sliderRef))
const { pressed: isHeld } = useMousePressed({
target: sliderRef,
})
const ratio = computed(() => {
const value = modelValue.value / (max - min) * 100
return Math.max(0, Math.min(100, value))
})
const mouseRatio = computed(() => {
const value = mouseInSlider.elementX / mouseInSlider.elementWidth * 100
return Math.max(0, Math.min(100, value))
})
/** 計算 step 的小數位數,用於修正浮點數精度 */
const stepPrecision = computed(() => {
const stepString = step.value.toString()
if (stepString.includes('.')) {
return stepString.split('.')[1]?.length ?? 0
}
return 0
})
function fixed(value: number) {
return Number(value.toFixed(stepPrecision.value))
}
/** 根據滑鼠位置計算數值,並對齊 step */
function getValue(ratio: number) {
const rawValue = min + (ratio / 100) * (max - min)
return fixed(Math.round(rawValue / step.value) * step.value)
}
watch([mouseRatio, isHeld], () => {
if (!isHeld.value)
return
modelValue.value = getValue(mouseRatio.value)
})
const thumbStyle = computed(() => ({
left: `${isHeld.value ? mouseRatio.value : ratio.value}%`,
transitionDuration: isHeld.value ? '0s' : '0.2s',
}))
</script>
<style scoped lang="sass">
.track
height: 8px
background: #EEE
border-radius: 9999px
.thumb
width: 20px
height: 20px
border-radius: 50%
background: #34c6eb
position: absolute
top: 50%
transform: translate(-50%, -50%)
transition-property: left
pointer-events: none
</style>

getValue 函式加入了 step 計算。先算出原始值,再用 Math.round 四捨五入到最近的 step 倍數:

function getValue(ratio: number) {
const rawValue = min + (ratio / 100) * (max - min)
return fixed(Math.round(rawValue / step.value) * step.value)
}

例如 step=5, rawValue=17:Math.round(17 / 5) * 5 = Math.round(3.4) * 5 = 3 * 5 = 15。

JavaScript 的浮點數運算會產生精度誤差。例如 0.1 + 0.2 = 0.30000000000000004。

在 step 為小數(如 0.1)時,這個問題會導致數值顯示為 50.10000000000001 之類的結果。

解法是根據 step 的小數位數來 toFixed:

const stepPrecision = computed(() => {
const stepString = step.value.toString()
if (stepString.includes('.')) {
return stepString.split('.')[1]?.length ?? 0
}
return 0
})
function fixed(value: number) {
return Number(value.toFixed(stepPrecision.value))
}

step=0.1 → stepPrecision=1 → toFixed(1) → "50.1" → Number("50.1") → 50.1。精度問題解決。

到目前為止,握把都是一個 CSS 圓形。但最終元件的握把是用 SVG 的 path 畫的,因為後面要讓它像橡皮筋一樣拉伸、彎曲,這些效果用純 CSS 做不到。

這一步先把圓形替換成 SVG path,但行為完全不變:

目前數值:50
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<div class="text-sm">
目前數值:{{ modelValue }}
</div>
<div
ref="sliderRef"
class="slider relative py-3"
:class="isHeld ? 'cursor-grabbing' : 'cursor-grab'"
@mousedown="(e) => e.preventDefault()"
@touchstart="(e) => e.preventDefault()"
>
<div class="track rounded-full" />
<!-- SVG 握把:用 path 畫一條線 -->
<svg
:width="thumbSize"
:height="thumbSize"
:viewBox="`${thumbSize / -2} ${thumbSize / -2} ${thumbSize} ${thumbSize}`"
:style="thumbStyle"
class="thumb-svg pointer-events-none absolute"
>
<!-- 起點 0,0 到終點 0,0,目前只是一個點 -->
<path
d="M0 0 Q0 0, 0 0"
:stroke="thumbColor"
:stroke-width="thumbSize"
stroke-linejoin="round"
stroke-linecap="round"
fill="none"
vector-effect="non-scaling-stroke"
/>
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import {
useMouseInElement,
useMousePressed,
} from '@vueuse/core'
import { computed, reactive, ref, watch } from 'vue'
const modelValue = ref(50)
const min = 0
const max = 100
const thumbSize = 20
const thumbColor = '#34c6eb'
const sliderRef = ref<HTMLDivElement>()
const mouseInSlider = reactive(useMouseInElement(sliderRef))
const { pressed: isHeld } = useMousePressed({
target: sliderRef,
})
const ratio = computed(() => {
const value = modelValue.value / (max - min) * 100
return Math.max(0, Math.min(100, value))
})
const mouseRatio = computed(() => {
const value = mouseInSlider.elementX / mouseInSlider.elementWidth * 100
return Math.max(0, Math.min(100, value))
})
function getValue(ratio: number) {
const rawValue = min + (ratio / 100) * (max - min)
return Math.round(rawValue)
}
watch([mouseRatio, isHeld], () => {
if (!isHeld.value)
return
modelValue.value = getValue(mouseRatio.value)
})
const thumbStyle = computed(() => ({
left: `${isHeld.value ? mouseRatio.value : ratio.value}%`,
transitionDuration: isHeld.value ? '0s' : '0.2s',
}))
</script>
<style scoped lang="sass">
.track
height: 8px
background: #EEE
border-radius: 9999px
.thumb-svg
top: 50%
transform: translate(-50%, -50%)
transition-property: left
transition-timing-function: cubic-bezier(0.85, 0, 0.15, 1)
</style>

一般的 HTML 元素只能畫矩形或圓形。但握把被拉伸時,需要:

  1. 一條可以任意彎曲的線段
  2. 線段的粗細可以動態變化
  3. 線段的端點要是圓頭的

SVG 的 path 元素搭配 Q(二次貝茲曲線)指令,完美滿足這些需求。

M0 0 Q cx cy, ex ey
  • M0 0:起點,固定在 SVG 中心
  • Q cx cy, ex ey:二次貝茲曲線,cx cy 是控制點,ex ey 是終點

目前控制點和終點都在 (0, 0),所以看起來就是一個點。下一步會讓終點跟著滑鼠移動。

<path
:stroke-width="thumbSize"
stroke-linejoin="round"
stroke-linecap="round"
fill="none"
vector-effect="non-scaling-stroke"
/>
  • stroke-linecap="round":線段端點是圓頭的,所以即使是一個點也會顯示為圓形
  • vector-effect="non-scaling-stroke":線段粗細不會受到 viewBox 縮放影響
  • fill="none":只要線條,不要填充

📚 SVG path 指令參考

這一步是整個元件最核心的視覺效果:disabled 時,握把會像橡皮筋一樣被拉長。

勾選「停用滑動條」後,試著拖動握把,觀察它如何伸縮:

目前數值:50 | 停用:false
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<div class="text-sm">
目前數值:{{ modelValue }} | 停用:{{ disabled }}
</div>
<label class="flex items-center gap-2 text-sm">
<input
v-model="disabled"
type="checkbox"
>
停用滑動條
</label>
<div
ref="sliderRef"
class="slider relative py-3"
:class="isHeld ? 'cursor-grabbing' : 'cursor-grab'"
@mousedown="(e) => e.preventDefault()"
@touchstart="(e) => e.preventDefault()"
>
<div class="track rounded-full" />
<svg
:width="svgSize"
:height="svgSize"
:viewBox="`${svgSize / -2} ${svgSize / -2} ${svgSize} ${svgSize}`"
:style="svgStyle"
class="thumb-svg pointer-events-none absolute"
>
<path
:d="pathD"
:stroke="thumbColor"
:stroke-width="strokeWidth"
stroke-linejoin="round"
stroke-linecap="round"
fill="none"
vector-effect="non-scaling-stroke"
/>
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import {
useIntervalFn,
useMouseInElement,
useMousePressed,
} from '@vueuse/core'
import { computed, reactive, ref, watch } from 'vue'
const modelValue = ref(50)
const disabled = ref(false)
const min = 0
const max = 100
const thumbSize = 20
const thumbColor = '#34c6eb'
const maxThumbLength = 200
const sliderRef = ref<HTMLDivElement>()
const mouseInSlider = reactive(useMouseInElement(sliderRef))
const { pressed: isHeld } = useMousePressed({
target: sliderRef,
})
const ratio = computed(() => {
const value = modelValue.value / (max - min) * 100
return Math.max(0, Math.min(100, value))
})
const mouseRatio = computed(() => {
const value = mouseInSlider.elementX / mouseInSlider.elementWidth * 100
return Math.max(0, Math.min(100, value))
})
function getValue(ratio: number) {
const rawValue = min + (ratio / 100) * (max - min)
return Math.round(rawValue)
}
watch([mouseRatio, isHeld], () => {
if (!isHeld.value || disabled.value)
return
modelValue.value = getValue(mouseRatio.value)
})
// --- SVG 握把 ---
/** 以 SVG 中心為原點的滑鼠座標 */
const mousePosition = computed(() => ({
x: mouseInSlider.elementX - mouseInSlider.elementWidth * (ratio.value / 100),
y: mouseInSlider.elementY - 24,
}))
function getVectorLength({ x, y }: { x: number; y: number }) {
return Math.sqrt(x * x + y * y)
}
/** 線段終點:disabled 時跟著滑鼠拉伸 */
const endPoint = ref({ x: 0, y: 0 })
useIntervalFn(() => {
if (!isHeld.value || !disabled.value) {
return
}
const newPoint = {
x: (mousePosition.value.x - endPoint.value.x) / 2 + endPoint.value.x,
y: (mousePosition.value.y - endPoint.value.y) / 2 + endPoint.value.y,
}
const length = getVectorLength(newPoint)
if (length > maxThumbLength) {
const noise = Math.random() * 4
const scaleFactor = maxThumbLength / length
newPoint.x = newPoint.x * scaleFactor + noise
newPoint.y = newPoint.y * scaleFactor + noise
}
endPoint.value = newPoint
}, 15)
/** disabled 解除時,重設終點 */
watch([() => disabled.value, () => isHeld.value], () => {
if (!disabled.value || !isHeld.value) {
endPoint.value = { x: 0, y: 0 }
}
})
const length = computed(() => getVectorLength(endPoint.value))
const pathD = computed(() => {
const { x, y } = endPoint.value
return `M0 0 Q${x / 2} ${y / 2}, ${x} ${y}`
})
function mapNumber(
current: number,
inMin: number,
inMax: number,
outMin: number,
outMax: number,
) {
const mapped = ((current - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin
return Math.max(Math.min(mapped, Math.max(outMin, outMax)), Math.min(outMin, outMax))
}
const strokeWidth = computed(() => mapNumber(
length.value,
0,
maxThumbLength,
thumbSize,
Math.max(thumbSize * 0.1, 5),
))
const svgSize = ref(thumbSize * 1.5)
const svgStyle = computed<CSSProperties>(() => ({
left: `${disabled.value ? ratio.value : (isHeld.value ? mouseRatio.value : ratio.value)}%`,
transitionDuration: isHeld.value ? '0s' : '0.2s',
}))
</script>
<style scoped lang="sass">
.track
height: 8px
background: #EEE
border-radius: 9999px
.thumb-svg
top: 50%
transform: translate(-50%, -50%)
will-change: left width height
transition-property: left
transition-timing-function: cubic-bezier(0.85, 0, 0.15, 1)
</style>

拉伸效果的關鍵是控制 SVG path 的終點(endPoint):

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

正常狀態下,終點在 (0, 0),path 只是一個圓點。disabled 時,終點朝滑鼠方向延伸,path 就被「拉長」了。

為什麼用 useIntervalFn 而不是 watch?

Section titled “為什麼用 useIntervalFn 而不是 watch?”
useIntervalFn(() => {
if (!isHeld.value || !disabled.value)
return
const newPoint = {
x: (mousePosition.value.x - endPoint.value.x) / 2 + endPoint.value.x,
y: (mousePosition.value.y - endPoint.value.y) / 2 + endPoint.value.y,
}
endPoint.value = newPoint
}, 15)

終點不是直接跳到滑鼠位置,而是每 15ms 移動一半的距離(/ 2)。這產生了指數衰減的追蹤效果,一開始追得快,越接近越慢,看起來就像有彈性一樣。

如果用 watch 直接設定位置,終點會瞬間跳到滑鼠那邊,完全沒有「拉橡皮筋」的感覺。

拉太長不好看,所以加了 maxThumbLength 上限。當超過上限時,按比例縮小並加入隨機 noise:

if (length > maxThumbLength) {
const noise = Math.random() * 4
const scaleFactor = maxThumbLength / length
newPoint.x = newPoint.x * scaleFactor + noise
newPoint.y = newPoint.y * scaleFactor + noise
}

noise 讓握把在極限長度時微微抖動,暗示「已經拉到極限了,再拉也沒用」。

const strokeWidth = computed(() => mapNumber(
length.value,
0,
maxThumbLength,
thumbSize,
Math.max(thumbSize * 0.1, 5),
))

mapNumber 做線性映射:長度為 0 時,線寬等於 thumbSize(看起來是圓形);拉到最長時,線寬縮到很細。就像真的橡皮筋越拉越細。

上一步的握把雖然會拉伸,但線條太「硬」了。真實的橡皮筋被拉動時會晃啊晃的,有種 Q 彈的感覺。

這一步加入控制點彈簧物理,讓貝茲曲線的彎曲程度產生震盪效果:

目前數值:50 | 停用:false
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<div class="text-sm">
目前數值:{{ modelValue }} | 停用:{{ disabled }}
</div>
<label class="flex items-center gap-2 text-sm">
<input
v-model="disabled"
type="checkbox"
>
停用滑動條
</label>
<div
ref="sliderRef"
class="slider relative py-3"
:class="isHeld ? 'cursor-grabbing' : 'cursor-grab'"
@mousedown="(e) => e.preventDefault()"
@touchstart="(e) => e.preventDefault()"
>
<div class="track rounded-full" />
<svg
:width="svgSize"
:height="svgSize"
:viewBox="`${svgSize / -2} ${svgSize / -2} ${svgSize} ${svgSize}`"
:style="svgStyle"
class="thumb-svg pointer-events-none absolute"
>
<path
:d="pathD"
:stroke="thumbColor"
:stroke-width="strokeWidth"
stroke-linejoin="round"
stroke-linecap="round"
fill="none"
vector-effect="non-scaling-stroke"
/>
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import {
useIntervalFn,
useMouseInElement,
useMousePressed,
useWindowSize,
} from '@vueuse/core'
import { computed, reactive, ref, watch } from 'vue'
const modelValue = ref(50)
const disabled = ref(false)
const min = 0
const max = 100
const thumbSize = 20
const thumbColor = '#34c6eb'
const maxThumbLength = 200
const windowSize = reactive(useWindowSize())
const sliderRef = ref<HTMLDivElement>()
const mouseInSlider = reactive(useMouseInElement(sliderRef))
const { pressed: isHeld } = useMousePressed({
target: sliderRef,
})
const ratio = computed(() => {
const value = modelValue.value / (max - min) * 100
return Math.max(0, Math.min(100, value))
})
const mouseRatio = computed(() => {
const value = mouseInSlider.elementX / mouseInSlider.elementWidth * 100
return Math.max(0, Math.min(100, value))
})
function getValue(mouseRatio: number) {
const rawValue = min + (mouseRatio / 100) * (max - min)
return Math.round(rawValue)
}
watch([mouseRatio, isHeld], () => {
if (!isHeld.value || disabled.value)
return
modelValue.value = getValue(mouseRatio.value)
})
// --- 向量工具 ---
function getVectorLength({ x, y }: { x: number; y: number }) {
return Math.sqrt(x * x + y * y)
}
function mapNumber(
current: number,
inMin: number,
inMax: number,
outMin: number,
outMax: number,
) {
const mapped = ((current - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin
return Math.max(Math.min(mapped, Math.max(outMin, outMax)), Math.min(outMin, outMax))
}
// --- SVG 握把 ---
const mousePosition = computed(() => ({
x: mouseInSlider.elementX - mouseInSlider.elementWidth * (ratio.value / 100),
y: mouseInSlider.elementY - 24,
}))
const endPoint = ref({ x: 0, y: 0 })
const length = computed(() => getVectorLength(endPoint.value))
/** 處理終點動畫 */
useIntervalFn(() => {
if (!isHeld.value || !disabled.value)
return
const newPoint = {
x: (mousePosition.value.x - endPoint.value.x) / 2 + endPoint.value.x,
y: (mousePosition.value.y - endPoint.value.y) / 2 + endPoint.value.y,
}
const pointLength = getVectorLength(newPoint)
if (pointLength > maxThumbLength) {
const noise = Math.random() * 4
const scaleFactor = maxThumbLength / pointLength
newPoint.x = newPoint.x * scaleFactor + noise
newPoint.y = newPoint.y * scaleFactor + noise
}
endPoint.value = newPoint
}, 15)
watch([() => disabled.value, () => isHeld.value], () => {
if (!disabled.value || !isHeld.value) {
endPoint.value = { x: 0, y: 0 }
}
})
// --- 控制點(彈簧震盪) ---
const ctrlPoint = ref({ x: 0, y: 0 })
let ctrlPointVelocity = { x: 0, y: 0 }
/** 彈性係數:拉越長震動越快 */
const ctrlPointStiffness = computed(() => mapNumber(
length.value,
0,
maxThumbLength,
2,
3,
))
/** 阻尼:越短停得越快 */
const ctrlPointDamping = computed(() => mapNumber(
length.value,
0,
maxThumbLength,
0.7,
0.9,
))
/** 處理控制點彈簧物理 */
useIntervalFn(() => {
const targetPoint = {
x: endPoint.value.x / 2,
y: endPoint.value.y / 2,
}
const dx = targetPoint.x - ctrlPoint.value.x
const dy = targetPoint.y - ctrlPoint.value.y
// 彈力公式:F = -k * x
ctrlPointVelocity.x += ctrlPointStiffness.value * dx
ctrlPointVelocity.y += ctrlPointStiffness.value * dy
// 阻尼衰減
ctrlPointVelocity.x *= ctrlPointDamping.value
ctrlPointVelocity.y *= ctrlPointDamping.value
if (Math.abs(ctrlPointVelocity.x) < 0.001
&& Math.abs(ctrlPointVelocity.y) < 0.001) {
ctrlPointVelocity = { x: 0, y: 0 }
return
}
ctrlPoint.value.x += ctrlPointVelocity.x
ctrlPoint.value.y += ctrlPointVelocity.y
// 不可以超出畫面
if (Math.abs(ctrlPoint.value.x) > windowSize.width / 2)
ctrlPoint.value.x = ctrlPoint.value.x > 0 ? windowSize.width / 2 : -windowSize.width / 2
if (Math.abs(ctrlPoint.value.y) > windowSize.height / 2)
ctrlPoint.value.y = ctrlPoint.value.y > 0 ? windowSize.height / 2 : -windowSize.height / 2
}, 15)
// --- 繪製 ---
const pathD = computed(() => {
const { x: ctrlX, y: ctrlY } = ctrlPoint.value
const { x: endX, y: endY } = endPoint.value
return `M0 0 Q${ctrlX} ${ctrlY}, ${endX} ${endY}`
})
const strokeWidth = computed(() => mapNumber(
length.value,
0,
maxThumbLength,
thumbSize,
Math.max(thumbSize * 0.1, 5),
))
const svgSize = ref(thumbSize * 1.5)
/** 動態調整 SVG 尺寸 */
useIntervalFn(() => {
let newSize = thumbSize
if (isHeld.value && disabled.value) {
newSize = Math.max(
Math.abs(mousePosition.value.x),
Math.abs(mousePosition.value.y),
thumbSize,
) * 2
if (newSize > maxThumbLength * 2) {
newSize = maxThumbLength * 2
}
}
newSize += thumbSize * 1.5
const delta = newSize - svgSize.value
if (Math.abs(delta) < 0.01) {
svgSize.value = newSize
return
}
// 長大要快,縮小要慢
if (delta > 0) {
svgSize.value += delta
}
else {
svgSize.value += delta / 10
}
}, 15)
const svgStyle = computed<CSSProperties>(() => ({
left: `${disabled.value ? ratio.value : (isHeld.value ? mouseRatio.value : ratio.value)}%`,
transitionDuration: isHeld.value ? '0s' : '0.2s',
}))
</script>
<style scoped lang="sass">
.track
height: 8px
background: #EEE
border-radius: 9999px
.thumb-svg
top: 50%
transform: translate(-50%, -50%)
will-change: left width height
transition-property: left
transition-timing-function: cubic-bezier(0.85, 0, 0.15, 1)
</style>

控制點的震盪用的是經典的彈簧-阻尼系統(Spring-Damper System),公式很簡單:

加速度 = 彈性係數 × 位移
速度 = 速度 × 阻尼率
位置 = 位置 + 速度

翻成程式碼:

// 彈力:離目標越遠,拉力越大
ctrlPointVelocity.x += ctrlPointStiffness.value * dx
ctrlPointVelocity.y += ctrlPointStiffness.value * dy
// 阻尼:每幀速度衰減一點點
ctrlPointVelocity.x *= ctrlPointDamping.value
ctrlPointVelocity.y *= ctrlPointDamping.value
// 更新位置
ctrlPoint.value.x += ctrlPointVelocity.x
ctrlPoint.value.y += ctrlPointVelocity.y

彈性係數和阻尼率不是固定值,而是根據目前握把長度動態映射:

// 拉越長 → 彈性係數越大 → 震動越快
const ctrlPointStiffness = computed(() => mapNumber(
length.value,
0,
maxThumbLength,
2,
3,
))
// 拉越長 → 阻尼越大 → 震動持續越久
const ctrlPointDamping = computed(() => mapNumber(
length.value,
0,
maxThumbLength,
0.7,
0.9,
))

這讓握把在短的時候幾乎不晃,拉長後才明顯震盪,符合物理直覺。

控制點的目標不是滑鼠位置,而是終點的中點:

const targetPoint = {
x: endPoint.value.x / 2,
y: endPoint.value.y / 2,
}

因為貝茲曲線的控制點在中間,控制點追到終點中間,曲線才會自然地弧向外側。

握把被拉長時,SVG 元素的尺寸要跟著擴大,否則超出的部分會被裁切:

useIntervalFn(() => {
let newSize = thumbSize
if (isHeld.value && disabled.value) {
newSize = Math.max(
Math.abs(mousePosition.value.x),
Math.abs(mousePosition.value.y),
thumbSize,
) * 2
}
newSize += thumbSize * 1.5 // 安全係數
// 長大要快,縮小要慢,避免閃爍
svgSize.value += delta > 0 ? delta : delta / 10
}, 15)

「長大要快,縮小要慢」是個小巧思:擴大時立刻跟上(避免裁切),縮小時慢慢來(避免閃爍)。

上一步的握把在放開後會直接「消失」(終點瞬間回到 0,0)。加上 anime.js 的 easeOutElastic 緩動函式,讓放開的瞬間有橡皮筋回彈的效果:

目前數值:50 | 停用:false
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<div class="text-sm">
目前數值:{{ modelValue }} | 停用:{{ disabled }}
</div>
<label class="flex items-center gap-2 text-sm">
<input
v-model="disabled"
type="checkbox"
>
停用滑動條(拖動後放開,觀察回彈效果)
</label>
<div
ref="sliderRef"
class="slider relative py-3"
:class="isHeld ? 'cursor-grabbing' : 'cursor-grab'"
@mousedown="(e) => e.preventDefault()"
@touchstart="(e) => e.preventDefault()"
>
<div class="track rounded-full" />
<svg
:width="svgSize"
:height="svgSize"
:viewBox="`${svgSize / -2} ${svgSize / -2} ${svgSize} ${svgSize}`"
:style="svgStyle"
class="thumb-svg pointer-events-none absolute"
>
<path
:d="pathD"
:stroke="thumbColor"
:stroke-width="strokeWidth"
stroke-linejoin="round"
stroke-linecap="round"
fill="none"
vector-effect="non-scaling-stroke"
/>
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import {
useIntervalFn,
useMouseInElement,
useMousePressed,
useWindowSize,
} from '@vueuse/core'
import anime from 'animejs'
import { computed, reactive, ref, watch } from 'vue'
const modelValue = ref(50)
const disabled = ref(false)
const min = 0
const max = 100
const thumbSize = 20
const thumbColor = '#34c6eb'
const maxThumbLength = 200
const windowSize = reactive(useWindowSize())
const sliderRef = ref<HTMLDivElement>()
const mouseInSlider = reactive(useMouseInElement(sliderRef))
const { pressed: isHeld } = useMousePressed({
target: sliderRef,
})
const ratio = computed(() => {
const value = modelValue.value / (max - min) * 100
return Math.max(0, Math.min(100, value))
})
const mouseRatio = computed(() => {
const value = mouseInSlider.elementX / mouseInSlider.elementWidth * 100
return Math.max(0, Math.min(100, value))
})
function getValue(mouseRatio: number) {
const rawValue = min + (mouseRatio / 100) * (max - min)
return Math.round(rawValue)
}
watch([mouseRatio, isHeld], () => {
if (!isHeld.value || disabled.value)
return
modelValue.value = getValue(mouseRatio.value)
})
// --- 向量工具 ---
function getVectorLength({ x, y }: { x: number; y: number }) {
return Math.sqrt(x * x + y * y)
}
function mapNumber(
current: number,
inMin: number,
inMax: number,
outMin: number,
outMax: number,
) {
const mapped = ((current - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin
return Math.max(Math.min(mapped, Math.max(outMin, outMax)), Math.min(outMin, outMax))
}
// --- SVG 握把 ---
const mousePosition = computed(() => ({
x: mouseInSlider.elementX - mouseInSlider.elementWidth * (ratio.value / 100),
y: mouseInSlider.elementY - 24,
}))
const endPoint = ref({ x: 0, y: 0 })
const length = computed(() => getVectorLength(endPoint.value))
useIntervalFn(() => {
if (!isHeld.value || !disabled.value)
return
const newPoint = {
x: (mousePosition.value.x - endPoint.value.x) / 2 + endPoint.value.x,
y: (mousePosition.value.y - endPoint.value.y) / 2 + endPoint.value.y,
}
const pointLength = getVectorLength(newPoint)
if (pointLength > maxThumbLength) {
const noise = Math.random() * 4
const scaleFactor = maxThumbLength / pointLength
newPoint.x = newPoint.x * scaleFactor + noise
newPoint.y = newPoint.y * scaleFactor + noise
}
endPoint.value = newPoint
}, 15)
/** 放開時,播放回彈動畫 */
watch(isHeld, (value) => {
if (value)
return
anime({
targets: endPoint.value,
x: 0,
y: 0,
easing: 'easeOutElastic',
duration: 300,
})
})
/** disabled 解除時重設 */
watch(() => disabled.value, () => {
if (!disabled.value) {
endPoint.value = { x: 0, y: 0 }
}
})
// --- 控制點(彈簧震盪) ---
const ctrlPoint = ref({ x: 0, y: 0 })
let ctrlPointVelocity = { x: 0, y: 0 }
const ctrlPointStiffness = computed(() => mapNumber(
length.value, 0, maxThumbLength, 2, 3,
))
const ctrlPointDamping = computed(() => mapNumber(
length.value, 0, maxThumbLength, 0.7, 0.9,
))
useIntervalFn(() => {
const targetPoint = {
x: endPoint.value.x / 2,
y: endPoint.value.y / 2,
}
const dx = targetPoint.x - ctrlPoint.value.x
const dy = targetPoint.y - ctrlPoint.value.y
ctrlPointVelocity.x += ctrlPointStiffness.value * dx
ctrlPointVelocity.y += ctrlPointStiffness.value * dy
ctrlPointVelocity.x *= ctrlPointDamping.value
ctrlPointVelocity.y *= ctrlPointDamping.value
if (Math.abs(ctrlPointVelocity.x) < 0.001
&& Math.abs(ctrlPointVelocity.y) < 0.001) {
ctrlPointVelocity = { x: 0, y: 0 }
return
}
ctrlPoint.value.x += ctrlPointVelocity.x
ctrlPoint.value.y += ctrlPointVelocity.y
if (Math.abs(ctrlPoint.value.x) > windowSize.width / 2)
ctrlPoint.value.x = ctrlPoint.value.x > 0 ? windowSize.width / 2 : -windowSize.width / 2
if (Math.abs(ctrlPoint.value.y) > windowSize.height / 2)
ctrlPoint.value.y = ctrlPoint.value.y > 0 ? windowSize.height / 2 : -windowSize.height / 2
}, 15)
// --- 繪製 ---
const pathD = computed(() => {
const { x: ctrlX, y: ctrlY } = ctrlPoint.value
const { x: endX, y: endY } = endPoint.value
return `M0 0 Q${ctrlX} ${ctrlY}, ${endX} ${endY}`
})
const strokeWidth = computed(() => mapNumber(
length.value, 0, maxThumbLength, thumbSize, Math.max(thumbSize * 0.1, 5),
))
const svgSize = ref(thumbSize * 1.5)
useIntervalFn(() => {
let newSize = thumbSize
if (isHeld.value && disabled.value) {
newSize = Math.max(
Math.abs(mousePosition.value.x),
Math.abs(mousePosition.value.y),
thumbSize,
) * 2
if (newSize > maxThumbLength * 2)
newSize = maxThumbLength * 2
}
newSize += thumbSize * 1.5
const delta = newSize - svgSize.value
if (Math.abs(delta) < 0.01) {
svgSize.value = newSize
return
}
svgSize.value += delta > 0 ? delta : delta / 10
}, 15)
const svgStyle = computed<CSSProperties>(() => ({
left: `${disabled.value ? ratio.value : (isHeld.value ? mouseRatio.value : ratio.value)}%`,
transitionDuration: isHeld.value ? '0s' : '0.2s',
}))
</script>
<style scoped lang="sass">
.track
height: 8px
background: #EEE
border-radius: 9999px
.thumb-svg
top: 50%
transform: translate(-50%, -50%)
will-change: left width height
transition-property: left
transition-timing-function: cubic-bezier(0.85, 0, 0.15, 1)
</style>
watch(isHeld, (value) => {
if (value)
return
anime({
targets: endPoint.value,
x: 0,
y: 0,
easing: 'easeOutElastic',
duration: 300,
})
})

easeOutElastic 是 anime.js 內建的彈性緩動曲線。它會讓數值「衝過」目標值再彈回來,重複幾次後穩定。搭配終點從 (x, y) 回到 (0, 0) 的動畫,看起來就像橡皮筋鬆手後彈回去一樣。

duration: 300 是刻意設得很短的。太長的話回彈動畫會和下一次拖動衝突,使用者可能在動畫還沒播完就又開始拖了。300ms 足夠看到回彈效果,又不會妨礙操作。

CSS 的 transition-timing-function 沒有 elastic 緩動。雖然可以用 cubic-bezier 模擬,但效果遠不如 anime.js 的 elastic 自然。而且 anime.js 直接操作 JavaScript 物件(endPoint.value),不需要透過 DOM 屬性,和 Vue 的響應式系統整合得很好。

📚 anime.js 文件

前面所有步驟的 disabled 都是「全部停用」。但實際使用場景更常見的是部分停用,例如免費方案只能選 1~3 隻魚,高級方案才能選更多。

調整下方的 minDisabled 和 maxDisabled,觀察握把碰到邊界時的行為:

目前數值:50.0 | 可用範圍:20 ~ 80
<template>
<div class="flex flex-col gap-4 border border-gray-200 rounded-xl p-6">
<div class="text-sm">
目前數值:{{ modelValue.toFixed(1) }} |
可用範圍:{{ minDisabled }} ~ {{ maxDisabled }}
</div>
<div class="flex gap-4 text-sm">
<label>
minDisabled:
<input
v-model.number="minDisabled"
type="number"
class="w-16 border rounded px-2 py-1"
:min="0"
:max="maxDisabled"
>
</label>
<label>
maxDisabled:
<input
v-model.number="maxDisabled"
type="number"
class="w-16 border rounded px-2 py-1"
:min="minDisabled"
:max="100"
>
</label>
</div>
<div
ref="sliderRef"
class="slider relative py-3"
:class="isHeld ? 'cursor-grabbing' : 'cursor-grab'"
@mousedown="(e) => e.preventDefault()"
@touchstart="(e) => e.preventDefault()"
>
<!-- 可用範圍指示條 -->
<div class="track rounded-full" />
<div
class="active-track absolute top-3 rounded-full"
:style="{
left: `${minDisabled}%`,
width: `${maxDisabled - minDisabled}%`,
}"
/>
<svg
:width="svgSize"
:height="svgSize"
:viewBox="`${svgSize / -2} ${svgSize / -2} ${svgSize} ${svgSize}`"
:style="svgStyle"
class="thumb-svg pointer-events-none absolute"
>
<path
:d="pathD"
:stroke="thumbColor"
:stroke-width="strokeWidth"
stroke-linejoin="round"
stroke-linecap="round"
fill="none"
vector-effect="non-scaling-stroke"
/>
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import {
useIntervalFn,
useMouseInElement,
useMousePressed,
useWindowSize,
} from '@vueuse/core'
import anime from 'animejs'
import { computed, reactive, ref, watch } from 'vue'
const modelValue = ref(50)
const min = 0
const max = 100
const step = 1
const thumbSize = 20
const thumbColor = '#34c6eb'
const maxThumbLength = 200
const minDisabled = ref(20)
const maxDisabled = ref(80)
const windowSize = reactive(useWindowSize())
const sliderRef = ref<HTMLDivElement>()
const mouseInSlider = reactive(useMouseInElement(sliderRef))
const { pressed: isHeld } = useMousePressed({
target: sliderRef,
})
const ratio = computed(() => {
const value = modelValue.value / (max - min) * 100
return Math.max(0, Math.min(100, value))
})
const mouseRatio = computed(() => {
const value = mouseInSlider.elementX / mouseInSlider.elementWidth * 100
return Math.max(0, Math.min(100, value))
})
function getValue(mouseRatio: number) {
const rawValue = min + (mouseRatio / 100) * (max - min)
return Math.round(rawValue / step) * step
}
/** 當 disabled 範圍變動時,強制將數值限制在範圍內 */
watch([minDisabled, maxDisabled], () => {
modelValue.value = Math.max(
minDisabled.value,
Math.min(maxDisabled.value, modelValue.value),
)
})
/** 拖動方向 */
const draggingDirection = computed(() => {
return mouseRatio.value > ratio.value ? 1 : -1
})
const disabledValue = ref(false)
const isDisabled = computed(() => disabledValue.value)
/** 拖動時更新數值,碰到 disabled 範圍邊界就停止 */
watch([mouseRatio, isHeld], () => {
if (!isHeld.value)
return
const targetValue = getValue(mouseRatio.value)
let currentValue = modelValue.value
if (targetValue === currentValue)
return
const direction = draggingDirection.value
const stepDir = step * direction
while (true) {
if (
(direction === -1 && currentValue < minDisabled.value)
|| (direction === 1 && currentValue > maxDisabled.value)
) {
disabledValue.value = true
return
}
modelValue.value = currentValue
disabledValue.value = false
if (
(direction === 1 && currentValue >= targetValue)
|| (direction === -1 && currentValue <= targetValue)
) {
return
}
currentValue += stepDir
}
}, { deep: true })
// --- 向量工具 ---
function getVectorLength({ x, y }: { x: number; y: number }) {
return Math.sqrt(x * x + y * y)
}
function mapNumber(
current: number,
inMin: number,
inMax: number,
outMin: number,
outMax: number,
) {
const mapped = ((current - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin
return Math.max(Math.min(mapped, Math.max(outMin, outMax)), Math.min(outMin, outMax))
}
// --- SVG 握把 ---
const mousePosition = computed(() => ({
x: mouseInSlider.elementX - mouseInSlider.elementWidth * (ratio.value / 100),
y: mouseInSlider.elementY - 24,
}))
const endPoint = ref({ x: 0, y: 0 })
const length = computed(() => getVectorLength(endPoint.value))
useIntervalFn(() => {
if (!isHeld.value || !isDisabled.value)
return
const newPoint = {
x: (mousePosition.value.x - endPoint.value.x) / 2 + endPoint.value.x,
y: (mousePosition.value.y - endPoint.value.y) / 2 + endPoint.value.y,
}
const pointLength = getVectorLength(newPoint)
if (pointLength > maxThumbLength) {
const noise = Math.random() * 4
const scaleFactor = maxThumbLength / pointLength
newPoint.x = newPoint.x * scaleFactor + noise
newPoint.y = newPoint.y * scaleFactor + noise
}
endPoint.value = newPoint
}, 15)
watch(isHeld, (value) => {
if (value)
return
anime({
targets: endPoint.value,
x: 0,
y: 0,
easing: 'easeOutElastic',
duration: 300,
})
})
watch(() => isDisabled.value, () => {
if (!isDisabled.value) {
endPoint.value = { x: 0, y: 0 }
}
})
// --- 控制點 ---
const ctrlPoint = ref({ x: 0, y: 0 })
let ctrlPointVelocity = { x: 0, y: 0 }
const ctrlPointStiffness = computed(() => mapNumber(
length.value, 0, maxThumbLength, 2, 3,
))
const ctrlPointDamping = computed(() => mapNumber(
length.value, 0, maxThumbLength, 0.7, 0.9,
))
useIntervalFn(() => {
const targetPoint = {
x: endPoint.value.x / 2,
y: endPoint.value.y / 2,
}
const dx = targetPoint.x - ctrlPoint.value.x
const dy = targetPoint.y - ctrlPoint.value.y
ctrlPointVelocity.x += ctrlPointStiffness.value * dx
ctrlPointVelocity.y += ctrlPointStiffness.value * dy
ctrlPointVelocity.x *= ctrlPointDamping.value
ctrlPointVelocity.y *= ctrlPointDamping.value
if (Math.abs(ctrlPointVelocity.x) < 0.001
&& Math.abs(ctrlPointVelocity.y) < 0.001) {
ctrlPointVelocity = { x: 0, y: 0 }
return
}
ctrlPoint.value.x += ctrlPointVelocity.x
ctrlPoint.value.y += ctrlPointVelocity.y
if (Math.abs(ctrlPoint.value.x) > windowSize.width / 2)
ctrlPoint.value.x = ctrlPoint.value.x > 0 ? windowSize.width / 2 : -windowSize.width / 2
if (Math.abs(ctrlPoint.value.y) > windowSize.height / 2)
ctrlPoint.value.y = ctrlPoint.value.y > 0 ? windowSize.height / 2 : -windowSize.height / 2
}, 15)
// --- 繪製 ---
const pathD = computed(() => {
const { x: ctrlX, y: ctrlY } = ctrlPoint.value
const { x: endX, y: endY } = endPoint.value
return `M0 0 Q${ctrlX} ${ctrlY}, ${endX} ${endY}`
})
const strokeWidth = computed(() => mapNumber(
length.value, 0, maxThumbLength, thumbSize, Math.max(thumbSize * 0.1, 5),
))
const svgSize = ref(thumbSize * 1.5)
useIntervalFn(() => {
let newSize = thumbSize
if (isHeld.value && isDisabled.value) {
newSize = Math.max(
Math.abs(mousePosition.value.x),
Math.abs(mousePosition.value.y),
thumbSize,
) * 2
if (newSize > maxThumbLength * 2)
newSize = maxThumbLength * 2
}
newSize += thumbSize * 1.5
const delta = newSize - svgSize.value
if (Math.abs(delta) < 0.01) {
svgSize.value = newSize
return
}
svgSize.value += delta > 0 ? delta : delta / 10
}, 15)
const svgStyle = computed<CSSProperties>(() => ({
left: `${isDisabled.value ? ratio.value : (isHeld.value ? mouseRatio.value : ratio.value)}%`,
transitionDuration: isHeld.value ? '0s' : '0.2s',
}))
</script>
<style scoped lang="sass">
.track
height: 8px
background: #EEE
border-radius: 9999px
.active-track
height: 8px
background: rgba(52, 198, 235, 0.2)
.thumb-svg
top: 50%
transform: translate(-50%, -50%)
will-change: left width height
transition-property: left
transition-timing-function: cubic-bezier(0.85, 0, 0.15, 1)
</style>

部分停用的拖動邏輯比全部停用複雜得多。數值不能直接跳到滑鼠位置,而是要一步一步走,碰到邊界就停下來:

watch([mouseRatio, isHeld], () => {
const targetValue = getValue(mouseRatio.value)
let currentValue = modelValue.value
const direction = draggingDirection.value
const stepDir = step * direction
while (true) {
// 碰到邊界,啟動 disabled
if (
(direction === -1 && currentValue < minDisabled.value)
|| (direction === 1 && currentValue > maxDisabled.value)
) {
disabledValue.value = true
return
}
modelValue.value = currentValue
disabledValue.value = false
// 已經到達目標
if (
(direction === 1 && currentValue >= targetValue)
|| (direction === -1 && currentValue <= targetValue)
) {
return
}
currentValue += stepDir
}
})

你可能會想:直接把 modelValue 設成 targetValue 再做邊界檢查不就好了?

問題在於 step 吸附。如果 step 是 5,targetValue 可能是 15,而 minDisabled 是 12。直接設成 15 會跳過 12 這個邊界。逐步走才能精確地在邊界停下來。

元件不用一個固定的 disabled prop,而是用 disabledValue 動態判斷:

const disabledValue = ref(false)
const isDisabled = computed(() => disabledValue.value)

拖動碰到邊界時 disabledValue 變成 true,握把開始拉伸。往回拖時 disabledValue 變成 false,回到正常模式。

watch([minDisabled, maxDisabled], () => {
modelValue.value = Math.max(
minDisabled.value,
Math.min(maxDisabled.value, modelValue.value),
)
})

如果外部改變了 disabled 範圍,目前數值可能落在新範圍之外。這個 watch 確保數值永遠在合法範圍內。

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

步驟概念關鍵技術
1靜態軌道HTML + CSS 基礎結構
2追蹤滑鼠useMouseInElement
3數值映射ratio 百分比計算
4拖動互動useMousePressed + watch
5Step 吸附Math.round + 浮點數精度修正
6SVG 握把SVG path + Q 貝茲曲線指令
7握把拉伸終點追蹤 + useIntervalFn 指數衰減
8彈簧震盪彈簧-阻尼物理模型 + 動態 SVG 尺寸
9回彈動畫anime.js easeOutElastic
10停用範圍逐步前進邏輯 + 動態 disabled

最終的元件還拆分成了主元件與握把子元件,加入了 v-model、thumbColor、thumbSize 等 props。

完整元件請見:固執的滑動條 🐟

最後,用分層爆炸圖來回顧整個元件的 DOM 堆疊結構:

控制點 Q 貝茲曲線path (Q)橡皮筋握把最前層svg.thumb-svgposition: absolute · 動態尺寸SVG 容器第 2 層滑動軌道div.track圓角條 · bg: #EEE第 3 層外層容器div.slider-stubborncursor: grab · 事件攔截最底層

整個元件由 4 層 DOM 節點堆疊而成。爆炸圖由上到下依照視覺前後順序排列,最上面是使用者看到的最前層,最下面是最底層:

使用者實際看到的握把。用 SVG 的 Q 指令畫出一條二次貝茲曲線,由三個關鍵點決定形狀:

  • 起點 (0, 0):固定在 SVG 中心,也就是握把的圓形本體
  • 終點 endPoint:disabled 時朝滑鼠方向延伸,用指數衰減追蹤滑鼠位置
  • 控制點 ctrlPoint:追蹤終點中點,透過彈簧物理產生震盪效果

線寬(strokeWidth)會隨長度動態變化,越拉越細,模擬橡皮筋的視覺效果。

握把子元件的根節點。用 position: absolute 疊在軌道上方,left 值會根據 ratio 或 mouseRatio 動態切換:

  • 拖動時跟著 mouseRatio(即時回饋)
  • 放開後回到 ratio(數值位置)

SVG 的 width、height、viewBox 也是動態的,被拉伸時自動擴大,避免曲線被裁切。

純視覺元素。一條灰色圓角條,代表可拖動的範圍。沒有任何邏輯,CSS 就搞定。

最底層:div.slider-stubborn(外層容器)

Section titled “最底層:div.slider-stubborn(外層容器)”

最外層的定位參考點。負責攔截 mousedown、touchstart 等事件防止預設行為,同時透過 VueUse 的 composable 計算出所有核心狀態:

  • mouseRatio:滑鼠在軌道上的百分比位置
  • isHeld:滑鼠是否正在按住
  • sliderSize:軌道的實際尺寸(px)

這些狀態會透過 props 傳給內層的握把子元件。