Ladder

No dependencies
Code format
React + Tailwind
import { useEffect, useId, useRef, useState } from 'react';

const SECTIONS = [
  { label: 'Fiction', body: '1,280 novels, A to Z. The new Ishiguro is on the end.' },
  { label: 'Poetry', body: '214 volumes. The Larkin is out until Friday.' },
  { label: 'History', body: 'Ancient on the top shelf, the last century below.' },
  { label: 'Science', body: 'Field guides and star atlases, and one telescope manual.' },
];
const CENTER = [33, 88, 142, 197];
const BAY = 52;
const SHELF_BOTTOM = [82, 132];

// Books for every bay and shelf, from a fixed seed so they never shuffle.
const BOOKS = (() => {
  let seed = 7;
  const rnd = () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;
  const out: { x: number; y: number; w: number; h: number; tone: number; band: number; tilt: number }[] = [];
  CENTER.forEach((c) => {
    SHELF_BOTTOM.forEach((bottom) => {
      let x = c - BAY / 2 + 2;
      const end = c + BAY / 2 - 2;
      while (x < end - 4) {
        const w = Math.min(end - x, 4 + Math.round(rnd() * 4));
        const h = 28 + Math.round(rnd() * 14);
        const lean = end - x - w > 6 && rnd() < 0.08;
        out.push({ x, y: bottom - h, w, h, tone: Math.floor(rnd() * 4), band: 4 + Math.round(rnd() * 8), tilt: lean ? 14 : 0 });
        x += w + (lean ? 6 : rnd() < 0.2 ? 1 : 0.4);
      }
    });
  });
  return out;
})();

const css = `
  .tabs-ladder { --shade: light-dark(#373a40, #121417); --wood: light-dark(#cbced3, #2e3035); --rail: light-dark(#50535b, #c6c8cd); width: 230px; font-family: Georgia, 'Times New Roman', serif; }
  .tabs-ladder__case { position: relative; height: 144px; }
  .tabs-ladder__plates { position: absolute; inset: 0 0 auto; height: 18px; }
  /* Brass label plates along the top: the tabs. */
  .tabs-ladder__plate { position: absolute; top: 0; width: 48px; height: 17px; margin-left: -24px; padding: 0; border: 0; border-radius: 2px; background: linear-gradient(light-dark(#cbced3, #393c41), light-dark(#dbdde1, #2e3035)); box-shadow: inset 0 0 0 1px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 12%, transparent), 0 1px 2px light-dark(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.5)); color: light-dark(#50535b, #c6c8cd); font: inherit; font-size: 9.5px; font-variant: small-caps; letter-spacing: 0.06em; cursor: pointer; transition: background 0.3s, color 0.3s; }
  .tabs-ladder__plate:hover { color: light-dark(#373a40, #f1f2f3); }
  .tabs-ladder__plate[aria-selected='true'] { background: linear-gradient(light-dark(#42454c, #e5e6e9), light-dark(#5f626a, #a7abb2)); color: light-dark(#fbfcfd, #121417); }
  .tabs-ladder__plate:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
  .tabs-ladder__shelves { position: absolute; left: 0; top: 0; overflow: visible; }
  .tabs-ladder__back { fill: light-dark(#f3f4f7, #1a1c1f); }
  .tabs-ladder__wood { fill: var(--wood); }
  .tabs-ladder__rod { stroke: var(--rail); stroke-width: 2; stroke-linecap: round; }
  .tabs-ladder__book { stroke: color-mix(in oklab, var(--shade) 40%, transparent); stroke-width: 0.5; }
  .tabs-ladder__book[data-tone='0'] { fill: light-dark(#6d7179, #7d8087); }
  .tabs-ladder__book[data-tone='1'] { fill: light-dark(#cbced3, #393c41); }
  .tabs-ladder__book[data-tone='2'] { fill: light-dark(#dbdde1, #2e3035); }
  .tabs-ladder__book[data-tone='3'] { fill: light-dark(#5f626a, #a7abb2); }
  .tabs-ladder__bandline { fill: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 30%, transparent); }
  /* The ladder hangs from the rail by two hooks and rolls on two wheels; it is moved and
     leaned from script, so its weight lags when it sets off and stops. */
  .tabs-ladder__ladder { position: absolute; left: 0; top: 19px; width: 28px; height: 124px; margin-left: -14px; transform-origin: 14px 2px; pointer-events: none; }
  .tabs-ladder__ladder svg { display: block; overflow: visible; }
  .tabs-ladder__stile { stroke: light-dark(#50535b, #a7abb2); stroke-width: 3; stroke-linecap: round; }
  .tabs-ladder__rung { stroke: light-dark(#5f626a, #7d8087); stroke-width: 2.2; stroke-linecap: round; }
  .tabs-ladder__hook { fill: none; stroke: light-dark(#42454c, #e5e6e9); stroke-width: 1.8; stroke-linecap: round; }
  .tabs-ladder__wheel circle { fill: light-dark(#ebedf0, #232529); stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.5; }
  .tabs-ladder__wheel line { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.2; }
  .tabs-ladder__panel { min-height: 3em; margin-top: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12.5px; font-style: italic; line-height: 1.5; }
`;

