Elevator
import { useEffect, useId, useRef, useState, type CSSProperties } from 'react';
const FLOORS = [
{ key: 'G', name: 'Lobby', body: 'Post and parcels at the desk, open from eight.', icon: 'M5 21h18M7 21a7 7 0 0 1 14 0M14 14v-2.5M11.5 11.5h5' },
{ key: '2', name: 'Studio', body: 'Four desks by the window, booked by the hour.', icon: 'M8 22h12M14 22l-3-8 6-5M17 9l4 3.5-5.5 2.5zM11 14l-2.5-1' },
{ key: '3', name: 'Café', body: 'Coffee all day, soup at noon and one long table.', icon: 'M7 13h12v4a6 6 0 0 1-12 0zM19 14h1.5a2.5 2.5 0 0 1 0 5H18.5M11 5c-1.2 1.6 1.2 2.6 0 4.4M15 5c-1.2 1.6 1.2 2.6 0 4.4' },
{ key: 'R', name: 'Roof', body: 'Deck chairs, herbs in boxes and the view east.', icon: 'M4 22h20M14 17a4.5 4.5 0 0 1 0-9a4.5 4.5 0 0 1 0 9M14 4.5V3M20.4 7.1l1-1M7.6 7.1l-1-1M22.5 12.5H24M5.5 12.5H4' },
];
// Where the needle points for each floor, in degrees from straight up.
const ANGLE = [-60, -20, 20, 60];
const CLOSE_MS = 460;
const travelMs = (floors: number) => 320 + 460 * floors;
const css = `
.tabs-elevator { --shade: light-dark(#373a40, #121417); --metal: light-dark(#dbdde1, #393c41); width: 230px; font-family: Didot, 'Bodoni 72', 'Times New Roman', serif; }
.tabs-elevator__shaft { display: grid; grid-template-columns: 46px 1fr; gap: 10px; height: 156px; }
/* The call panel: a plate of round buttons, the called one lit until the car arrives. */
.tabs-elevator__plate { display: flex; flex-direction: column-reverse; justify-content: center; align-items: center; gap: 8px; border-radius: 8px; background: linear-gradient(140deg, light-dark(#dbdde1, #2e3035), light-dark(#ebedf0, #232529)); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 12%, transparent); }
.tabs-elevator__button { position: relative; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 50%; background: radial-gradient(circle at 40% 35%, light-dark(#6d7179, #7d8087), light-dark(#dbdde1, #2e3035) 70%); box-shadow: 0 0 0 2px light-dark(#ebedf0, #232529), 0 0 0 3px light-dark(#cbced3, #393c41), 0 2px 3px 2px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.45)); color: light-dark(#42454c, #e5e6e9); font: inherit; font-size: 14px; line-height: 30px; cursor: pointer; transition: box-shadow 0.25s, color 0.25s, transform 0.1s; }
.tabs-elevator__button:active { transform: scale(0.94); }
.tabs-elevator__button[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 0 2px light-dark(#ebedf0, #232529), 0 0 0 3px light-dark(#50535b, #c6c8cd), 0 2px 3px 2px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.45)); }
.tabs-elevator__button[data-called] { color: light-dark(#fbfcfd, #121417); background: radial-gradient(circle at 40% 35%, light-dark(#373a40, #f1f2f3), light-dark(#50535b, #c6c8cd) 75%); box-shadow: 0 0 0 2px light-dark(#ebedf0, #232529), 0 0 0 3px light-dark(#373a40, #f1f2f3), 0 0 14px 2px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 45%, transparent); }
.tabs-elevator__button:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 5px; }
.tabs-elevator__unit { display: grid; grid-template-rows: 34px 1fr; }
.tabs-elevator__dial { display: block; margin: 0 auto; overflow: visible; }
.tabs-elevator__face { fill: light-dark(#ebedf0, #232529); stroke: var(--metal); stroke-width: 2; }
.tabs-elevator__num { fill: light-dark(#5f626a, #a7abb2); font-size: 9px; text-anchor: middle; transition: fill 0.3s; }
.tabs-elevator__num[data-at] { fill: light-dark(#373a40, #f1f2f3); }
.tabs-elevator__needle { transform-box: view-box; transform-origin: 40px 31px; transition: transform var(--ms, 0.6s) cubic-bezier(0.55, 0, 0.35, 1); }
.tabs-elevator__needle line { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.5; stroke-linecap: round; }
.tabs-elevator__needle circle { fill: light-dark(#50535b, #c6c8cd); }
.tabs-elevator__arrow { fill: light-dark(#dbdde1, #2e3035); transition: fill 0.2s; }
.tabs-elevator__arrow[data-on] { fill: light-dark(#373a40, #f1f2f3); animation: tabs-elevator-blink 0.9s ease-in-out infinite; }
.tabs-elevator__frame { position: relative; border-radius: 4px 4px 0 0; background: linear-gradient(var(--metal), color-mix(in oklab, var(--metal) 80%, var(--shade))); box-shadow: inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 15%, transparent); }
.tabs-elevator__opening { position: absolute; inset: 7px 7px 0; overflow: hidden; background: light-dark(#f3f4f7, #1a1c1f); }
/* Inside the car: the floor's name and a small drawing, lit from above. */
.tabs-elevator__scene { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 4px; background: radial-gradient(ellipse 80% 70% at 50% 0%, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 14%, transparent), transparent 70%), linear-gradient(transparent 82%, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 6%, transparent) 82%); color: light-dark(#42454c, #e5e6e9); }
.tabs-elevator__scene[hidden] { display: none; }
.tabs-elevator[data-open] .tabs-elevator__scene { animation: tabs-elevator-settle 0.6s cubic-bezier(0.3, 1.6, 0.5, 1); }
.tabs-elevator__scene svg { width: 28px; height: 28px; fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; }
.tabs-elevator__scene b { font-size: 20px; font-style: italic; font-weight: 400; letter-spacing: 0.01em; color: light-dark(#373a40, #f1f2f3); }
.tabs-elevator__big { position: absolute; right: 6px; bottom: -14px; font-size: 64px; line-height: 1; color: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 8%, transparent); }
/* Brushed steel doors that meet in the middle and slide into the wall. */
.tabs-elevator__leaf { position: absolute; top: 0; bottom: 0; width: 50%; background: repeating-linear-gradient(90deg, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 5%, transparent) 0 1px, transparent 1px 3px), linear-gradient(90deg, light-dark(#cbced3, #393c41), light-dark(#6d7179, #7d8087) 45%, light-dark(#cbced3, #393c41)); transition: transform ${CLOSE_MS / 1000}s cubic-bezier(0.45, 0, 0.55, 1); }
.tabs-elevator__leaf--l { left: 0; box-shadow: inset -1px 0 0 color-mix(in oklab, var(--shade) 45%, transparent); }
.tabs-elevator__leaf--r { right: 0; box-shadow: inset 1px 0 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 18%, transparent); }
.tabs-elevator[data-open] .tabs-elevator__leaf { transition: transform 0.7s cubic-bezier(0.25, 0.7, 0.2, 1); }
.tabs-elevator[data-open] .tabs-elevator__leaf--l { transform: translateX(-96%); }
.tabs-elevator[data-open] .tabs-elevator__leaf--r { transform: translateX(96%); }
.tabs-elevator__panel { min-height: 3em; margin-top: 10px; color: light-dark(#50535b, #c6c8cd); font-family: system-ui, sans-serif; font-size: 12.5px; line-height: 1.5; }
@keyframes tabs-elevator-blink { 50% { fill: light-dark(#6d7179, #7d8087); } }
@keyframes tabs-elevator-settle { 0% { transform: translateY(3px); } 100% { transform: none; } }
@media (prefers-reduced-motion: reduce) {
.tabs-elevator__leaf, .tabs-elevator[data-open] .tabs-elevator__leaf, .tabs-elevator__needle { transition: none; }
.tabs-elevator[data-open] .tabs-elevator__scene, .tabs-elevator__arrow[data-on] { animation: none; }
}
`;
export function Preview() {
const id = useId();
const [sel, setSel] = useState(1);
// The car: the floor it is heading for, the floor showing behind the doors, whether they
// are open, which way it is going and how long the trip takes.
const [car, setCar] = useState({ at: 1, shown: 1, open: true, dir: 0, ms: 600 });
const carRef = useRef(car);
carRef.current = car;
const timers = useRef<number[]>([]);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
useEffect(() => {
const c = carRef.current;
if (c.at === sel && c.shown === sel && c.open) return;
timers.current.forEach(clearTimeout);
timers.current = [];
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setCar({ at: sel, shown: sel, open: true, dir: 0, ms: 0 });
return;
}
const later = (ms: number, fn: () => void) => timers.current.push(window.setTimeout(fn, ms));
// Doors shut first, then the car travels, then the doors open on the new floor.
const wait = c.open ? CLOSE_MS : 0;
const ms = travelMs(Math.abs(sel - c.at));
setCar((s) => ({ ...s, open: false }));
later(wait, () => setCar((s) => ({ ...s, at: sel, dir: Math.sign(sel - s.at), ms })));
later(wait + ms, () => setCar((s) => ({ ...s, dir: 0, shown: sel })));
later(wait + ms + 220, () => setCar((s) => ({ ...s, open: true })));
}, [sel]);
useEffect(() => () => timers.current.forEach(clearTimeout), []);
const n = FLOORS.length;
const onKeyDown = (e: React.KeyboardEvent) => {
const next = { ArrowUp: Math.min(n - 1, sel + 1), ArrowRight: Math.min(n - 1, sel + 1), ArrowDown: Math.max(0, sel - 1), ArrowLeft: Math.max(0, sel - 1), Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
setSel(next);
tabs.current[next]?.focus();
};
return (
<>
<style href="kit-tabs-elevator" precedence="kit">
{css}
</style>
<div className="tabs-elevator" data-open={car.open ? '' : undefined}>
<div className="tabs-elevator__shaft">
<div className="tabs-elevator__plate" role="tablist" aria-label="Floors" aria-orientation="vertical" onKeyDown={onKeyDown}>
{FLOORS.map((f, i) => (
<button
key={f.key}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-elevator__button"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-label={`${f.key} ${f.name}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
data-called={sel === i && car.shown !== i ? '' : undefined}
onClick={() => setSel(i)}
>
{f.key}
</button>
))}
</div>
<div className="tabs-elevator__unit" aria-hidden="true">
<svg className="tabs-elevator__dial" width="80" height="34" viewBox="0 0 80 34">
<path className="tabs-elevator__face" d="M10 31A30 30 0 0 1 70 31Z" />
{FLOORS.map((f, i) => {
const a = (ANGLE[i] * Math.PI) / 180;
return (
<text key={f.key} className="tabs-elevator__num" data-at={car.shown === i && car.open ? '' : undefined} x={40 + 21 * Math.sin(a)} y={34 - 21 * Math.cos(a)}>
{f.key}
</text>
);
})}
<g className="tabs-elevator__needle" style={{ transform: `rotate(${ANGLE[car.at]}deg)`, '--ms': `${car.ms}ms` } as CSSProperties}>
<line x1="40" y1="31" x2="40" y2="16" />
<circle cx="40" cy="31" r="2.5" />
</g>
<path className="tabs-elevator__arrow" data-on={car.dir > 0 ? '' : undefined} d="M-6 22L-1 14L4 22Z" />
<path className="tabs-elevator__arrow" data-on={car.dir < 0 ? '' : undefined} d="M76 14L81 22L86 14Z" />
</svg>
<div className="tabs-elevator__frame">
<div className="tabs-elevator__opening">
{FLOORS.map((f, i) => (
<div key={f.key} className="tabs-elevator__scene" hidden={car.shown !== i}>
<span className="tabs-elevator__big">{f.key}</span>
<svg viewBox="0 0 28 28">
<path d={f.icon} />
</svg>
<b>{f.name}</b>
</div>
))}
<span className="tabs-elevator__leaf tabs-elevator__leaf--l" />
<span className="tabs-elevator__leaf tabs-elevator__leaf--r" />
</div>
</div>
</div>
</div>
{FLOORS.map((f, i) => (
<div key={f.key} className="tabs-elevator__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{f.body}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsElevator(el) on the mounted element instead.
<div class="tabs-elevator" data-open="">
<div class="tabs-elevator__shaft">
<div class="tabs-elevator__plate" role="tablist" aria-label="Floors" aria-orientation="vertical">
<button class="tabs-elevator__button" type="button" role="tab" id="tabs-elevator-tab-0" aria-label="G Lobby" aria-selected="false" aria-controls="tabs-elevator-panel-0" tabindex="-1">G</button>
<button class="tabs-elevator__button" type="button" role="tab" id="tabs-elevator-tab-1" aria-label="2 Studio" aria-selected="true" aria-controls="tabs-elevator-panel-1" tabindex="0">2</button>
<button class="tabs-elevator__button" type="button" role="tab" id="tabs-elevator-tab-2" aria-label="3 Café" aria-selected="false" aria-controls="tabs-elevator-panel-2" tabindex="-1">3</button>
<button class="tabs-elevator__button" type="button" role="tab" id="tabs-elevator-tab-3" aria-label="R Roof" aria-selected="false" aria-controls="tabs-elevator-panel-3" tabindex="-1">R</button>
</div>
<div class="tabs-elevator__unit" aria-hidden="true">
<svg class="tabs-elevator__dial" width="80" height="34" viewBox="0 0 80 34">
<path class="tabs-elevator__face" d="M10 31A30 30 0 0 1 70 31Z"></path>
<text class="tabs-elevator__num" x="21.813466520526788" y="23.5">G</text>
<text class="tabs-elevator__num" data-at="" x="32.81757699016096" y="14.266454963495924">2</text>
<text class="tabs-elevator__num" x="47.18242300983904" y="14.266454963495924">3</text>
<text class="tabs-elevator__num" x="58.18653347947321" y="23.5">R</text>
<g class="tabs-elevator__needle" style="transformtabs-elevator-2600ms">
<line x1="40" y1="31" x2="40" y2="16"></line>
<circle cx="40" cy="31" r="2.5"></circle>
</g>
<path class="tabs-elevator__arrow" d="M-6 22L-1 14L4 22Z"></path>
<path class="tabs-elevator__arrow" d="M76 14L81 22L86 14Z"></path>
</svg>
<div class="tabs-elevator__frame">
<div class="tabs-elevator__opening">
<div class="tabs-elevator__scene" hidden="">
<span class="tabs-elevator__big">G</span>
<svg viewBox="0 0 28 28">
<path d="M5 21h18M7 21a7 7 0 0 1 14 0M14 14v-2.5M11.5 11.5h5"></path>
</svg>
<b>Lobby</b>
</div>
<div class="tabs-elevator__scene">
<span class="tabs-elevator__big">2</span>
<svg viewBox="0 0 28 28">
<path d="M8 22h12M14 22l-3-8 6-5M17 9l4 3.5-5.5 2.5zM11 14l-2.5-1"></path>
</svg>
<b>Studio</b>
</div>
<div class="tabs-elevator__scene" hidden="">
<span class="tabs-elevator__big">3</span>
<svg viewBox="0 0 28 28">
<path d="M7 13h12v4a6 6 0 0 1-12 0zM19 14h1.5a2.5 2.5 0 0 1 0 5H18.5M11 5c-1.2 1.6 1.2 2.6 0 4.4M15 5c-1.2 1.6 1.2 2.6 0 4.4"></path>
</svg>
<b>Café</b>
</div>
<div class="tabs-elevator__scene" hidden="">
<span class="tabs-elevator__big">R</span>
<svg viewBox="0 0 28 28">
<path d="M4 22h20M14 17a4.5 4.5 0 0 1 0-9a4.5 4.5 0 0 1 0 9M14 4.5V3M20.4 7.1l1-1M7.6 7.1l-1-1M22.5 12.5H24M5.5 12.5H4"></path>
</svg>
<b>Roof</b>
</div>
<span class="tabs-elevator__leaf tabs-elevator__leaf--l"></span>
<span class="tabs-elevator__leaf tabs-elevator__leaf--r"></span>
</div>
</div>
</div>
</div>
<div class="tabs-elevator__panel" role="tabpanel" id="tabs-elevator-panel-0" aria-labelledby="tabs-elevator-tab-0" hidden="">Post and parcels at the desk, open from eight.</div>
<div class="tabs-elevator__panel" role="tabpanel" id="tabs-elevator-panel-1" aria-labelledby="tabs-elevator-tab-1">Four desks by the window, booked by the hour.</div>
<div class="tabs-elevator__panel" role="tabpanel" id="tabs-elevator-panel-2" aria-labelledby="tabs-elevator-tab-2" hidden="">Coffee all day, soup at noon and one long table.</div>
<div class="tabs-elevator__panel" role="tabpanel" id="tabs-elevator-panel-3" aria-labelledby="tabs-elevator-tab-3" hidden="">Deck chairs, herbs in boxes and the view east.</div>
</div>.tabs-elevator { --shade: light-dark(#373a40, #121417); --metal: light-dark(#dbdde1, #393c41); width: 230px; font-family: Didot, 'Bodoni 72', 'Times New Roman', serif; }
.tabs-elevator__shaft { display: grid; grid-template-columns: 46px 1fr; gap: 10px; height: 156px; }
/* The call panel: a plate of round buttons, the called one lit until the car arrives. */
.tabs-elevator__plate { display: flex; flex-direction: column-reverse; justify-content: center; align-items: center; gap: 8px; border-radius: 8px; background: linear-gradient(140deg, light-dark(#dbdde1, #2e3035), light-dark(#ebedf0, #232529)); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 12%, transparent); }
.tabs-elevator__button { position: relative; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 50%; background: radial-gradient(circle at 40% 35%, light-dark(#6d7179, #7d8087), light-dark(#dbdde1, #2e3035) 70%); box-shadow: 0 0 0 2px light-dark(#ebedf0, #232529), 0 0 0 3px light-dark(#cbced3, #393c41), 0 2px 3px 2px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.45)); color: light-dark(#42454c, #e5e6e9); font: inherit; font-size: 14px; line-height: 30px; cursor: pointer; transition: box-shadow 0.25s, color 0.25s, transform 0.1s; }
.tabs-elevator__button:active { transform: scale(0.94); }
.tabs-elevator__button[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 0 2px light-dark(#ebedf0, #232529), 0 0 0 3px light-dark(#50535b, #c6c8cd), 0 2px 3px 2px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.45)); }
.tabs-elevator__button[data-called] { color: light-dark(#fbfcfd, #121417); background: radial-gradient(circle at 40% 35%, light-dark(#373a40, #f1f2f3), light-dark(#50535b, #c6c8cd) 75%); box-shadow: 0 0 0 2px light-dark(#ebedf0, #232529), 0 0 0 3px light-dark(#373a40, #f1f2f3), 0 0 14px 2px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 45%, transparent); }
.tabs-elevator__button:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 5px; }
.tabs-elevator__unit { display: grid; grid-template-rows: 34px 1fr; }
.tabs-elevator__dial { display: block; margin: 0 auto; overflow: visible; }
.tabs-elevator__face { fill: light-dark(#ebedf0, #232529); stroke: var(--metal); stroke-width: 2; }
.tabs-elevator__num { fill: light-dark(#5f626a, #a7abb2); font-size: 9px; text-anchor: middle; transition: fill 0.3s; }
.tabs-elevator__num[data-at] { fill: light-dark(#373a40, #f1f2f3); }
.tabs-elevator__needle { transform-box: view-box; transform-origin: 40px 31px; transition: transform var(--ms, 0.6s) cubic-bezier(0.55, 0, 0.35, 1); }
.tabs-elevator__needle line { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.5; stroke-linecap: round; }
.tabs-elevator__needle circle { fill: light-dark(#50535b, #c6c8cd); }
.tabs-elevator__arrow { fill: light-dark(#dbdde1, #2e3035); transition: fill 0.2s; }
.tabs-elevator__arrow[data-on] { fill: light-dark(#373a40, #f1f2f3); animation: tabs-elevator-blink 0.9s ease-in-out infinite; }
.tabs-elevator__frame { position: relative; border-radius: 4px 4px 0 0; background: linear-gradient(var(--metal), color-mix(in oklab, var(--metal) 80%, var(--shade))); box-shadow: inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 15%, transparent); }
.tabs-elevator__opening { position: absolute; inset: 7px 7px 0; overflow: hidden; background: light-dark(#f3f4f7, #1a1c1f); }
/* Inside the car: the floor's name and a small drawing, lit from above. */
.tabs-elevator__scene { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 4px; background: radial-gradient(ellipse 80% 70% at 50% 0%, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 14%, transparent), transparent 70%), linear-gradient(transparent 82%, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 6%, transparent) 82%); color: light-dark(#42454c, #e5e6e9); }
.tabs-elevator__scene[hidden] { display: none; }
.tabs-elevator[data-open] .tabs-elevator__scene { animation: tabs-elevator-settle 0.6s cubic-bezier(0.3, 1.6, 0.5, 1); }
.tabs-elevator__scene svg { width: 28px; height: 28px; fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; }
.tabs-elevator__scene b { font-size: 20px; font-style: italic; font-weight: 400; letter-spacing: 0.01em; color: light-dark(#373a40, #f1f2f3); }
.tabs-elevator__big { position: absolute; right: 6px; bottom: -14px; font-size: 64px; line-height: 1; color: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 8%, transparent); }
/* Brushed steel doors that meet in the middle and slide into the wall. */
.tabs-elevator__leaf { position: absolute; top: 0; bottom: 0; width: 50%; background: repeating-linear-gradient(90deg, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 5%, transparent) 0 1px, transparent 1px 3px), linear-gradient(90deg, light-dark(#cbced3, #393c41), light-dark(#6d7179, #7d8087) 45%, light-dark(#cbced3, #393c41)); transition: transform 0.46s cubic-bezier(0.45, 0, 0.55, 1); }
.tabs-elevator__leaf--l { left: 0; box-shadow: inset -1px 0 0 color-mix(in oklab, var(--shade) 45%, transparent); }
.tabs-elevator__leaf--r { right: 0; box-shadow: inset 1px 0 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 18%, transparent); }
.tabs-elevator[data-open] .tabs-elevator__leaf { transition: transform 0.7s cubic-bezier(0.25, 0.7, 0.2, 1); }
.tabs-elevator[data-open] .tabs-elevator__leaf--l { transform: translateX(-96%); }
.tabs-elevator[data-open] .tabs-elevator__leaf--r { transform: translateX(96%); }
.tabs-elevator__panel { min-height: 3em; margin-top: 10px; color: light-dark(#50535b, #c6c8cd); font-family: system-ui, sans-serif; font-size: 12.5px; line-height: 1.5; }
@keyframes tabs-elevator-blink { 50% { fill: light-dark(#6d7179, #7d8087); } }
@keyframes tabs-elevator-settle { 0% { transform: translateY(3px); } 100% { transform: none; } }
@media (prefers-reduced-motion: reduce) {
.tabs-elevator__leaf, .tabs-elevator[data-open] .tabs-elevator__leaf, .tabs-elevator__needle { transition: none; }
.tabs-elevator[data-open] .tabs-elevator__scene, .tabs-elevator__arrow[data-on] { animation: none; }
}function initTabsElevator(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 needle = root.querySelector('.tabs-elevator__needle');
var arrows = root.querySelectorAll('.tabs-elevator__arrow');
var nums = root.querySelectorAll('.tabs-elevator__num');
var scenes = root.querySelectorAll('.tabs-elevator__scene');
var ANGLE = [-60, -20, 20, 60]; // where the needle points for each floor, from straight up
var CLOSE_MS = 460;
// The car: the floor it is heading for, the floor showing behind the doors, whether they
// are open and which way it is going.
var car = { at: sel, shown: sel, open: true, dir: 0 };
var timers = [];
function later(ms, fn) { timers.push(setTimeout(fn, ms)); }
function draw() {
root.toggleAttribute('data-open', car.open);
needle.style.transform = 'rotate(' + ANGLE[car.at] + 'deg)';
arrows[0].toggleAttribute('data-on', car.dir > 0);
arrows[1].toggleAttribute('data-on', car.dir < 0);
nums.forEach(function (num, k) { num.toggleAttribute('data-at', car.shown === k && car.open); });
scenes.forEach(function (scene, k) { scene.hidden = car.shown !== k; });
tabs.forEach(function (tab, k) { tab.toggleAttribute('data-called', sel === k && car.shown !== k); });
}
function update() {
var target = sel;
timers.forEach(clearTimeout);
timers = [];
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
car = { at: target, shown: target, open: true, dir: 0 };
needle.style.setProperty('--ms', '0ms');
draw();
return;
}
// Doors shut first, then the car travels, then the doors open on the new floor.
var wait = car.open ? CLOSE_MS : 0;
var ms = 320 + 460 * Math.abs(target - car.at);
car.open = false;
draw();
later(wait, function () {
car.dir = Math.sign(target - car.at);
car.at = target;
needle.style.setProperty('--ms', ms + 'ms');
draw();
});
later(wait + ms, function () {
car.dir = 0;
car.shown = target;
draw();
});
later(wait + ms + 220, function () {
car.open = true;
draw();
});
}
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 = { ArrowUp: Math.min(n - 1, sel + 1), ArrowRight: Math.min(n - 1, sel + 1), ArrowDown: Math.max(0, sel - 1), ArrowLeft: Math.max(0, sel - 1), 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);
timers.forEach(clearTimeout);
};
}
document.querySelectorAll('.tabs-elevator').forEach(initTabsElevator);Goal: Create a "Elevator" tab.
Visual behavior: Floors as a lift: press a button and the doors close, the dial above sweeps to the floor with the arrow lit, then the doors open on the new floor.
Technique:
- A real tablist of buttons with roving tabindex and a labelled panel for each tab; everything drawn follows the selection.
- A small state machine keeps the car apart from the selection: the floor it is heading for, the floor behind the doors, whether they are open and which way it is going. A new press mid-trip just retargets it.
- Doors close, then the needle travels for a time set by the number of floors, with the direction arrow blinking and the called button lit, then the floor changes behind the shut doors and they open.
- The tablist runs vertically, so the up and down arrows go up and down the building. The panel text changes at once, so a screen reader never waits for the ride.
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 list is vertical and each button is named with its floor. The drawing is aria-hidden. Under prefers-reduced-motion the doors stay open and the needle jumps straight to the floor.
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 "Elevator" tab.
Visual behavior: Floors as a lift: press a button and the doors close, the dial above sweeps to the floor with the arrow lit, then the doors open on the new floor.
Technique:
- A real tablist of buttons with roving tabindex and a labelled panel for each tab; everything drawn follows the selection.
- A small state machine keeps the car apart from the selection: the floor it is heading for, the floor behind the doors, whether they are open and which way it is going. A new press mid-trip just retargets it.
- Doors close, then the needle travels for a time set by the number of floors, with the direction arrow blinking and the called button lit, then the floor changes behind the shut doors and they open.
- The tablist runs vertically, so the up and down arrows go up and down the building. The panel text changes at once, so a screen reader never waits for the ride.
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 list is vertical and each button is named with its floor. The drawing is aria-hidden. Under prefers-reduced-motion the doors stay open and the needle jumps straight to the floor.
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
Floors as a lift: press a button and the doors close, the dial above sweeps to the floor with the arrow lit, then the doors open on the new floor.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, state change, keyframe
- Techniques
- state machine, css transition, keyframes, svg
- 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 list is vertical and each button is named with its floor. The drawing is aria-hidden. Under prefers-reduced-motion the doors stay open and the needle jumps straight to the floor.