Ladder
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsLadder(el) on the mounted element instead.
<div class="tabs-ladder">
<div class="tabs-ladder__case">
<svg class="tabs-ladder__shelves" width="230" height="144" viewBox="0 0 230 144" aria-hidden="true">
<rect class="tabs-ladder__back" x="4" y="28" width="222" height="110"></rect>
<g>
<rect class="tabs-ladder__book" data-tone="0" x="9" y="41" width="4" height="41" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="9.5" y="51" width="3" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="2" x="13.4" y="43" width="5" height="39" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="13.9" y="52" width="4" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="2" x="18.8" y="51" width="7" height="31" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="19.3" y="61" width="6" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="3" x="26.8" y="47" width="7" height="35" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="27.3" y="52" width="6" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="2" x="34.2" y="44" width="8" height="38" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="34.7" y="55" width="7" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="1" x="42.6" y="49" width="5" height="33" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="43.1" y="54" width="4" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="3" x="48" y="50" width="6" height="32" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="48.5" y="58" width="5" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="2" x="9" y="103" width="8" height="29" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="9.5" y="111" width="7" height="1.2"></rect>
</g>
<g transform="rotate(14 22.4 132)">
<rect class="tabs-ladder__book" data-tone="1" x="17.4" y="91" width="5" height="41" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="17.9" y="98" width="4" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="0" x="28.4" y="91" width="7" height="41" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="28.9" y="98" width="6" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="2" x="35.8" y="98" width="5" height="34" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="36.3" y="107" width="4" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="2" x="41.199999999999996" y="98" width="8" height="34" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="41.699999999999996" y="107" width="7" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="3" x="49.599999999999994" y="94" width="6" height="38" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="50.099999999999994" y="101" width="5" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="1" x="64" y="46" width="5" height="36" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="64.5" y="53" width="4" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="0" x="69.4" y="52" width="8" height="30" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="69.9" y="59" width="7" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="2" x="78.4" y="40" width="4" height="42" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="78.9" y="45" width="3" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="3" x="82.80000000000001" y="52" width="6" height="30" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="83.30000000000001" y="63" width="5" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="0" x="89.20000000000002" y="43" width="8" height="39" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="89.70000000000002" y="49" width="7" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="2" x="98.20000000000002" y="45" width="4" height="37" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="98.70000000000002" y="53" width="3" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="0" x="102.60000000000002" y="42" width="5" height="40" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="103.10000000000002" y="49" width="4" height="1.2"></rect>
</g>
<g transform="rotate(14 68 132)">
<rect class="tabs-ladder__book" data-tone="3" x="64" y="97" width="4" height="35" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="64.5" y="106" width="3" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="0" x="74" y="104" width="8" height="28" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="74.5" y="116" width="7" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="0" x="82.4" y="100" width="7" height="32" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="82.9" y="109" width="6" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="1" x="90.4" y="94" width="7" height="38" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="90.9" y="98" width="6" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="2" x="98.4" y="102" width="8" height="30" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="98.9" y="113" width="7" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="3" x="107.4" y="92" width="4.599999999999994" height="40" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="107.9" y="104" width="3.5999999999999943" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="3" x="118" y="51" width="6" height="31" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="118.5" y="58" width="5" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="1" x="124.4" y="42" width="6" height="40" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="124.9" y="51" width="5" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="0" x="130.8" y="44" width="5" height="38" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="131.3" y="50" width="4" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="3" x="136.20000000000002" y="45" width="8" height="37" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="136.70000000000002" y="51" width="7" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="3" x="144.60000000000002" y="47" width="5" height="35" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="145.10000000000002" y="59" width="4" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="2" x="150.00000000000003" y="48" width="5" height="34" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="150.50000000000003" y="56" width="4" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="1" x="155.40000000000003" y="43" width="6" height="39" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="155.90000000000003" y="53" width="5" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="2" x="118" y="96" width="5" height="36" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="118.5" y="102" width="4" height="1.2"></rect>
</g>
<g transform="rotate(14 130.4 132)">
<rect class="tabs-ladder__book" data-tone="3" x="123.4" y="98" width="7" height="34" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="123.9" y="109" width="6" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="3" x="136.4" y="91" width="8" height="41" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="136.9" y="95" width="7" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="0" x="144.8" y="94" width="6" height="38" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="145.3" y="99" width="5" height="1.2"></rect>
</g>
<g transform="rotate(14 155.8 132)">
<rect class="tabs-ladder__book" data-tone="2" x="151.8" y="103" width="4" height="29" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="152.3" y="115" width="3" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="0" x="161.8" y="102" width="4.199999999999989" height="30" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="162.3" y="111" width="3.1999999999999886" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="2" x="173" y="40" width="6" height="42" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="173.5" y="50" width="5" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="3" x="179.4" y="53" width="6" height="29" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="179.9" y="65" width="5" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="0" x="186.4" y="47" width="8" height="35" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="186.9" y="56" width="7" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="2" x="195.4" y="50" width="4" height="32" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="195.9" y="58" width="3" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="0" x="199.8" y="48" width="4" height="34" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="200.3" y="52" width="3" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="3" x="204.20000000000002" y="51" width="4" height="31" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="204.70000000000002" y="61" width="3" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="3" x="208.60000000000002" y="41" width="7" height="41" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="209.10000000000002" y="50" width="6" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="1" x="216.00000000000003" y="48" width="4.999999999999972" height="34" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="216.50000000000003" y="56" width="3.9999999999999716" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="2" x="173" y="95" width="7" height="37" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="173.5" y="106" width="6" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="3" x="180.4" y="98" width="8" height="34" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="180.9" y="110" width="7" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="2" x="188.8" y="99" width="4" height="33" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="189.3" y="109" width="3" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="3" x="193.20000000000002" y="101" width="7" height="31" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="193.70000000000002" y="106" width="6" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="2" x="200.60000000000002" y="95" width="7" height="37" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="201.10000000000002" y="103" width="6" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="2" x="208.00000000000003" y="92" width="6" height="40" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="208.50000000000003" y="99" width="5" height="1.2"></rect>
</g>
<g>
<rect class="tabs-ladder__book" data-tone="0" x="214.40000000000003" y="96" width="5" height="36" rx="0.6"></rect>
<rect class="tabs-ladder__bandline" x="214.90000000000003" y="107" width="4" height="1.2"></rect>
</g>
<rect class="tabs-ladder__wood" x="2" y="24" width="226" height="5" rx="1"></rect>
<rect class="tabs-ladder__wood" x="2" y="26" width="7" height="114"></rect>
<rect class="tabs-ladder__wood" x="57" y="26" width="7" height="114"></rect>
<rect class="tabs-ladder__wood" x="112" y="26" width="7" height="114"></rect>
<rect class="tabs-ladder__wood" x="166" y="26" width="7" height="114"></rect>
<rect class="tabs-ladder__wood" x="221" y="26" width="7" height="114"></rect>
<rect class="tabs-ladder__wood" x="2" y="82" width="226" height="5"></rect>
<rect class="tabs-ladder__wood" x="2" y="132" width="226" height="5"></rect>
<rect class="tabs-ladder__wood" x="2" y="137" width="226" height="6" rx="1"></rect>
<line class="tabs-ladder__rod" x1="1" x2="229" y1="22" y2="22"></line>
</svg>
<div class="tabs-ladder__plates" role="tablist" aria-label="Sections">
<button class="tabs-ladder__plate" type="button" role="tab" id="tabs-ladder-tab-0" aria-selected="false" aria-controls="tabs-ladder-panel-0" tabindex="-1" style="left:33px">Fiction</button>
<button class="tabs-ladder__plate" type="button" role="tab" id="tabs-ladder-tab-1" aria-selected="true" aria-controls="tabs-ladder-panel-1" tabindex="0" style="left:88px">Poetry</button>
<button class="tabs-ladder__plate" type="button" role="tab" id="tabs-ladder-tab-2" aria-selected="false" aria-controls="tabs-ladder-panel-2" tabindex="-1" style="left:142px">History</button>
<button class="tabs-ladder__plate" type="button" role="tab" id="tabs-ladder-tab-3" aria-selected="false" aria-controls="tabs-ladder-panel-3" tabindex="-1" style="left:197px">Science</button>
</div>
<span class="tabs-ladder__ladder" style="transform:translateX(88px)" aria-hidden="true">
<svg width="28" height="124" viewBox="0 0 28 124">
<path class="tabs-ladder__hook" d="M4 10V3a2.5 2.5 0 0 1 5 0M19 10V3a2.5 2.5 0 0 1 5 0"></path>
<line class="tabs-ladder__stile" x1="5" y1="8" x2="3" y2="116"></line>
<line class="tabs-ladder__stile" x1="23" y1="8" x2="25" y2="116"></line>
<line class="tabs-ladder__rung" x1="4.62962962962963" x2="23.37037037037037" y1="20" y2="20"></line>
<line class="tabs-ladder__rung" x1="4.37037037037037" x2="23.62962962962963" y1="34" y2="34"></line>
<line class="tabs-ladder__rung" x1="4.111111111111111" x2="23.88888888888889" y1="48" y2="48"></line>
<line class="tabs-ladder__rung" x1="3.851851851851852" x2="24.14814814814815" y1="62" y2="62"></line>
<line class="tabs-ladder__rung" x1="3.5925925925925926" x2="24.40740740740741" y1="76" y2="76"></line>
<line class="tabs-ladder__rung" x1="3.333333333333333" x2="24.666666666666668" y1="90" y2="90"></line>
<line class="tabs-ladder__rung" x1="3.0740740740740744" x2="24.925925925925927" y1="104" y2="104"></line>
<g transform="translate(3 119)">
<g class="tabs-ladder__wheel">
<circle r="4"></circle>
<line x1="-4" x2="4"></line>
</g>
</g>
<g transform="translate(25 119)">
<g class="tabs-ladder__wheel">
<circle r="4"></circle>
<line x1="-4" x2="4"></line>
</g>
</g>
</svg>
</span>
</div>
<div class="tabs-ladder__panel" role="tabpanel" id="tabs-ladder-panel-0" aria-labelledby="tabs-ladder-tab-0" hidden="">1,280 novels, A to Z. The new Ishiguro is on the end.</div>
<div class="tabs-ladder__panel" role="tabpanel" id="tabs-ladder-panel-1" aria-labelledby="tabs-ladder-tab-1">214 volumes. The Larkin is out until Friday.</div>
<div class="tabs-ladder__panel" role="tabpanel" id="tabs-ladder-panel-2" aria-labelledby="tabs-ladder-tab-2" hidden="">Ancient on the top shelf, the last century below.</div>
<div class="tabs-ladder__panel" role="tabpanel" id="tabs-ladder-panel-3" aria-labelledby="tabs-ladder-tab-3" hidden="">Field guides and star atlases, and one telescope manual.</div>
</div>.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; }function initTabsLadder(root) {
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = Array.prototype.slice.call(root.querySelectorAll('[role="tabpanel"]'));
var n = tabs.length;
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var ladder = root.querySelector('.tabs-ladder__ladder');
var wheels = root.querySelectorAll('.tabs-ladder__wheel');
var CENTER = [33, 88, 142, 197];
var s = { x: CENTER[sel], v: 0, th: 0, w: 0 };
var raf = 0;
function draw() {
ladder.style.transform = 'translateX(' + s.x + 'px) rotate(' + s.th + 'rad)';
wheels.forEach(function (wheel) { wheel.setAttribute('transform', 'rotate(' + (s.x / 4) * (180 / Math.PI) + ')'); });
}
function update() {
cancelAnimationFrame(raf);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
s = { x: CENTER[sel], v: 0, th: 0, w: 0 };
draw();
return;
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
// A spring rolls the ladder over; its acceleration swings the foot the other way.
var 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) {
s = { x: CENTER[sel], v: 0, th: 0, w: 0 };
draw();
return;
}
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
function pick(i) {
if (i === sel) return;
var from = sel;
sel = i;
tabs.forEach(function (tab, k) {
tab.setAttribute('aria-selected', String(k === sel));
tab.tabIndex = k === sel ? 0 : -1;
});
panels.forEach(function (panel, k) { panel.hidden = k !== sel; });
update(from);
}
function onClick(e) {
var i = tabs.indexOf(e.currentTarget);
if (i >= 0) pick(i);
}
function onKey(e) {
var next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
pick(next);
tabs[next].focus();
}
tabs.forEach(function (tab) { tab.addEventListener('click', onClick); });
list.addEventListener('keydown', onKey);
return function () {
tabs.forEach(function (tab) { tab.removeEventListener('click', onClick); });
list.removeEventListener('keydown', onKey);
cancelAnimationFrame(raf);
};
}
document.querySelectorAll('.tabs-ladder').forEach(initTabsLadder);Goal: Create a "Ladder" tab.
Visual behavior: 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.
Technique:
- A real tablist of buttons with roving tabindex and a labelled panel for each tab; everything drawn follows the selection.
- A spring moves the ladder along the rail, and its acceleration drives a second damped spring that leans the ladder from its hooks, so the foot lags when it sets off and swings on when it stops.
- The wheels turn by exactly the distance rolled (x divided by their radius). The loop ends as soon as everything has settled.
- The books come from a seeded generator, so they keep their heights, tones and the odd lean on every render, and the brass plates along the top are the tabs.
Accessibility: 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.
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 "Ladder" tab.
Visual behavior: 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.
Technique:
- A real tablist of buttons with roving tabindex and a labelled panel for each tab; everything drawn follows the selection.
- A spring moves the ladder along the rail, and its acceleration drives a second damped spring that leans the ladder from its hooks, so the foot lags when it sets off and swings on when it stops.
- The wheels turn by exactly the distance rolled (x divided by their radius). The loop ends as soon as everything has settled.
- The books come from a seeded generator, so they keep their heights, tones and the odd lean on every render, and the brass plates along the top are the tabs.
Accessibility: 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.
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 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.