export function Preview() {
  const id = useId();
  const [sel, setSel] = useState(1);
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const ladder = useRef<HTMLSpanElement>(null);
  const wheels = useRef<(SVGGElement | null)[]>([]);
  const st = useRef({ x: CENTER[1], v: 0, th: 0, w: 0, raf: 0 });

  useEffect(() => {
    const s = st.current;
    const draw = () => {
      if (ladder.current) ladder.current.style.transform = `translateX(${s.x}px) rotate(${s.th}rad)`;
      wheels.current.forEach((g) => g?.setAttribute('transform', `rotate(${(s.x / 4) * (180 / Math.PI)})`));
    };
    cancelAnimationFrame(s.raf);
    if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
      Object.assign(s, { x: CENTER[sel], v: 0, th: 0, w: 0 });
      draw();
      return;
    }
    let last = performance.now();
    const step = (now: number) => {
      const dt = Math.min(0.032, (now - last) / 1000);
      last = now;
      // A spring rolls the ladder over; its acceleration swings the foot the other way.
      const a = 60 * (CENTER[sel] - s.x) - 12.5 * s.v;
      s.v += a * dt;
      s.x += s.v * dt;
      s.w += (-210 * s.th - 9 * s.w + 0.0011 * a) * dt;
      s.th = Math.max(-0.14, Math.min(0.14, s.th + s.w * dt));
      draw();
      if (Math.abs(CENTER[sel] - s.x) < 0.1 && Math.abs(s.v) < 0.5 && Math.abs(s.th) < 0.0005 && Math.abs(s.w) < 0.005) {
        Object.assign(s, { x: CENTER[sel], v: 0, th: 0, w: 0 });
        draw();
        return;
      }
      s.raf = requestAnimationFrame(step);
    };
    s.raf = requestAnimationFrame(step);
    return () => cancelAnimationFrame(s.raf);
  }, [sel]);

  const n = SECTIONS.length;
  const onKeyDown = (e: React.KeyboardEvent) => {
    const next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
    if (next === undefined) return;
    e.preventDefault();
    setSel(next);
    tabs.current[next]?.focus();
  };
  return (
    <>
      <style href="kit-tabs-ladder" precedence="kit">
        {css}
      </style>
      <div className="tabs-ladder">
        <div className="tabs-ladder__case">
          <svg className="tabs-ladder__shelves" width="230" height="144" viewBox="0 0 230 144" aria-hidden="true">
            <rect className="tabs-ladder__back" x="4" y="28" width="222" height="110" />
            {BOOKS.map((b, i) => (
              <g key={i} transform={b.tilt ? `rotate(${b.tilt} ${b.x + b.w} ${b.y + b.h})` : undefined}>
                <rect className="tabs-ladder__book" data-tone={b.tone} x={b.x} y={b.y} width={b.w} height={b.h} rx="0.6" />
                <rect className="tabs-ladder__bandline" x={b.x + 0.5} y={b.y + b.band} width={b.w - 1} height="1.2" />
              </g>
            ))}
            <rect className="tabs-ladder__wood" x="2" y="24" width="226" height="5" rx="1" />
            {[2, 57, 112, 166, 221].map((x) => (
              <rect key={x} className="tabs-ladder__wood" x={x} y="26" width="7" height="114" />
            ))}
            {SHELF_BOTTOM.map((y) => (
              <rect key={y} className="tabs-ladder__wood" x="2" y={y} width="226" height="5" />
            ))}
            <rect className="tabs-ladder__wood" x="2" y="137" width="226" height="6" rx="1" />
            <line className="tabs-ladder__rod" x1="1" x2="229" y1="22" y2="22" />
          </svg>
          <div className="tabs-ladder__plates" role="tablist" aria-label="Sections" onKeyDown={onKeyDown}>
            {SECTIONS.map((s, i) => (
              <button
                key={s.label}
                ref={(el) => {
                  tabs.current[i] = el;
                }}
                className="tabs-ladder__plate"
                type="button"
                role="tab"
                id={`${id}-tab-${i}`}
                aria-selected={sel === i}
                aria-controls={`${id}-panel-${i}`}
                tabIndex={sel === i ? 0 : -1}
                style={{ left: CENTER[i] }}
                onClick={() => setSel(i)}
              >
                {s.label}
              </button>
            ))}
          </div>
          <span ref={ladder} className="tabs-ladder__ladder" style={{ transform: `translateX(${CENTER[1]}px)` }} aria-hidden="true">
            <svg width="28" height="124" viewBox="0 0 28 124">
              <path className="tabs-ladder__hook" d="M4 10V3a2.5 2.5 0 0 1 5 0M19 10V3a2.5 2.5 0 0 1 5 0" />
              <line className="tabs-ladder__stile" x1="5" y1="8" x2="3" y2="116" />
              <line className="tabs-ladder__stile" x1="23" y1="8" x2="25" y2="116" />
              {[20, 34, 48, 62, 76, 90, 104].map((y) => (
                <line key={y} className="tabs-ladder__rung" x1={5 - (y / 108) * 2} x2={23 + (y / 108) * 2} y1={y} y2={y} />
              ))}
              {[3, 25].map((cx, k) => (
                <g key={cx} transform={`translate(${cx} 119)`}>
                  <g
                    ref={(el) => {
                      wheels.current[k] = el;
                    }}
                    className="tabs-ladder__wheel"
                  >
                    <circle r="4" />
                    <line x1="-4" x2="4" />
                  </g>
                </g>
              ))}
            </svg>
          </span>
        </div>
        {SECTIONS.map((s, i) => (
          <div key={s.label} className="tabs-ladder__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
            {s.body}
          </div>
        ))}
      </div>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/tab-ladder
Source on GitHub

About this component

A library bookcase with a rolling ladder: pick a section and the ladder rolls along the rail to it, its wheels turning and its weight lagging as it starts and stops.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, spring
Techniques
raf, spring, svg, css transition
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. The drawing is aria-hidden. Under prefers-reduced-motion the ladder is simply at the new section.

More tabs

See all
A 7 cm blade that locks open, for parcels, rope and apples.
Multitool
Pens
WednesdayVitamin D, iron, and a fish oil with dinner.
Pill Box
The May invoices are signed and on their way back.
Pneumatic