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
Tip Jar
With slideLocked on, the tie point is pinned to the current value. Dragging sideways only pulls one segment taut and lets the other droop, and the amount is settled entirely by where the thumb lands.
How much you give is not up to you ( ˘ω˘ )
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 op-70">
<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')
function handleLand(amount: number) {
if (amount === 0) {
reactionKey.value = 'zero'
return
}
if (amount < 100) {
reactionKey.value = 'small'
return
}
if (amount < 250) {
reactionKey.value = 'normal'
return
}
if (amount < 450) {
reactionKey.value = 'large'
return
}
reactionKey.value = 'huge'
}
</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
69
70
How It Works
The thumb has five states: idle on the track, pulling, launching while the band is still pushing it, flying once it has come off the band, and recalling. Switching between them is the core of the whole component.
Separating below means the thumb coming off the band, which happens after you release the pointer, not at the same instant.
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. That same path is the rubber band; it is called the track when the geometry matters and the band when the tension does.
<path
d="M0,centerY Q c1x,c1y thumbX,thumbY"
stroke-linecap="round"
fill="none"
/>
<path
d="MthumbX,thumbY Q c2x,c2y width,centerY"
stroke-linecap="round"
fill="none"
/>2
3
4
5
6
7
8
9
10
With the thumb on the center line the track renders as a straight bar; pull up or down and the thumb leaves the line, bending the track into a V.
The dashed line is the track center line. Pull the thumb down and both curves turn with it. The two hollow dots are the control points: while both segments are taut they land exactly on the midpoint of their own segment's chord, so each curve is indistinguishable from a straight line.
fill="none" leaves nothing but the stroke and stroke-linecap is round, so the ends and the seam come out rounded, matching the usual rounded track.
Rest length is how long a segment is with no force on it. Once a segment's span (the straight line between its endpoints) falls below that, it has gone slack, the control point drops, and that half sags into a curve. The sag depth comes from the right triangle formed by the rest length and the span:
sag = sqrt(restLength² - spanLength²) / 2A quadratic curve's midpoint lands on a weighted average of half the control point and a quarter of each endpoint, so pushing the control point down by twice sag drops that midpoint by exactly sag.
A taut segment also thins out. The stroke is flat, so with a fixed area its width goes inversely with its length: thickness is simply rest length over span, growing thinner the further it is pulled, down to a floor of 35% of the original. A slack segment is not stretched at all, so it keeps its full thickness.
The SVG itself sets overflow: visible, so the band still draws in full when the thumb leaves the container.
Chasing the pointer
The thumb never sticks to the pointer while you drag. The component only records where the pointer is, and each frame the thumb closes part of the gap. The fraction scales with frame time, landing around a third at 60 fps. Pressing down also picks up from wherever the thumb already was.
So clicking elsewhere on the track slides the thumb over instead of teleporting it. With slideLocked off the value follows the thumb rather than the pointer, so the two never come apart.
Tension direction
Each band segment pulls with a force proportional to its own stretch, along the unit vector from the thumb to its anchor. Rest length comes from the tie point, the spot where the thumb is knotted to the band, which moves with the current value (an external change to the value eases in over several frames rather than snapping):
ratio = (value - min) / (max - min)
displayRatio → ratio (eased in over frames, never a jump)
anchorX = displayRatio * width
restLeft = anchorX
restRight = width - anchorX
stretchLeft = max(0, length(left - thumb) - restLeft)
stretchRight = max(0, length(right - thumb) - restRight)
direction = normalize(
unit(left - thumb) * stretchLeft + unit(right - thumb) * stretchRight
)2
3
4
5
6
7
8
9
10
11
12
In the implementation stretch is written as a difference of squares over a sum of lengths. That is algebraically the same subtraction, but it stays accurate when the stretch is tiny.
Normally the tie point travels with the thumb, so both segments stay taut. Turn on slideLocked and the tie point is pinned: dragging sideways pulls one segment taut and lets the other droop. A slack segment stretches by 0, contributes no force, and the net direction is set by the taut side alone.
One caveat: the tie point only sets the rest lengths while the thumb is still stuck to the band. Once it comes off, nothing holds the band open, so rest length has to follow the V's own corner instead. Otherwise the horizontal travel of the recoil reads as "this segment got shorter" and the band jitters between slack and taut.
Pulling down stretches the segment with the shorter rest length more, so it pulls harder and its arrow is drawn longer. The dashed lines complete the parallelogram, and the thick arrow is the sum of the two.
While both segments are taut, the closer the thumb sits to the left anchor, the further left the net force tilts, exactly like a real slingshot: it flings toward whichever side you are closer to.
This is what slideLocked plus a down-left drag looks like: the left segment falls below its rest length, sags, and contributes nothing, while the right one is pulled tight. The net force is entirely its own, so the thumb flies up and to the right.
Acceleration and oscillation
The thumb's vertical distance from the track center line is the vertical pull below. Under minLaunchLength nothing launches and the band simply hauls the thumb back to the track; reach that threshold and it enters launching, still stuck to the band and accelerated by its tension.
The acceleration is launchPower² × the vertical pull in magnitude, with the tension setting its direction. When the thumb and the tie point both sit at the horizontal middle of the track the two tensions are symmetric, their horizontal parts cancel, and the whole acceleration is simple harmonic motion, giving a separation speed of exactly launchPower × that pull.
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 extreme angles so it cannot get stuck there.
Once the thumb is gone the band oscillates on its own, one spring-damper system per axis. The damping ratio is 0.6, so it overshoots once and settles rather than ringing, and the initial velocity is capped so a hard launch cannot fling the band off screen.
Flight and landing
Flight switches to viewport coordinates (position: fixed) and moves at constant velocity every frame. Hitting a window edge forces the velocity on that one axis to point away from the edge, leaving it alone if it already does, so the thumb never sticks and rattles against a wall.
The thumb has to clear the contact range (its own radius plus half the track thickness) or leave the track's horizontal span before the check arms, otherwise it would land the instant it separates.
The check has two branches, and every frame tries the crossing one first. When the thumb clears the center line the sign of its offset flips between frames, so the segment between the two positions is intersected with the center line; if that crossing sits inside the track's horizontal span, it lands there and the value comes off the crossing point. Only without a crossing does it ask whether the thumb has entered the contact range, again within the track's horizontal span, and read the value off where the thumb is.
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