Crane
import { useEffect, useRef, useState, type CSSProperties } from 'react';
const X0 = 55; // trolley centre at the shortest reach
const RUN = 164; // and how far out it can go
const CABLE = 86;
const metres = (v: number) => Math.round(10 + v * 50);
// The jib's lattice: top and bottom chords with a zigzag of struts between.
const LATTICE = (() => {
let d = 'M37 30 H228 M37 40 H228 M4 30 H37 M4 38 H37';
for (let x = 37, up = true; x < 228; x += 10, up = !up) d += ` M${x} ${up ? 40 : 30} L${Math.min(228, x + 10)} ${up ? 30 : 40}`;
for (let y = 40; y < 186; y += 12) d += ` M30 ${y} L44 ${y + 12} M44 ${y} L30 ${y + 12}`;
return d;
})();
const css = `
.slider-crane { --steel: light-dark(#5f626a, #7d8087); position: relative; width: 230px; height: 196px; }
.slider-crane__frame { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.slider-crane__lattice { fill: none; stroke: var(--steel); stroke-width: 1.6; stroke-linecap: round; }
.slider-crane__mast { fill: none; stroke: var(--steel); stroke-width: 2.5; }
.slider-crane__pendant { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
.slider-crane__weight { fill: light-dark(#cbced3, #393c41); }
.slider-crane__cab { fill: light-dark(#6d7179, #7d8087); }
.slider-crane__glass { fill: light-dark(#ebedf0, #232529); }
.slider-crane__ground { stroke: light-dark(#cbced3, #393c41); stroke-width: 2; stroke-linecap: round; }
.slider-crane__footing { fill: light-dark(#dbdde1, #2e3035); }
/* The trolley is the handle: it runs along the bottom of the jib. */
.slider-crane__trolley { position: absolute; left: calc(var(--slider-crane-x, ${X0 + 0.45 * RUN}px) - 10px); top: 40px; width: 20px; height: 9px; border-radius: 2px 2px 4px 4px; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 2px 5px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5)); }
.slider-crane__trolley::before { content: ''; position: absolute; left: 2px; right: 2px; top: -3px; height: 4px; background: radial-gradient(circle, light-dark(#50535b, #c6c8cd) 0 2px, transparent 2.5px) 0 0 / 8px 4px repeat-x; }
.slider-crane:has(.slider-crane__input:focus-visible) .slider-crane__trolley { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
/* The load hangs from the trolley and swings about it. */
.slider-crane__load { position: absolute; left: var(--slider-crane-x, ${X0 + 0.45 * RUN}px); top: 49px; width: 0; height: 0; transform-origin: 0 0; rotate: var(--slider-crane-swing, 0deg); }
.slider-crane__cable { position: absolute; left: -2px; top: 0; width: 4px; height: ${CABLE}px; background: linear-gradient(90deg, light-dark(#6d7179, #7d8087) 0 1px, transparent 1px 3px, light-dark(#6d7179, #7d8087) 3px); }
.slider-crane__hook { position: absolute; left: -6px; top: ${CABLE - 2}px; width: 12px; height: 9px; border-radius: 2px 2px 6px 6px; background: light-dark(#50535b, #c6c8cd); }
.slider-crane__sling { position: absolute; left: -18px; top: ${CABLE + 6}px; width: 36px; height: 12px; background: linear-gradient(to top right, transparent calc(50% - 0.8px), light-dark(#6d7179, #7d8087) calc(50% - 0.8px) calc(50% + 0.8px), transparent 0) 0 0 / 50% 100% no-repeat, linear-gradient(to top left, transparent calc(50% - 0.8px), light-dark(#6d7179, #7d8087) calc(50% - 0.8px) calc(50% + 0.8px), transparent 0) 100% 0 / 50% 100% no-repeat; }
.slider-crane__crate { position: absolute; left: -20px; top: ${CABLE + 18}px; width: 40px; height: 24px; border-radius: 2px; background: repeating-linear-gradient(90deg, light-dark(#cbced3, #393c41) 0 7px, color-mix(in oklab, light-dark(#cbced3, #393c41) 80%, light-dark(#fbfcfd, #121417)) 7px 8px); box-shadow: inset 0 0 0 2px light-dark(#6d7179, #7d8087), 0 4px 8px -4px light-dark(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.6)); }
/* The reach rides above the trolley as a tag. */
.slider-crane__tag { position: absolute; left: var(--slider-crane-x, ${X0 + 0.45 * RUN}px); top: 7px; translate: -50% 0; padding: 2px 6px; border-radius: 5px; background: light-dark(#373a40, #f1f2f3); color: light-dark(#f3f4f7, #1a1c1f); font-size: 11px; font-weight: 700; white-space: nowrap; font-variant-numeric: tabular-nums; box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.5)); }
.slider-crane__tag::after { content: ''; position: absolute; left: 50%; bottom: -4px; translate: -50% 0; border: 4px solid transparent; border-bottom: 0; border-top-color: light-dark(#373a40, #f1f2f3); }
.slider-crane__input { position: absolute; left: ${X0 - 10}px; top: 22px; width: ${RUN + 20}px; height: 30px; margin: 0; opacity: 0; cursor: ew-resize; -webkit-appearance: none; appearance: none; background: none; }
.slider-crane__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 30px; }
.slider-crane__input::-moz-range-thumb { width: 20px; height: 30px; border: 0; }
`;
export function Preview() {
const [v, setV] = useState(45);
const root = useRef<HTMLDivElement>(null);
const live = useRef({ x: X0 + 0.45 * RUN, vx: 0, th: 0, w: 0, raf: 0, last: 0 });
// The trolley eases to where you put it; the load is a pendulum hung from it,
// so it lags behind when the trolley starts, swings past when it stops, and settles.
useEffect(() => {
const el = root.current!;
const m = live.current;
const target = X0 + (v / 100) * RUN;
const paint = () => {
el.style.setProperty('--slider-crane-x', `${m.x.toFixed(2)}px`);
el.style.setProperty('--slider-crane-swing', `${((m.th * 180) / Math.PI).toFixed(2)}deg`);
};
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
Object.assign(m, { x: target, vx: 0, th: 0, w: 0 });
paint();
return;
}
const step = (now: number) => {
const dt = Math.min(1 / 30, (now - (m.last || now)) / 1000);
m.last = now;
// Trolley: a stiff, critically damped spring toward the target.
const ax = 160 * (target - m.x) - 2 * Math.sqrt(160) * m.vx;
m.vx += ax * dt;
m.x += m.vx * dt;
// Load: theta'' = -(g / L) sin(theta) - damping - (trolley acceleration) / L.
const alpha = -11 * Math.sin(m.th) - 0.9 * m.w - ax / CABLE;
m.w += alpha * dt;
m.th = Math.max(-0.6, Math.min(0.6, m.th + m.w * dt));
paint();
const settled = Math.abs(target - m.x) < 0.05 && Math.abs(m.vx) < 0.05 && Math.abs(m.th) < 0.001 && Math.abs(m.w) < 0.002;
m.raf = settled ? 0 : requestAnimationFrame(step);
};
cancelAnimationFrame(m.raf);
m.last = 0;
m.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(m.raf);
}, [v]);
const reach = metres(v / 100);
return (
<>
<style href="kit-sliders-crane" precedence="kit">
{css}
</style>
<div ref={root} className="slider-crane">
<svg className="slider-crane__frame" viewBox="0 0 230 196" aria-hidden="true">
<path className="slider-crane__pendant" d="M37 8 L4 30 M37 8 L132 30 M37 8 L226 30" />
<path className="slider-crane__mast" d="M30 186 V40 M44 186 V40 M30 30 L37 8 L44 30" />
<path className="slider-crane__lattice" d={LATTICE} />
<rect className="slider-crane__weight" x="6" y="38" width="20" height="16" rx="2" />
<rect className="slider-crane__cab" x="44" y="40" width="16" height="14" rx="2" />
<rect className="slider-crane__glass" x="51" y="43" width="7" height="6" rx="1" />
<rect className="slider-crane__footing" x="22" y="180" width="30" height="6" rx="1" />
<path className="slider-crane__ground" d="M4 186 H226" />
</svg>
<span className="slider-crane__load" aria-hidden="true">
<span className="slider-crane__cable" />
<span className="slider-crane__hook" />
<span className="slider-crane__sling" />
<span className="slider-crane__crate" />
</span>
<span className="slider-crane__trolley" aria-hidden="true" />
<span className="slider-crane__tag" aria-hidden="true">
{reach} m
</span>
<input className="slider-crane__input" type="range" min={0} max={100} value={v} onChange={(e) => setV(+e.target.value)} aria-label="Reach" aria-valuetext={`${reach} metres`} />
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderCrane(el) on the mounted element instead.
<div class="slider-crane">
<svg class="slider-crane__frame" viewBox="0 0 230 196" aria-hidden="true">
<path class="slider-crane__pendant" d="M37 8 L4 30 M37 8 L132 30 M37 8 L226 30"></path>
<path class="slider-crane__mast" d="M30 186 V40 M44 186 V40 M30 30 L37 8 L44 30"></path>
<path class="slider-crane__lattice" d="M37 30 H228 M37 40 H228 M4 30 H37 M4 38 H37 M37 40 L47 30 M47 30 L57 40 M57 40 L67 30 M67 30 L77 40 M77 40 L87 30 M87 30 L97 40 M97 40 L107 30 M107 30 L117 40 M117 40 L127 30 M127 30 L137 40 M137 40 L147 30 M147 30 L157 40 M157 40 L167 30 M167 30 L177 40 M177 40 L187 30 M187 30 L197 40 M197 40 L207 30 M207 30 L217 40 M217 40 L227 30 M227 30 L228 40 M30 40 L44 52 M44 40 L30 52 M30 52 L44 64 M44 52 L30 64 M30 64 L44 76 M44 64 L30 76 M30 76 L44 88 M44 76 L30 88 M30 88 L44 100 M44 88 L30 100 M30 100 L44 112 M44 100 L30 112 M30 112 L44 124 M44 112 L30 124 M30 124 L44 136 M44 124 L30 136 M30 136 L44 148 M44 136 L30 148 M30 148 L44 160 M44 148 L30 160 M30 160 L44 172 M44 160 L30 172 M30 172 L44 184 M44 172 L30 184 M30 184 L44 196 M44 184 L30 196"></path>
<rect class="slider-crane__weight" x="6" y="38" width="20" height="16" rx="2"></rect>
<rect class="slider-crane__cab" x="44" y="40" width="16" height="14" rx="2"></rect>
<rect class="slider-crane__glass" x="51" y="43" width="7" height="6" rx="1"></rect>
<rect class="slider-crane__footing" x="22" y="180" width="30" height="6" rx="1"></rect>
<path class="slider-crane__ground" d="M4 186 H226"></path>
</svg>
<span class="slider-crane__load" aria-hidden="true">
<span class="slider-crane__cable"></span>
<span class="slider-crane__hook"></span>
<span class="slider-crane__sling"></span>
<span class="slider-crane__crate"></span>
</span>
<span class="slider-crane__trolley" aria-hidden="true"></span>
<span class="slider-crane__tag" aria-hidden="true">33 m</span>
<input class="slider-crane__input" type="range" min="0" max="100" aria-label="Reach" aria-valuetext="33 metres" value="45"/>
</div>.slider-crane { --steel: light-dark(#5f626a, #7d8087); position: relative; width: 230px; height: 196px; }
.slider-crane__frame { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.slider-crane__lattice { fill: none; stroke: var(--steel); stroke-width: 1.6; stroke-linecap: round; }
.slider-crane__mast { fill: none; stroke: var(--steel); stroke-width: 2.5; }
.slider-crane__pendant { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
.slider-crane__weight { fill: light-dark(#cbced3, #393c41); }
.slider-crane__cab { fill: light-dark(#6d7179, #7d8087); }
.slider-crane__glass { fill: light-dark(#ebedf0, #232529); }
.slider-crane__ground { stroke: light-dark(#cbced3, #393c41); stroke-width: 2; stroke-linecap: round; }
.slider-crane__footing { fill: light-dark(#dbdde1, #2e3035); }
/* The trolley is the handle: it runs along the bottom of the jib. */
.slider-crane__trolley { position: absolute; left: calc(var(--slider-crane-x, 128.8px) - 10px); top: 40px; width: 20px; height: 9px; border-radius: 2px 2px 4px 4px; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 2px 5px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5)); }
.slider-crane__trolley::before { content: ''; position: absolute; left: 2px; right: 2px; top: -3px; height: 4px; background: radial-gradient(circle, light-dark(#50535b, #c6c8cd) 0 2px, transparent 2.5px) 0 0 / 8px 4px repeat-x; }
.slider-crane:has(.slider-crane__input:focus-visible) .slider-crane__trolley { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
/* The load hangs from the trolley and swings about it. */
.slider-crane__load { position: absolute; left: var(--slider-crane-x, 128.8px); top: 49px; width: 0; height: 0; transform-origin: 0 0; rotate: var(--slider-crane-swing, 0deg); }
.slider-crane__cable { position: absolute; left: -2px; top: 0; width: 4px; height: 86px; background: linear-gradient(90deg, light-dark(#6d7179, #7d8087) 0 1px, transparent 1px 3px, light-dark(#6d7179, #7d8087) 3px); }
.slider-crane__hook { position: absolute; left: -6px; top: 84px; width: 12px; height: 9px; border-radius: 2px 2px 6px 6px; background: light-dark(#50535b, #c6c8cd); }
.slider-crane__sling { position: absolute; left: -18px; top: 92px; width: 36px; height: 12px; background: linear-gradient(to top right, transparent calc(50% - 0.8px), light-dark(#6d7179, #7d8087) calc(50% - 0.8px) calc(50% + 0.8px), transparent 0) 0 0 / 50% 100% no-repeat, linear-gradient(to top left, transparent calc(50% - 0.8px), light-dark(#6d7179, #7d8087) calc(50% - 0.8px) calc(50% + 0.8px), transparent 0) 100% 0 / 50% 100% no-repeat; }
.slider-crane__crate { position: absolute; left: -20px; top: 104px; width: 40px; height: 24px; border-radius: 2px; background: repeating-linear-gradient(90deg, light-dark(#cbced3, #393c41) 0 7px, color-mix(in oklab, light-dark(#cbced3, #393c41) 80%, light-dark(#fbfcfd, #121417)) 7px 8px); box-shadow: inset 0 0 0 2px light-dark(#6d7179, #7d8087), 0 4px 8px -4px light-dark(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.6)); }
/* The reach rides above the trolley as a tag. */
.slider-crane__tag { position: absolute; left: var(--slider-crane-x, 128.8px); top: 7px; translate: -50% 0; padding: 2px 6px; border-radius: 5px; background: light-dark(#373a40, #f1f2f3); color: light-dark(#f3f4f7, #1a1c1f); font-size: 11px; font-weight: 700; white-space: nowrap; font-variant-numeric: tabular-nums; box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.5)); }
.slider-crane__tag::after { content: ''; position: absolute; left: 50%; bottom: -4px; translate: -50% 0; border: 4px solid transparent; border-bottom: 0; border-top-color: light-dark(#373a40, #f1f2f3); }
.slider-crane__input { position: absolute; left: 45px; top: 22px; width: 184px; height: 30px; margin: 0; opacity: 0; cursor: ew-resize; -webkit-appearance: none; appearance: none; background: none; }
.slider-crane__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 30px; }
.slider-crane__input::-moz-range-thumb { width: 20px; height: 30px; border: 0; }function initSliderCrane(root) {
var input = root.querySelector('.slider-crane__input');
var tag = root.querySelector('.slider-crane__tag');
var start = 55; // trolley centre at the shortest reach
var run = 164;
var cable = 86;
var still = window.matchMedia('(prefers-reduced-motion: reduce)');
var x = start + (+input.value / 100) * run;
var vx = 0;
var swing = 0;
var spin = 0;
var raf = 0;
var last = 0;
function paint() {
root.style.setProperty('--slider-crane-x', x.toFixed(2) + 'px');
root.style.setProperty('--slider-crane-swing', ((swing * 180) / Math.PI).toFixed(2) + 'deg');
}
// The trolley eases to where you put it; the load is a pendulum hung from it, so
// it lags behind when the trolley starts, swings past when it stops, and settles.
function step(now) {
var target = start + (+input.value / 100) * run;
var dt = Math.min(1 / 30, (now - (last || now)) / 1000);
last = now;
var ax = 160 * (target - x) - 2 * Math.sqrt(160) * vx;
vx += ax * dt;
x += vx * dt;
spin += (-11 * Math.sin(swing) - 0.9 * spin - ax / cable) * dt;
swing = Math.max(-0.6, Math.min(0.6, swing + spin * dt));
paint();
var settled = Math.abs(target - x) < 0.05 && Math.abs(vx) < 0.05 && Math.abs(swing) < 0.001 && Math.abs(spin) < 0.002;
raf = settled ? 0 : requestAnimationFrame(step);
}
function update() {
var v = +input.value / 100;
var metres = Math.round(10 + v * 50);
tag.textContent = metres + ' m';
input.setAttribute('aria-valuetext', metres + ' metres');
if (still.matches) {
x = start + v * run;
vx = swing = spin = 0;
paint();
} else if (!raf) {
last = 0;
raf = requestAnimationFrame(step);
}
}
input.addEventListener('input', update);
paint();
update();
return function () {
cancelAnimationFrame(raf);
input.removeEventListener('input', update);
};
}
document.querySelectorAll('.slider-crane').forEach(initSliderCrane);Goal: Create a "Crane" slider.
Visual behavior: A tower crane whose jib is the track: drag the trolley out and the load swings behind it on its cable and settles.
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. It lies over the jib, and a frame loop runs the trolley and the load.
- The trolley eases to its target on a stiff, critically damped spring.
- The load is a pendulum hung from the trolley: its swing is driven by the trolley acceleration, so it lags when the trolley starts, swings past when it stops, and settles.
- The reach rides above the trolley as a tag, and the loop stops once everything is still.
Accessibility: A native range input named "Reach" 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 trolley moves straight to its place and the load hangs still.
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 "Crane" slider.
Visual behavior: A tower crane whose jib is the track: drag the trolley out and the load swings behind it on its cable and settles.
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. It lies over the jib, and a frame loop runs the trolley and the load.
- The trolley eases to its target on a stiff, critically damped spring.
- The load is a pendulum hung from the trolley: its swing is driven by the trolley acceleration, so it lags when the trolley starts, swings past when it stops, and settles.
- The reach rides above the trolley as a tag, and the loop stops once everything is still.
Accessibility: A native range input named "Reach" 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 trolley moves straight to its place and the load hangs still.
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 tower crane whose jib is the track: drag the trolley out and the load swings behind it on its cable and settles.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- state change, spring
- Techniques
- raf, spring, svg, css variables
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input named "Reach" 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 trolley moves straight to its place and the load hangs still.