Crane

No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/slider-crane
Source on GitHub

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.

More sliders

See all
Dice
Dive
Egg
Flipbook