Slingshot
import { useEffect, useRef, useState } from 'react';
const css = `
.toggle-slingshot { position: relative; display: inline-flex; align-items: center; gap: 12px; color: light-dark(#42454c, #e5e6e9); font-size: 14px; cursor: pointer; user-select: none; }
.toggle-slingshot__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.toggle-slingshot__track {
position: relative;
width: 88px;
height: 34px;
border-radius: 999px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
touch-action: none;
transition: background-color 0.3s;
}
.toggle-slingshot__input:focus-visible + .toggle-slingshot__track { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
.toggle-slingshot__input:checked + .toggle-slingshot__track { background: light-dark(#dbdde1, #2e3035); }
.toggle-slingshot__band { position: absolute; inset: 0; width: 88px; height: 34px; overflow: visible; }
.toggle-slingshot__band path { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-linecap: round; stroke-linejoin: round; }
.toggle-slingshot__post { fill: light-dark(#6d7179, #7d8087); }
.toggle-slingshot__thumb {
position: absolute;
left: 0;
top: 6px;
width: 22px;
height: 22px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, light-dark(#373a40, #f1f2f3), light-dark(#5f626a, #a7abb2) 80%);
box-shadow: 0 2px 6px -2px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6));
cursor: grab;
}
.toggle-slingshot__thumb:active { cursor: grabbing; }
`;
const W = 88;
const MID = W / 2;
const OFF = 16;
const ON = W - 16;
export function SlingshotToggle() {
const [on, setOn] = useState(false);
const thumb = useRef<HTMLSpanElement>(null);
const band = useRef<SVGPathElement>(null);
const track = useRef<HTMLSpanElement>(null);
const sim = useRef({ x: OFF, v: 0, target: OFF, drag: null as null | { start: number; x0: number }, moved: false });
useEffect(() => {
sim.current.target = on ? ON : OFF;
}, [on]);
useEffect(() => {
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
const s = sim.current;
let last = performance.now();
let raf = 0;
const tick = (now: number) => {
const dt = Math.min(0.033, (now - last) / 1000);
last = now;
if (!s.drag) {
if (still) {
s.x = s.target;
s.v = 0;
} else {
// A loose spring: it overshoots and settles like a ball on elastic.
s.v += ((s.target - s.x) * 320 - s.v * 13) * dt;
s.x += s.v * dt;
}
}
thumb.current!.style.transform = `translateX(${(s.x - 11).toFixed(2)}px)`;
// The band runs post, thumb, post; the further it stretches, the thinner it gets.
const stretch = Math.abs(s.x - MID);
band.current!.setAttribute('d', `M${MID} 2 Q ${s.x.toFixed(1)} 17 ${MID} 32`);
band.current!.style.strokeWidth = String(Math.max(1, 2.6 - stretch / 30));
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
const local = (clientX: number) => clientX - track.current!.getBoundingClientRect().left;
return (
<>
<style>
{css}
</style>
<label
className="toggle-slingshot"
onClick={(e) => {
// A drag already decided the state; don't let the click flip it back.
if (sim.current.moved) {
e.preventDefault();
sim.current.moved = false;
}
}}
>
<input className="toggle-slingshot__input" type="checkbox" role="switch" checked={on} onChange={(e) => setOn(e.target.checked)} />
<span ref={track} className="toggle-slingshot__track" aria-hidden="true">
<svg className="toggle-slingshot__band" viewBox={`0 0 ${W} 34`}>
<path ref={band} />
<circle className="toggle-slingshot__post" cx={MID} cy="2" r="2.5" />
<circle className="toggle-slingshot__post" cx={MID} cy="32" r="2.5" />
</svg>
<span
ref={thumb}
className="toggle-slingshot__thumb"
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
const s = sim.current;
s.drag = { start: local(e.clientX), x0: s.x };
s.moved = false;
}}
onPointerMove={(e) => {
const s = sim.current;
if (!s.drag) return;
const dx = local(e.clientX) - s.drag.start;
if (Math.abs(dx) > 3) s.moved = true;
s.x = Math.max(2, Math.min(W - 2, s.drag.x0 + dx));
s.v = 0;
}}
onPointerUp={() => {
const s = sim.current;
if (!s.drag) return;
s.drag = null;
if (!s.moved) return;
// Pulled back past its rest and let go: fire across. Dragged over the middle: it lands there.
const pulled = on ? s.x - ON : OFF - s.x;
const crossed = on ? s.x < MID : s.x > MID;
if (pulled > 4 || crossed) {
s.v = (on ? -1 : 1) * Math.max(0, pulled) * 22;
setOn(!on);
}
}}
/>
</span>
Fling mode
</label>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initToggleSlingshot(el) on the mounted element instead.
<label class="toggle-slingshot"><input class="toggle-slingshot__input" type="checkbox" role="switch"/><span class="toggle-slingshot__track" aria-hidden="true"><svg class="toggle-slingshot__band" viewBox="0 0 88 34"><path></path><circle class="toggle-slingshot__post" cx="44" cy="2" r="2.5"></circle><circle class="toggle-slingshot__post" cx="44" cy="32" r="2.5"></circle></svg><span class="toggle-slingshot__thumb"></span></span>Fling mode</label>.toggle-slingshot { position: relative; display: inline-flex; align-items: center; gap: 12px; color: light-dark(#42454c, #e5e6e9); font-size: 14px; cursor: pointer; user-select: none; }
.toggle-slingshot__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.toggle-slingshot__track {
position: relative;
width: 88px;
height: 34px;
border-radius: 999px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
touch-action: none;
transition: background-color 0.3s;
}
.toggle-slingshot__input:focus-visible + .toggle-slingshot__track { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
.toggle-slingshot__input:checked + .toggle-slingshot__track { background: light-dark(#dbdde1, #2e3035); }
.toggle-slingshot__band { position: absolute; inset: 0; width: 88px; height: 34px; overflow: visible; }
.toggle-slingshot__band path { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-linecap: round; stroke-linejoin: round; }
.toggle-slingshot__post { fill: light-dark(#6d7179, #7d8087); }
.toggle-slingshot__thumb {
position: absolute;
left: 0;
top: 6px;
width: 22px;
height: 22px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, light-dark(#373a40, #f1f2f3), light-dark(#5f626a, #a7abb2) 80%);
box-shadow: 0 2px 6px -2px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6));
cursor: grab;
}
.toggle-slingshot__thumb:active { cursor: grabbing; }function initToggleSlingshot(root) {
var input = root.querySelector('input');
var track = root.querySelector('.toggle-slingshot__track');
var thumb = root.querySelector('.toggle-slingshot__thumb');
var band = root.querySelector('.toggle-slingshot__band path');
if (!input || !track || !thumb || !band) return;
var W = 88;
var MID = W / 2;
var OFF = 16;
var ON = W - 16;
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var s = { x: input.checked ? ON : OFF, v: 0, drag: null, moved: false };
var last = performance.now();
var raf = 0;
function tick(now) {
var dt = Math.min(0.033, (now - last) / 1000);
last = now;
var target = input.checked ? ON : OFF;
if (!s.drag) {
if (still) {
s.x = target;
s.v = 0;
} else {
// A loose spring: it overshoots and settles like a ball on elastic.
s.v += ((target - s.x) * 320 - s.v * 13) * dt;
s.x += s.v * dt;
}
}
thumb.style.transform = 'translateX(' + (s.x - 11).toFixed(2) + 'px)';
// The band runs post, thumb, post; the further it stretches, the thinner it gets.
band.setAttribute('d', 'M' + MID + ' 2 Q ' + s.x.toFixed(1) + ' 17 ' + MID + ' 32');
band.style.strokeWidth = String(Math.max(1, 2.6 - Math.abs(s.x - MID) / 30));
raf = requestAnimationFrame(tick);
}
function local(e) { return e.clientX - track.getBoundingClientRect().left; }
function down(e) {
thumb.setPointerCapture(e.pointerId);
s.drag = { start: local(e), x0: s.x };
s.moved = false;
}
function move(e) {
if (!s.drag) return;
var dx = local(e) - s.drag.start;
if (Math.abs(dx) > 3) s.moved = true;
s.x = Math.max(2, Math.min(W - 2, s.drag.x0 + dx));
s.v = 0;
}
function up() {
if (!s.drag) return;
s.drag = null;
if (!s.moved) return;
// Pulled back past its rest and let go: fire across. Dragged over the middle: it lands there.
var on = input.checked;
var pulled = on ? s.x - ON : OFF - s.x;
var crossed = on ? s.x < MID : s.x > MID;
if (pulled > 4 || crossed) {
s.v = (on ? -1 : 1) * Math.max(0, pulled) * 22;
input.checked = !on;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
}
// A drag already decided the state; don't let the click that follows flip it back.
function click(e) {
if (s.moved) {
e.preventDefault();
s.moved = false;
}
}
thumb.addEventListener('pointerdown', down);
thumb.addEventListener('pointermove', move);
thumb.addEventListener('pointerup', up);
root.addEventListener('click', click);
raf = requestAnimationFrame(tick);
return function () {
cancelAnimationFrame(raf);
thumb.removeEventListener('pointerdown', down);
thumb.removeEventListener('pointermove', move);
thumb.removeEventListener('pointerup', up);
root.removeEventListener('click', click);
};
}
document.querySelectorAll('.toggle-slingshot').forEach(initToggleSlingshot);Goal: Create a "Slingshot" toggle.
Visual behavior: A fling switch on an elastic band: pull the thumb back against the band and let go, and it fires across to the other side and wobbles to rest. A plain click flicks it over too.
Technique:
- The state is a native checkbox (role="switch") in a <label>. The band is an SVG quadratic curve from a post on the top edge, bent to the thumb, to a post on the bottom edge, thinner the further it stretches.
- A spring (k 320, damping 13) moves the thumb toward its target every frame, so every change overshoots and settles.
- Dragging moves the thumb directly. Released after a pull of more than 4px, or past the middle, it flips the state with a launch speed proportional to the pull; the click that follows a drag is cancelled.
Accessibility: A native checkbox with role="switch" inside a <label>, so Space toggles it and screen readers announce a switch; the visible label names it and :focus-visible on the input outlines the visual. Keyboard and click toggle it like any switch. Under prefers-reduced-motion it jumps without the spring.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Slingshot" toggle.
Visual behavior: A fling switch on an elastic band: pull the thumb back against the band and let go, and it fires across to the other side and wobbles to rest. A plain click flicks it over too.
Technique:
- The state is a native checkbox (role="switch") in a <label>. The band is an SVG quadratic curve from a post on the top edge, bent to the thumb, to a post on the bottom edge, thinner the further it stretches.
- A spring (k 320, damping 13) moves the thumb toward its target every frame, so every change overshoots and settles.
- Dragging moves the thumb directly. Released after a pull of more than 4px, or past the middle, it flips the state with a launch speed proportional to the pull; the click that follows a drag is cancelled.
Accessibility: A native checkbox with role="switch" inside a <label>, so Space toggles it and screen readers announce a switch; the visible label names it and :focus-visible on the input outlines the visual. Keyboard and click toggle it like any switch. Under prefers-reduced-motion it jumps without the spring.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A fling switch on an elastic band: pull the thumb back against the band and let go, and it fires across to the other side and wobbles to rest. A plain click flicks it over too.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, spring, state change
- Techniques
- raf, spring, event position
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native checkbox with role="switch" inside a <label>, so Space toggles it and screen readers announce a switch; the visible label names it and :focus-visible on the input outlines the visual. Keyboard and click toggle it like any switch. Under prefers-reduced-motion it jumps without the spring.