Swing Ride
import { useEffect, useRef, useState, type CSSProperties } from 'react';
const CHAIRS = 8;
const RIM = 52; // radius the chains hang from
const MAX_RPM = 12;
const css = `
.slider-swing-ride { --paint: light-dark(#6d7179, #a7abb2); display: grid; gap: 12px; width: 230px; }
.slider-swing-ride__scene { position: relative; height: 146px; perspective: 560px; perspective-origin: 50% 10%; }
/* The ride is seen from a little above: the chairs' circle reads as an ellipse. */
.slider-swing-ride__world { position: absolute; left: 50%; top: 22px; width: 0; height: 0; transform-style: preserve-3d; transform: rotateX(-16deg); }
.slider-swing-ride__spin { position: absolute; transform-style: preserve-3d; transform: rotateY(var(--slider-swing-ride-phi, 0deg)); }
.slider-swing-ride__arm { position: absolute; transform-style: preserve-3d; transform: rotateY(var(--a)) translateZ(${RIM}px); }
/* A chain hangs from the rim and swings outward with the speed. */
.slider-swing-ride__chain { position: absolute; left: -0.75px; top: 0; width: 1.5px; height: 46px; transform-origin: 50% 0; transform: rotateX(var(--slider-swing-ride-theta, calc(var(--slider-swing-ride-v) * 58deg))); background: light-dark(#6d7179, #7d8087); }
.slider-swing-ride__seat { position: absolute; left: -6px; bottom: -8px; width: 12px; height: 9px; border-radius: 2px 2px 4px 4px; background: var(--paint); box-shadow: inset 0 -3px 0 color-mix(in oklab, var(--paint) 70%, light-dark(#fbfcfd, #121417)); }
.slider-swing-ride__seat::before { content: ''; position: absolute; left: 3px; top: -7px; width: 6px; height: 7px; border-radius: 50% 50% 2px 2px; background: light-dark(#42454c, #e5e6e9); }
/* The canopy: its stripes slide past at the speed of the ride. */
.slider-swing-ride__canopy { position: absolute; left: 50%; top: 2px; width: 120px; height: 26px; translate: -50% 0; clip-path: polygon(50% 0, 100% 78%, 96% 100%, 4% 100%, 0 78%); background: repeating-linear-gradient(90deg, light-dark(#50535b, #c6c8cd) 0 10px, light-dark(#cbced3, #393c41) 10px 20px); background-position: calc(var(--slider-swing-ride-phi-n, 0) * -0.9px) 0; }
.slider-swing-ride__rim { position: absolute; left: 50%; top: 24px; width: 112px; height: 6px; translate: -50% 0; border-radius: 3px; background: radial-gradient(circle, light-dark(#373a40, #f1f2f3) 0 1.3px, transparent 1.8px) 0 50% / 9px 6px repeat-x, light-dark(#6d7179, #7d8087); }
.slider-swing-ride[data-moving] .slider-swing-ride__rim { animation: slider-swing-ride-lights 0.5s steps(2) infinite; }
.slider-swing-ride__pole { position: absolute; left: 50%; top: 26px; width: 8px; height: 100px; translate: -50% 0; border-radius: 3px; background: linear-gradient(90deg, light-dark(#6d7179, #7d8087), light-dark(#50535b, #c6c8cd) 45%, light-dark(#cbced3, #393c41)); }
.slider-swing-ride__base { position: absolute; left: 50%; top: 120px; width: 150px; height: 20px; translate: -50% 0; border-radius: 50%; background: radial-gradient(ellipse at 50% 40%, light-dark(#dbdde1, #2e3035), light-dark(#ebedf0, #232529)); box-shadow: inset 0 -3px 0 light-dark(#cbced3, #393c41); }
.slider-swing-ride__read { display: flex; align-items: baseline; justify-content: space-between; }
.slider-swing-ride__rpm { color: light-dark(#373a40, #f1f2f3); font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; }
.slider-swing-ride__rpm b { font-weight: inherit; }
.slider-swing-ride__rpm small { margin-left: 4px; color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-weight: 500; }
.slider-swing-ride__word { color: light-dark(#5f626a, #a7abb2); font-size: 11px; }
.slider-swing-ride__track { position: relative; height: 22px; }
.slider-swing-ride__rail { position: absolute; left: 0; right: 0; top: 9px; height: 4px; border-radius: 2px; background: light-dark(#dbdde1, #2e3035); }
.slider-swing-ride__fill { position: absolute; left: 0; top: 9px; height: 4px; border-radius: 2px; width: calc(11px + var(--slider-swing-ride-v) * (100% - 22px)); background: light-dark(#50535b, #c6c8cd); }
.slider-swing-ride__thumb { position: absolute; top: 0; left: calc(var(--slider-swing-ride-v) * (100% - 22px)); width: 22px; height: 22px; border-radius: 50%; background: conic-gradient(light-dark(#373a40, #f1f2f3) 0 45deg, light-dark(#50535b, #c6c8cd) 45deg 90deg, light-dark(#373a40, #f1f2f3) 90deg 135deg, light-dark(#50535b, #c6c8cd) 135deg 180deg, light-dark(#373a40, #f1f2f3) 180deg 225deg, light-dark(#50535b, #c6c8cd) 225deg 270deg, light-dark(#373a40, #f1f2f3) 270deg 315deg, light-dark(#50535b, #c6c8cd) 315deg); box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)), inset 0 0 0 1.5px light-dark(#373a40, #f1f2f3); rotate: var(--slider-swing-ride-phi, 0deg); }
.slider-swing-ride:has(.slider-swing-ride__input:focus-visible) .slider-swing-ride__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-swing-ride__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-swing-ride__input::-webkit-slider-thumb { -webkit-appearance: none; width: 22px; height: 22px; }
.slider-swing-ride__input::-moz-range-thumb { width: 22px; height: 22px; border: 0; }
@keyframes slider-swing-ride-lights { 50% { background-position: 4.5px 50%, 0 0; } }
@media (prefers-reduced-motion: reduce) {
.slider-swing-ride__rim { animation: none !important; }
}
`;
const word = (rpm: number) => (rpm === 0 ? 'Stopped' : rpm < 5 ? 'Gentle' : rpm < 9 ? 'Swinging' : 'Flying');
export function Preview() {
const [rpm, setRpm] = useState(7);
const root = useRef<HTMLDivElement>(null);
const live = useRef({ target: rpm / MAX_RPM, speed: rpm / MAX_RPM, phi: 0, raf: 0, last: 0 });
// The ride has weight: its speed eases toward the setting, the angle of the
// chains follows the speed it has actually reached, not the one you asked for.
useEffect(() => {
const el = root.current!;
const m = live.current;
m.target = rpm / MAX_RPM;
const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const paint = () => {
el.style.setProperty('--slider-swing-ride-phi', `${m.phi.toFixed(2)}deg`);
el.style.setProperty('--slider-swing-ride-phi-n', m.phi.toFixed(1));
el.style.setProperty('--slider-swing-ride-theta', `${(58 * m.speed ** 1.3).toFixed(2)}deg`);
el.toggleAttribute('data-moving', m.speed > 0.02);
};
if (still) {
m.speed = m.target;
paint();
return;
}
const step = (now: number) => {
const dt = Math.min(0.05, (now - (m.last || now)) / 1000);
m.last = now;
m.speed += (m.target - m.speed) * Math.min(1, dt * 1.6);
m.phi = (m.phi + m.speed * 260 * dt) % 360;
paint();
m.raf = m.target > 0 || m.speed > 0.002 ? requestAnimationFrame(step) : 0;
};
cancelAnimationFrame(m.raf);
m.last = 0;
m.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(m.raf);
}, [rpm]);
return (
<>
<style href="kit-sliders-swing-ride" precedence="kit">
{css}
</style>
<div ref={root} className="slider-swing-ride" style={{ '--slider-swing-ride-v': (rpm / MAX_RPM).toFixed(4) } as CSSProperties}>
<div className="slider-swing-ride__scene" aria-hidden="true">
<span className="slider-swing-ride__base" />
<span className="slider-swing-ride__pole" />
<span className="slider-swing-ride__world">
<span className="slider-swing-ride__spin">
{Array.from({ length: CHAIRS }, (_, i) => (
<span key={i} className="slider-swing-ride__arm" style={{ '--a': `${(i * 360) / CHAIRS}deg` } as CSSProperties}>
<span className="slider-swing-ride__chain">
<span className="slider-swing-ride__seat" />
</span>
</span>
))}
</span>
</span>
<span className="slider-swing-ride__canopy" />
<span className="slider-swing-ride__rim" />
</div>
<div className="slider-swing-ride__read" aria-hidden="true">
<span className="slider-swing-ride__rpm">
<b>{rpm}</b>
<small>rpm</small>
</span>
<span className="slider-swing-ride__word">{word(rpm)}</span>
</div>
<div className="slider-swing-ride__track">
<span className="slider-swing-ride__rail" aria-hidden="true" />
<span className="slider-swing-ride__fill" aria-hidden="true" />
<span className="slider-swing-ride__thumb" aria-hidden="true" />
<input className="slider-swing-ride__input" type="range" min={0} max={MAX_RPM} value={rpm} onChange={(e) => setRpm(+e.target.value)} aria-label="Ride speed" aria-valuetext={`${rpm} revolutions per minute, ${word(rpm).toLowerCase()}`} />
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderSwingRide(el) on the mounted element instead.
<div class="slider-swing-ride" style="--slider-swing-ride-v:0.5833">
<div class="slider-swing-ride__scene" aria-hidden="true">
<span class="slider-swing-ride__base"></span>
<span class="slider-swing-ride__pole"></span>
<span class="slider-swing-ride__world">
<span class="slider-swing-ride__spin">
<span class="slider-swing-ride__arm" style="--a:0deg">
<span class="slider-swing-ride__chain">
<span class="slider-swing-ride__seat"></span>
</span>
</span>
<span class="slider-swing-ride__arm" style="--a:45deg">
<span class="slider-swing-ride__chain">
<span class="slider-swing-ride__seat"></span>
</span>
</span>
<span class="slider-swing-ride__arm" style="--a:90deg">
<span class="slider-swing-ride__chain">
<span class="slider-swing-ride__seat"></span>
</span>
</span>
<span class="slider-swing-ride__arm" style="--a:135deg">
<span class="slider-swing-ride__chain">
<span class="slider-swing-ride__seat"></span>
</span>
</span>
<span class="slider-swing-ride__arm" style="--a:180deg">
<span class="slider-swing-ride__chain">
<span class="slider-swing-ride__seat"></span>
</span>
</span>
<span class="slider-swing-ride__arm" style="--a:225deg">
<span class="slider-swing-ride__chain">
<span class="slider-swing-ride__seat"></span>
</span>
</span>
<span class="slider-swing-ride__arm" style="--a:270deg">
<span class="slider-swing-ride__chain">
<span class="slider-swing-ride__seat"></span>
</span>
</span>
<span class="slider-swing-ride__arm" style="--a:315deg">
<span class="slider-swing-ride__chain">
<span class="slider-swing-ride__seat"></span>
</span>
</span>
</span>
</span>
<span class="slider-swing-ride__canopy"></span>
<span class="slider-swing-ride__rim"></span>
</div>
<div class="slider-swing-ride__read" aria-hidden="true">
<span class="slider-swing-ride__rpm">
<b>7</b>
<small>rpm</small>
</span>
<span class="slider-swing-ride__word">Swinging</span>
</div>
<div class="slider-swing-ride__track">
<span class="slider-swing-ride__rail" aria-hidden="true"></span>
<span class="slider-swing-ride__fill" aria-hidden="true"></span>
<span class="slider-swing-ride__thumb" aria-hidden="true"></span>
<input class="slider-swing-ride__input" type="range" min="0" max="12" aria-label="Ride speed" aria-valuetext="7 revolutions per minute, swinging" value="7"/>
</div>
</div>.slider-swing-ride { --paint: light-dark(#6d7179, #a7abb2); display: grid; gap: 12px; width: 230px; }
.slider-swing-ride__scene { position: relative; height: 146px; perspective: 560px; perspective-origin: 50% 10%; }
/* The ride is seen from a little above: the chairs' circle reads as an ellipse. */
.slider-swing-ride__world { position: absolute; left: 50%; top: 22px; width: 0; height: 0; transform-style: preserve-3d; transform: rotateX(-16deg); }
.slider-swing-ride__spin { position: absolute; transform-style: preserve-3d; transform: rotateY(var(--slider-swing-ride-phi, 0deg)); }
.slider-swing-ride__arm { position: absolute; transform-style: preserve-3d; transform: rotateY(var(--a)) translateZ(52px); }
/* A chain hangs from the rim and swings outward with the speed. */
.slider-swing-ride__chain { position: absolute; left: -0.75px; top: 0; width: 1.5px; height: 46px; transform-origin: 50% 0; transform: rotateX(var(--slider-swing-ride-theta, calc(var(--slider-swing-ride-v) * 58deg))); background: light-dark(#6d7179, #7d8087); }
.slider-swing-ride__seat { position: absolute; left: -6px; bottom: -8px; width: 12px; height: 9px; border-radius: 2px 2px 4px 4px; background: var(--paint); box-shadow: inset 0 -3px 0 color-mix(in oklab, var(--paint) 70%, light-dark(#fbfcfd, #121417)); }
.slider-swing-ride__seat::before { content: ''; position: absolute; left: 3px; top: -7px; width: 6px; height: 7px; border-radius: 50% 50% 2px 2px; background: light-dark(#42454c, #e5e6e9); }
/* The canopy: its stripes slide past at the speed of the ride. */
.slider-swing-ride__canopy { position: absolute; left: 50%; top: 2px; width: 120px; height: 26px; translate: -50% 0; clip-path: polygon(50% 0, 100% 78%, 96% 100%, 4% 100%, 0 78%); background: repeating-linear-gradient(90deg, light-dark(#50535b, #c6c8cd) 0 10px, light-dark(#cbced3, #393c41) 10px 20px); background-position: calc(var(--slider-swing-ride-phi-n, 0) * -0.9px) 0; }
.slider-swing-ride__rim { position: absolute; left: 50%; top: 24px; width: 112px; height: 6px; translate: -50% 0; border-radius: 3px; background: radial-gradient(circle, light-dark(#373a40, #f1f2f3) 0 1.3px, transparent 1.8px) 0 50% / 9px 6px repeat-x, light-dark(#6d7179, #7d8087); }
.slider-swing-ride[data-moving] .slider-swing-ride__rim { animation: slider-swing-ride-lights 0.5s steps(2) infinite; }
.slider-swing-ride__pole { position: absolute; left: 50%; top: 26px; width: 8px; height: 100px; translate: -50% 0; border-radius: 3px; background: linear-gradient(90deg, light-dark(#6d7179, #7d8087), light-dark(#50535b, #c6c8cd) 45%, light-dark(#cbced3, #393c41)); }
.slider-swing-ride__base { position: absolute; left: 50%; top: 120px; width: 150px; height: 20px; translate: -50% 0; border-radius: 50%; background: radial-gradient(ellipse at 50% 40%, light-dark(#dbdde1, #2e3035), light-dark(#ebedf0, #232529)); box-shadow: inset 0 -3px 0 light-dark(#cbced3, #393c41); }
.slider-swing-ride__read { display: flex; align-items: baseline; justify-content: space-between; }
.slider-swing-ride__rpm { color: light-dark(#373a40, #f1f2f3); font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; }
.slider-swing-ride__rpm b { font-weight: inherit; }
.slider-swing-ride__rpm small { margin-left: 4px; color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-weight: 500; }
.slider-swing-ride__word { color: light-dark(#5f626a, #a7abb2); font-size: 11px; }
.slider-swing-ride__track { position: relative; height: 22px; }
.slider-swing-ride__rail { position: absolute; left: 0; right: 0; top: 9px; height: 4px; border-radius: 2px; background: light-dark(#dbdde1, #2e3035); }
.slider-swing-ride__fill { position: absolute; left: 0; top: 9px; height: 4px; border-radius: 2px; width: calc(11px + var(--slider-swing-ride-v) * (100% - 22px)); background: light-dark(#50535b, #c6c8cd); }
.slider-swing-ride__thumb { position: absolute; top: 0; left: calc(var(--slider-swing-ride-v) * (100% - 22px)); width: 22px; height: 22px; border-radius: 50%; background: conic-gradient(light-dark(#373a40, #f1f2f3) 0 45deg, light-dark(#50535b, #c6c8cd) 45deg 90deg, light-dark(#373a40, #f1f2f3) 90deg 135deg, light-dark(#50535b, #c6c8cd) 135deg 180deg, light-dark(#373a40, #f1f2f3) 180deg 225deg, light-dark(#50535b, #c6c8cd) 225deg 270deg, light-dark(#373a40, #f1f2f3) 270deg 315deg, light-dark(#50535b, #c6c8cd) 315deg); box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)), inset 0 0 0 1.5px light-dark(#373a40, #f1f2f3); rotate: var(--slider-swing-ride-phi, 0deg); }
.slider-swing-ride:has(.slider-swing-ride__input:focus-visible) .slider-swing-ride__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-swing-ride__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-swing-ride__input::-webkit-slider-thumb { -webkit-appearance: none; width: 22px; height: 22px; }
.slider-swing-ride__input::-moz-range-thumb { width: 22px; height: 22px; border: 0; }
@keyframes slider-swing-ride-lights { 50% { background-position: 4.5px 50%, 0 0; } }
@media (prefers-reduced-motion: reduce) {
.slider-swing-ride__rim { animation: none !important; }
}function initSliderSwingRide(root) {
var input = root.querySelector('.slider-swing-ride__input');
var number = root.querySelector('.slider-swing-ride__rpm b');
var word = root.querySelector('.slider-swing-ride__word');
var max = +input.max;
var still = window.matchMedia('(prefers-reduced-motion: reduce)');
var speed = +input.value / max;
var phi = 0;
var raf = 0;
var last = 0;
function label(rpm) { return rpm === 0 ? 'Stopped' : rpm < 5 ? 'Gentle' : rpm < 9 ? 'Swinging' : 'Flying'; }
function paint() {
root.style.setProperty('--slider-swing-ride-phi', phi.toFixed(2) + 'deg');
root.style.setProperty('--slider-swing-ride-phi-n', phi.toFixed(1));
root.style.setProperty('--slider-swing-ride-theta', (58 * Math.pow(speed, 1.3)).toFixed(2) + 'deg');
root.toggleAttribute('data-moving', speed > 0.02);
}
// The ride has weight: its speed eases toward the setting, and the chains swing
// out to the speed it has actually reached, not the one you asked for.
function step(now) {
var target = +input.value / max;
var dt = Math.min(0.05, (now - (last || now)) / 1000);
last = now;
speed += (target - speed) * Math.min(1, dt * 1.6);
phi = (phi + speed * 260 * dt) % 360;
paint();
raf = target > 0 || speed > 0.002 ? requestAnimationFrame(step) : 0;
}
function update() {
var rpm = +input.value;
root.style.setProperty('--slider-swing-ride-v', (rpm / max).toFixed(4));
number.textContent = rpm;
word.textContent = label(rpm);
input.setAttribute('aria-valuetext', rpm + ' revolutions per minute, ' + label(rpm).toLowerCase());
if (still.matches) {
speed = rpm / max;
paint();
} else if (!raf) {
last = 0;
raf = requestAnimationFrame(step);
}
}
input.addEventListener('input', update);
update();
return function () {
cancelAnimationFrame(raf);
input.removeEventListener('input', update);
};
}
document.querySelectorAll('.slider-swing-ride').forEach(initSliderSwingRide);Goal: Create a "Swing Ride" slider.
Visual behavior: A fairground swing ride: set the speed and it spins up with real inertia while the chairs fly further out on their chains.
Technique:
- One real <input type="range"> lies transparent over the track (its thumb sized to the drawn handle, so the pointer lines up), and everything drawn is set from its value. A frame loop eases the ride's speed toward the setting and turns it.
- The chairs hang round the rim in preserve-3d, seen from a little above, so their circle reads as an ellipse and the far ones pass behind.
- Each chain swings outward with rotateX by an angle that follows the speed the ride has actually reached, so they rise as it spins up and drop as it slows.
- The canopy stripes slide past at the same speed and its lights chase while it moves; the handle turns with the ride.
Accessibility: A native range input named "Ride speed" with an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn handle. The drawing is aria-hidden. Under prefers-reduced-motion the ride does not spin and the chairs show the angle for the speed you set.
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 "Swing Ride" slider.
Visual behavior: A fairground swing ride: set the speed and it spins up with real inertia while the chairs fly further out on their chains.
Technique:
- One real <input type="range"> lies transparent over the track (its thumb sized to the drawn handle, so the pointer lines up), and everything drawn is set from its value. A frame loop eases the ride's speed toward the setting and turns it.
- The chairs hang round the rim in preserve-3d, seen from a little above, so their circle reads as an ellipse and the far ones pass behind.
- Each chain swings outward with rotateX by an angle that follows the speed the ride has actually reached, so they rise as it spins up and drop as it slows.
- The canopy stripes slide past at the same speed and its lights chase while it moves; the handle turns with the ride.
Accessibility: A native range input named "Ride speed" with an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn handle. The drawing is aria-hidden. Under prefers-reduced-motion the ride does not spin and the chairs show the angle for the speed you set.
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 fairground swing ride: set the speed and it spins up with real inertia while the chairs fly further out on their chains.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- state change, continuous, 3d
- Techniques
- preserve 3d, 3d transform, perspective, raf
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input named "Ride speed" with an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn handle. The drawing is aria-hidden. Under prefers-reduced-motion the ride does not spin and the chairs show the angle for the speed you set.