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

教學:固執的滑動條 tutorial

從零開始,一步步打造一個拖不動的固執滑動條。ᕕ( ゚ ∀。)ᕗ

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

前言

想像你有一個滑動條,使用者試著把數值拖到超出允許範圍,結果握把被拉長了卻死都不肯移動,就像在拉一條很有彈性的橡皮筋。(ノ>ω<)ノ

這就是固執滑動條的核心概念:當數值碰到禁止範圍時,握把會像橡皮筋一樣被拉伸,但死也不讓你過去

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

Step 1:靜態軌道

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

查看範例原始碼
vue
<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 形成圓角膠囊形狀

Step 2:追蹤滑鼠位置

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

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

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

elementX: 0
elementWidth: 0
mouseRatio: NaN%
查看範例原始碼
vue
<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>

mouseRatio 的計算

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

ts
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 文件

Step 3:數值與握把位置

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

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

目前數值:50
查看範例原始碼
vue
<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 cursor-pointer py-3"
    >
      <div class="track rounded-full" />

      <!-- 顯示數值位置的握把 -->
      <div
        class="thumb"
        :style="{ left: `${ratio}%` }"
      />
    </div>

    <input
      v-model.number="modelValue"
      type="number"
      class="w-20 border rounded px-2 py-1 text-sm"
      :min="0"
      :max="100"
    >
  </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:數值到位置的映射

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

ts
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 與 mouseRatio 的差異

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

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

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

Step 4:拖動互動

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

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

目前數值:50 | 拖動中:false
查看範例原始碼
vue
<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>

拖動原理

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

ts
const { pressed: isHeld } = useMousePressed({
  target: sliderRef,
})

watch([mouseRatio, isHeld], () => {
  if (!isHeld.value)
    return
  modelValue.value = getValue(mouseRatio.value)
})

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

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

為什麼握把位置要區分 isHeld?

ts
const thumbStyle = computed(() => ({
  left: `${isHeld.value ? mouseRatio.value : ratio.value}%`,
  transitionDuration: isHeld.value ? '0s' : '0.2s',
}))
  • 按住時:握把跟著 mouseRatio(滑鼠位置),transition 設為 0s 確保零延遲
  • 放開後:握把回到 ratio(數值位置),transition 設為 0.2s 產生平滑過渡

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

防止預設行為

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

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

📚 useMousePressed 文件

Step 5:Step 吸附

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

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

目前數值:50
查看範例原始碼
vue
<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="border rounded px-2 py-1"
        >
          <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 倍數:

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

例如 step=5, rawValue=17Math.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

ts
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.1stepPrecision=1toFixed(1)"50.1"Number("50.1")50.1。精度問題解決。

Step 6:SVG 貝茲曲線握把

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

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

目前數值:50
查看範例原始碼
vue
<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>

為什麼用 SVG path?

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

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

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

path 的 Q 指令

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

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

stroke 相關屬性

html
<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 指令參考

Step 7:握把拉伸

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

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

目前數值:50 | 停用:false
查看範例原始碼
vue
<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):

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

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

為什麼用 useIntervalFn 而不是 watch?

ts
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:

ts
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 讓握把在極限長度時微微抖動,暗示「已經拉到極限了,再拉也沒用」。

線寬隨長度變化

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

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

Step 8:彈簧震盪

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

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

目前數值:50 | 停用:false
查看範例原始碼
vue
<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),公式很簡單:

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

翻成程式碼:

ts
// 彈力:離目標越遠,拉力越大
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

參數映射

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

ts
// 拉越長 → 彈性係數越大 → 震動越快
const ctrlPointStiffness = computed(() => mapNumber(
  length.value, 0, maxThumbLength, 2, 3,
))

// 拉越長 → 阻尼越大 → 震動持續越久
const ctrlPointDamping = computed(() => mapNumber(
  length.value, 0, maxThumbLength, 0.7, 0.9,
))

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

控制點的目標位置

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

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

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

動態 SVG 尺寸

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

ts
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)

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

Step 9:回彈動畫

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

目前數值:50 | 停用:false
查看範例原始碼
vue
<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>

anime.js 回彈

ts
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) 的動畫,看起來就像橡皮筋鬆手後彈回去一樣。

為什麼是 300ms?

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

為什麼不用 CSS transition?

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

📚 anime.js 文件

Step 10:停用範圍

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

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

目前數值:50.0 | 可用範圍:20 ~ 80
查看範例原始碼
vue
<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>

逐步前進的拖動邏輯

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

ts
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
  }
})

為什麼是 while 迴圈?

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

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

動態 disabled 狀態

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

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

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

範圍變動時的保護

ts
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-modelthumbColorthumbSize 等 props。

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

元件結構

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

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

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

最前層:path(貝茲曲線)

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

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

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

第 2 層:svg.thumb-svg(SVG 容器)

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

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

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

第 3 層:div.track(軌道)

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

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

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

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

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

v0.59.1