Slingshot Slider slider
Pull it back and off it goes ლ(╹◡╹ლ)
Tech Keywords
| Name | Description |
|---|---|
| SVG | Graphics format usable in the DOM, great for complex shapes and animation; combined with Vue v-bind it enables data-driven effects |
| Physics Simulation | Simulates real-world physics such as gravity, collisions, and velocity |
| Spring-Damper System | Simulates spring oscillation and rebound with stiffness and damping constants, commonly used for natural UI motion feedback |
| Vector Math | Math operations for direction, acceleration, velocity, and more |
| Pointer Events | Detects pointer movement, clicks, hovers, and more, providing coordinates and target information |
| Anime.js | Lightweight JavaScript animation library |
Examples
Basic Usage
Pull up or down to bend the track into a V, then let go and the thumb shoots off =͟͟͞( •̀д•́)
There is no drag in flight. The thumb bounces off the window edges and only stops when it hits the track it came from.
View example source
<template>
<div class="w-full flex flex-col items-center py-24">
<div class="max-w-[180px] w-full flex flex-col gap-4">
<div>value: {{ value }}</div>
<slider-slingshot
v-model="value"
class="w-full"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import SliderSlingshot from '../slider-slingshot.vue'
const value = ref(50)
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Quote Form
Rumor has it a good mood raises the budget? ԅ(´∀` ԅ)
View example source
<template>
<div class="example-wrap relative w-full flex justify-center">
<div class="max-w-[20rem] w-full flex flex-col gap-5 py-6">
<div class="flex flex-col gap-1">
<span class="text-lg font-bold">{{ t('title') }}</span>
<span class="text-sm op-60">{{ t('subtitle') }}</span>
</div>
<label class="flex flex-col gap-1">
<span class="text-sm font-bold">{{ t('projectType') }}</span>
<base-select
v-model="form.projectType"
class="w-full"
>
<option
v-for="projectType in projectTypeList"
:key="projectType"
:value="projectType"
>
{{ t(`projectTypeOption.${projectType}`) }}
</option>
</base-select>
</label>
<div class="flex flex-col gap-2">
<div class="flex items-baseline justify-between">
<span class="text-sm font-bold">{{ t('budget') }}</span>
<span class="text-2xl font-bold">{{ budgetText }}</span>
</div>
<slider-slingshot
v-model="form.budget"
class="max-w-[12rem] w-full self-center"
:min="30000"
:max="1000000"
:step="10000"
thumb-color="#6366f1"
track-color="#e0e7ff"
/>
<span class="text-xs op-60">{{ t('budgetHint') }}</span>
</div>
<base-input
v-model="form.email"
type="email"
:label="t('email')"
placeholder="[email protected]"
/>
<div class="mt-3 flex justify-center">
<base-btn
:label="t('submit')"
@click="isSubmitted = true"
/>
</div>
</div>
<transition name="opacity">
<div
v-if="isSubmitted"
class="absolute inset-0 z-[40] flex flex-col items-center justify-center gap-6 rounded-xl bg-slate-600 bg-opacity-90 text-white"
@click="reset"
>
<div class="flex flex-col items-center gap-2">
<span class="text-xl tracking-wide">
{{ t('submitted') }}
</span>
<span class="text-sm">
{{ summaryText }}
</span>
</div>
<span class="cursor-pointer text-xs">
{{ t('retry') }}
</span>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import BaseInput from '../../base-input.vue'
import BaseSelect from '../../base-select.vue'
import SliderSlingshot from '../slider-slingshot.vue'
type ProjectType = 'brandSite' | 'eCommerce' | 'app' | 'other'
interface QuoteForm {
projectType: ProjectType;
budget: number;
email: string;
}
const { t } = useI18n()
const projectTypeList: ProjectType[] = ['brandSite', 'eCommerce', 'app', 'other']
function createForm(): QuoteForm {
return {
projectType: 'brandSite',
budget: 150000,
email: '',
}
}
const form = ref(createForm())
const isSubmitted = ref(false)
/** 千分位固定用逗號,伺服器渲染與瀏覽器的語系不同也不會對不上 */
const budgetText = computed(() =>
`${t('currency')}${form.value.budget.toLocaleString('en-US')}`,
)
const summaryText = computed(() => t('summary', {
projectType: t(`projectTypeOption.${form.value.projectType}`),
budget: budgetText.value,
}))
function reset() {
isSubmitted.value = false
form.value = createForm()
}
</script>
<style lang="sass" scoped>
.opacity-enter-active, .opacity-leave-active
transition-duration: 0.4s
.opacity-enter-from, .opacity-leave-to
opacity: 0 !important
</style>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
Tip Jar
With slideLocked on the thumb is pinned to the current value. Dragging sideways only pulls one side taut and lets the other droop, and the amount is settled entirely by where it lands.
Want to give less? Earn it. Try landing on zero ( ˘ω˘ )
View example source
<template>
<div class="w-full flex flex-col items-center py-24">
<div class="max-w-[300px] w-full flex flex-col gap-5">
<div class="flex items-baseline justify-between">
<span class="text-sm op-60">{{ t('title') }}</span>
<span class="text-2xl font-bold">{{ t('currency') }}{{ value }}</span>
</div>
<slider-slingshot
v-model="value"
slide-locked
class="w-full"
:max="500"
:step="5"
thumb-color="#f2b705"
track-color="#f7e7b0"
@land="handleLand"
/>
<div class="min-h-6 text-sm opacity-60">
<transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-150 ease-in"
enter-from-class="!op-0 translate-y-2"
leave-to-class="!op-0 -translate-y-2"
>
<span
:key="reactionKey"
class="block"
>
{{ t(`reaction.${reactionKey}`) }}
</span>
</transition>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SliderSlingshot from '../slider-slingshot.vue'
const { t } = useI18n()
const value = ref(500)
const reactionKey = ref('waiting')
/** 落點金額由低到高對應的回應,取第一個沒超過上限的 */
const reactionList = [
{ maxAmount: 0, key: 'zero' },
{ maxAmount: 30, key: 'coin' },
{ maxAmount: 75, key: 'halfCup' },
{ maxAmount: 130, key: 'americano' },
{ maxAmount: 200, key: 'bubbleTea' },
{ maxAmount: 300, key: 'wholeWeek' },
{ maxAmount: 400, key: 'beyondDrink' },
{ maxAmount: 480, key: 'tooMuch' },
{ maxAmount: Number.POSITIVE_INFINITY, key: 'sugarDaddy' },
]
function handleLand(amount: number) {
const reaction = reactionList.find((item) => amount <= item.maxAmount)
reactionKey.value = reaction?.key ?? 'zero'
}
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
Disabled
Turn on disabled and the value is locked. ԅ(´∀` ԅ)
View example source
<template>
<div class="example-wrap relative w-full flex justify-center">
<div class="max-w-[20rem] w-full flex flex-col gap-4 py-6">
<div class="flex items-baseline justify-between">
<span class="text-sm op-60">{{ t('score') }}</span>
<span class="text-2xl font-bold">{{ value }} / 5</span>
</div>
<slider-slingshot
v-model="value"
class="w-full"
:disabled="confirmed"
:max="5"
/>
<base-checkbox
v-model="confirmed"
class="text-sm"
:label="t('confirmation')"
/>
<div class="mt-3 flex justify-center">
<base-btn
:label="t('submit')"
:disabled="!confirmed"
ignore-click
@click="isSubmitted = true"
/>
</div>
</div>
<transition name="opacity">
<div
v-if="isSubmitted"
class="absolute inset-0 z-[40] flex flex-col items-center justify-center gap-6 rounded-xl bg-slate-600 bg-opacity-90 text-white"
@click="reset"
>
<span class="text-xl tracking-wide">
{{ t('submitted', { value }) }}
</span>
<span class="cursor-pointer text-xs">
{{ t('retry') }}
</span>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import BaseCheckbox from '../../base-checkbox.vue'
import SliderSlingshot from '../slider-slingshot.vue'
const { t } = useI18n()
const value = ref(3)
/** 確認後評分定案,握把還拉得動,但射不出去也改不了分數 */
const confirmed = ref(false)
const isSubmitted = ref(false)
function reset() {
isSubmitted.value = false
confirmed.value = false
}
</script>
<style lang="sass" scoped>
.opacity-enter-active, .opacity-leave-active
transition-duration: 0.4s
.opacity-enter-from, .opacity-leave-to
opacity: 0 !important
</style>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
How It Works
The thumb has five states: idle on the track, pulling, launching while the band accelerates it, flying once it has come off the band, and recalling. Switching between them is the core of the whole logic.
The thumb coming off the band is called separating below. Separating only happens once you release the pointer.
The V-shaped track
The track is two SVG path elements, one quadratic curve from the left anchor to the thumb and another from the thumb to the right anchor. They are drawn separately because each segment works out its own thickness. The path is also the rubber band: the track when the geometry matters, the band when the tension does.
With the thumb on the center line the control points land on the midpoint of their own chord, so the track is indistinguishable from a straight line. Pull up or down and both segments swing with the thumb, bending the track into a V.
Once a segment's span (the straight line between its endpoints) falls below its rest length it has gone slack, the control point drops, and that half sags. A quadratic curve's midpoint is a weighted average, half the control point and a quarter of each endpoint, so moving the control point down by twice sag drops that midpoint by exactly sag:
sag = sqrt(restLength² - spanLength²) / 2A taut segment also thins out. The band is a flat strip of fixed area, so its thickness goes inversely with its length, in practice rest length over span, thinning only as far as 35% of the original. A slack segment is not stretched, so it keeps its full thickness.
Chasing the pointer
The thumb never sticks to the pointer while you drag. The component records where the pointer is and the thumb closes part of the gap each frame, around a third at 60 fps. Pressing down starts the thumb from wherever it already was.
So clicking elsewhere on the track slides the thumb over rather than teleporting it. The value is then recomputed from the thumb; the next section covers how.
Tension direction
Each band segment pulls with a force proportional to its own strain, along the unit vector from the thumb to its anchor. Strain is stretch over rest length: lengthen two segments by the same 10px and the shorter one is worked far harder, so it pulls harder.
How rest length is worked out depends on whether the thumb is knotted to the band, and the two cases behave very differently.
With slideLocked off the thumb slides freely along the band, like a ring threaded on a rope. The band slips through until the tension on both sides matches, so the effective rest lengths are handed out in proportion to the spans and the two strains come out equal:
spanTotal = spanLeft + spanRight
restLeft = width * spanLeft / spanTotal
restRight = width * spanRight / spanTotal2
3
That split also decides the value. Since the thumb slides along the band, where it sits on the original track is the share of rest length lying to its left. On the center line that share is exactly the horizontal position; pull down and the band slips, so the value drifts toward the middle, and the deeper the pull the further the two extremes stay out of reach.
Both tensions match, so the arrows are the same length; the dashed lines complete the parallelogram and the thick arrow, their sum, bisects the angle between them. With the thumb near the left anchor the left segment is steep and the right one shallow, and a shallow segment pulls almost purely sideways, so the net force tilts right. Exactly like a real slingshot: it flings away from whichever side you are closer to.
Turn on slideLocked or disabled and the thumb is tied to the tie point instead. The tie point is where the thumb sits on the band, and it moves with the current value. Now each segment carries its own rest length (restLeft = anchorX), so dragging sideways pulls one taut and may leave the other drooping.
This is a tied-down thumb plus a down-left drag. The left segment's span falls below its rest length, it sags, and 0 strain means no force; the taut right segment decides the net direction on its own, and the thumb flies up and to the right.
Acceleration and oscillation
The thumb's vertical distance from the track center line is the pull below. Under minLaunchLength nothing flies out and the band hauls the thumb back to the center line; reach that threshold and it enters launching, still stuck to the band and accelerated by its tension. With disabled on that threshold is effectively infinite, so the thumb never leaves.
When disabled, the snap back is deliberately over the top. The band's angular frequency doubles first, so it springs back fast and sharp. On release the band also gets a kick back toward the tie point on top of the displacement, 1.5 × the displacement × the angular frequency, so the thumb overshoots to the other side. The damping ratio drops from 0.6 to 0.12, so it wobbles for several rounds before settling, making the "no" unmistakable.
The acceleration is launchPower² × the pull in magnitude, with the tension deciding its direction. When the thumb and the tie point both sit midway between the two anchors, the two tensions are symmetric and their horizontal parts cancel. The whole acceleration phase is then simple harmonic motion, giving a separation speed of exactly launchPower × the pull at the moment you let go. Away from the middle the tension leans to one side, some of the speed goes horizontal, and both the vertical speed and the total change.
The thumb separates the moment it crosses the center line, with a one second cap on the acceleration phase for the wilder angles so it cannot get stuck there. Once it is gone the band oscillates on its own, one spring-damper system per axis.
Flight and landing
Flight switches to viewport coordinates (position: fixed) and moves at constant velocity. Hitting a window edge turns the velocity on that axis to point away from the edge, and leaves it alone if it already does, so the thumb does not end up juddering against the edge.
Hitting an edge also buzzes the phone, the buzz lasting in proportion to the impact speed, between 5 and 40ms. It runs on VueUse's useVibrate, which is a no-op where the device does not support it. iOS Safari has never implemented the Vibration API, so only Android users feel this one.
The thumb is a circle and the track is a thick line with rounded ends. They land when they touch. The thumb's center being no further from the track than its own radius plus half the track thickness counts as touching, 19px by default.
Join up every center position that just touches the track and you get a capsule-shaped region, a half circle at each end; call it the contact range. A center inside the contact range is touching, including a little way past either end of the track.
Between one frame and the next the thumb moves from its old position to its new one, so did the center slip inside the contact range at any point along the way? Checking only the two positions is not enough; the center can dip in and back out in between.
So the check measures the whole path, the segment joining those two positions, and how close it ever gets to the track. Within 19px it lands, and the value is worked out from wherever that closest center maps onto the track.
When two segments do not intersect, the shortest distance between them always falls at an endpoint of one of them, which is a standing result. So four projections plus one intersection test give the answer, with nothing to iterate towards. Measure from the path's start and end to the track, from the track's left and right anchors to the path, and zero if the path passes through.
Two more things. A single frame's travel can be longer than the whole track, so each frame is first cut by speed into sub-steps no longer than 19px, which also bounces the thumb at the right point along the path. And since the thumb is touching the track the moment it separates, landing is only tested once a whole step's path has left the contact range.
Every comparison runs on squared distances, so the landing check never takes a square root and saves a little work.
Source
API
Props
interface Props {
modelValue: number;
/** 停用數值變更,握把仍可拉扯,但放手一律彈回,射不出去。@default false */
disabled?: boolean;
/** 鎖住繫點,握把仍可四處拉扯,但數值只由落點決定。@default false */
slideLocked?: boolean;
/** 最小值。@default 0 */
min?: number;
/** 最大值。@default 100 */
max?: number;
/** 數值間距。@default 1 */
step?: number;
/** 握把直徑(px)。@default 30 */
thumbSize?: number;
/** 握把顏色。@default '#34c6eb' */
thumbColor?: string;
/** 軌道顏色。@default '#EEE' */
trackColor?: string;
/** 軌道粗細(px)。@default 8 */
trackThickness?: number;
/** 射出握把所需的最短垂直拉扯量(px),不足則彈回軌道。@default 24 */
minLaunchLength?: number;
/** 橡皮筋的角頻率,也是每 1px 拉扯量換算成的脫手速度(px/s)。加速與回彈共用。@default 30 */
launchPower?: number;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Emits
const emit = defineEmits<{
'update:modelValue': [value: Props['modelValue']];
/** 握把脫手,帶出離手速度(px/s) */
'launch': [velocity: { x: number; y: number }];
/** 握把落回軌道,帶出落點換算的數值 */
'land': [value: number];
}>()2
3
4
5
6
7
Methods
interface Expose {
/** 召回飛行中的握把 */
recall: () => Promise<void>;
}2
3
4