Elevator

Four desks by the window, booked by the hour.
No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

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

Install with shadcn

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

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.

More tabs

See all
Tench under the pads. Fish it slowly in the evening.
Fishing
A:\PHOTOSBEACH.JPG412KSNOW.JPG506K
Floppy
Say it first.
Fortune Teller
Wool, double-breasted and fully lined. Sizes S to XL.
Hangers