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 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 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')
/** 落點金額由低到高對應的回應,取第一個沒超過上限的 */
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
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 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 slideLocked 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.
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.
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