Pneumatic

The May invoices are signed and on their way back.
No dependencies
Code format
React + Tailwind
import { useEffect, useId, useRef, useState } from 'react';

const STATIONS = [
  { label: 'Post', body: 'Two letters and a parcel slip came in at nine.' },
  { label: 'Accounts', body: 'The May invoices are signed and on their way back.' },
  { label: 'Studio', body: 'Proofs are ready. Corrections by Thursday, please.' },
  { label: 'Archive', body: 'The 1962 ledgers are out. Back by five, please.' },
];
const X = [27, 85, 143, 201];
const MOUTH = 36; // where a capsule waits, just inside the receiver
const MAIN = 100; // the main line along the bottom
const R = 13;
// Down the sender's tube, along the main and up the receiver's.
function route(a: number, b: number) {
  const s = Math.sign(X[b] - X[a]);
  return `M${X[a]} ${MOUTH}V${MAIN - R}Q${X[a]} ${MAIN} ${X[a] + s * R} ${MAIN}H${X[b] - s * R}Q${X[b]} ${MAIN} ${X[b]} ${MAIN - R}V${MOUTH}`;
}
const TUBES = [
  ...X.map((x) => `M${x} 30V${MAIN - R}`),
  `M${X[0] + R} ${MAIN}H${X[3] - R}`,
  ...X.flatMap((x, i) => [i > 0 ? `M${x} ${MAIN - R}Q${x} ${MAIN} ${x - R} ${MAIN}` : '', i < X.length - 1 ? `M${x} ${MAIN - R}Q${x} ${MAIN} ${x + R} ${MAIN}` : ''].filter(Boolean)),
].join('');
const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
const at = (x: number, y: number, deg: number) => `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${deg.toFixed(1)}deg)`;

const css = `
  .tabs-pneumatic { width: 230px; font-family: 'DIN Condensed', 'Arial Narrow', 'Helvetica Neue', sans-serif; }
  .tabs-pneumatic__plant { position: relative; height: 112px; }
  .tabs-pneumatic__pipes { position: absolute; left: 0; top: 0; overflow: visible; }
  /* Glass: a pale tube with its bore darker, so both walls catch the light whichever way it runs. */
  .tabs-pneumatic__glass { fill: none; stroke: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 26%, transparent); stroke-width: 11; stroke-linecap: round; }
  .tabs-pneumatic__bore { fill: none; stroke: color-mix(in oklab, light-dark(#fbfcfd, #121417) 72%, transparent); stroke-width: 8.6; stroke-linecap: round; }
  .tabs-pneumatic__shine { fill: none; stroke: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 28%, transparent); stroke-width: 1.2; stroke-linecap: round; }
  .tabs-pneumatic__air { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.4; stroke-linecap: round; opacity: 0; }
  .tabs-pneumatic__mouth { fill: light-dark(#cbced3, #393c41); }
  .tabs-pneumatic__valve circle { fill: light-dark(#dbdde1, #2e3035); stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
  .tabs-pneumatic__lever { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 2; stroke-linecap: round; transform-box: fill-box; transform-origin: 0 50%; transition: transform 0.3s cubic-bezier(0.3, 1.5, 0.5, 1); }
  .tabs-pneumatic__valve[data-open] .tabs-pneumatic__lever { transform: rotate(-60deg); stroke: light-dark(#373a40, #f1f2f3); }
  /* The carrier: a brass capsule with felt bands, moved along the route from script. */
  .tabs-pneumatic__capsule rect:first-child { fill: light-dark(#50535b, #c6c8cd); }
  .tabs-pneumatic__capsule .tabs-pneumatic__band { fill: light-dark(#cbced3, #393c41); }
  .tabs-pneumatic__capsule .tabs-pneumatic__cap { fill: light-dark(#6d7179, #7d8087); }
  .tabs-pneumatic__stations { position: absolute; inset: 0 0 auto; height: 24px; }
  .tabs-pneumatic__station { position: absolute; top: 0; width: 54px; height: 22px; margin-left: -27px; padding: 0; border: 0; border-radius: 3px; background: linear-gradient(light-dark(#cbced3, #393c41), light-dark(#dbdde1, #2e3035)); box-shadow: inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 16%, transparent), 0 2px 3px light-dark(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.5)); color: light-dark(#50535b, #c6c8cd); font: inherit; font-size: 10.5px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; cursor: pointer; transition: background 0.3s, color 0.3s; }
  .tabs-pneumatic__station:hover { color: light-dark(#373a40, #f1f2f3); }
  .tabs-pneumatic__station[aria-selected='true'] { background: linear-gradient(light-dark(#42454c, #e5e6e9), light-dark(#5f626a, #a7abb2)); color: light-dark(#fbfcfd, #121417); }
  .tabs-pneumatic__station[data-thud] { animation: tabs-pneumatic-thud 0.4s cubic-bezier(0.3, 1.6, 0.5, 1); }
  .tabs-pneumatic__station:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
  .tabs-pneumatic__panel { min-height: 3em; margin-top: 8px; color: light-dark(#50535b, #c6c8cd); font-family: system-ui, sans-serif; font-size: 12.5px; line-height: 1.5; }
  @keyframes tabs-pneumatic-thud { 30% { transform: translateY(-2px); } }
  @media (prefers-reduced-motion: reduce) {
    .tabs-pneumatic__lever { transition: none; }
    .tabs-pneumatic__station[data-thud] { animation: none; }
  }
`;

export function Preview() {
  const id = useId();
  const [{ sel, from, thud }, setState] = useState({ sel: 1, from: -1, thud: false });
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const capsule = useRef<SVGGElement>(null);
  const air = useRef<SVGPathElement>(null);
  const running = useRef<Animation[]>([]);
  const timer = useRef(0);
  const pick = (i: number) => setState((s) => (s.sel === i ? s : { sel: i, from: s.sel, thud: false }));

  // Suck the capsule out of the last station, blow it along the tubes and land it with a jolt.
  useEffect(() => {
    if (from < 0 || !capsule.current || !air.current || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    running.current.forEach((a) => a.cancel());
    clearTimeout(timer.current);
    const path = air.current;
    const L = path.getTotalLength();
    const frames: Keyframe[] = [];
    const N = 70;
    const wait = 320; // the valves turn first
    const ms = 520 + L * 2.2;
    const total = wait + ms + 260;
    for (let k = 0; k <= N; k++) {
      const d = L * ease(k / N);
      const p = path.getPointAtLength(d);
      const q = path.getPointAtLength(Math.min(L, d + 0.5));
      const r = path.getPointAtLength(Math.max(0, d - 0.5));
      frames.push({ offset: (wait + (ms * k) / N) / total, transform: at(p.x, p.y, (Math.atan2(q.y - r.y, q.x - r.x) * 180) / Math.PI) });
    }
    frames.unshift({ ...frames[0], offset: 0 });
    const end = path.getPointAtLength(L);
    frames.push({ offset: (wait + ms + 90) / total, transform: at(end.x, end.y - 5, -90) });
    frames.push({ offset: (wait + ms + 180) / total, transform: at(end.x, end.y + 1.5, -90) });
    frames.push({ offset: 1, transform: at(end.x, end.y, -90) });
    running.current = [
      capsule.current.animate(frames, { duration: total, easing: 'linear' }),
      path.animate(
        [
          { strokeDasharray: '6 14', strokeDashoffset: 0, opacity: 0 },
          { strokeDasharray: '6 14', strokeDashoffset: -L * 0.35, opacity: 0.5, offset: 0.4 },
          { strokeDasharray: '6 14', strokeDashoffset: -L * 0.9, opacity: 0 },
        ],
        { delay: wait, duration: ms },
      ),
    ];
    timer.current = window.setTimeout(() => setState((s) => ({ ...s, thud: true })), wait + ms);
    return () => {
      running.current.forEach((a) => a.cancel());
      clearTimeout(timer.current);
    };
  }, [sel, from]);

  const n = STATIONS.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();
    pick(next);
    tabs.current[next]?.focus();
  };
  return (
    <>
      <style href="kit-tabs-pneumatic" precedence="kit">
        {css}
      </style>
      <div className="tabs-pneumatic">
        <div className="tabs-pneumatic__plant">
          <svg className="tabs-pneumatic__pipes" width="230" height="112" viewBox="0 0 230 112" aria-hidden="true">
            {X.map((x) => (
              <path key={x} className="tabs-pneumatic__mouth" d={`M${x - 9} 22H${x + 9}L${x + 6.5} 31H${x - 6.5}Z`} />
            ))}
            <path className="tabs-pneumatic__glass" d={TUBES} />
            <path className="tabs-pneumatic__bore" d={TUBES} />
            <path className="tabs-pneumatic__shine" d={TUBES} transform="translate(-2.6 -1.5)" />
            <path ref={air} className="tabs-pneumatic__air" d={route(from < 0 ? 0 : from, from < 0 ? 1 : sel)} />
            {X.map((x, i) => (
              <g key={x} className="tabs-pneumatic__valve" data-open={i === sel || (i === from && from >= 0) ? '' : undefined} transform={`translate(${x} ${MAIN})`}>
                <circle r="5.5" />
                <line className="tabs-pneumatic__lever" x1="0" y1="0" x2="8" y2="0" />
              </g>
            ))}
            <g ref={capsule} className="tabs-pneumatic__capsule" style={{ transform: at(X[sel], MOUTH, -90) }}>
              <rect x="-9" y="-3.6" width="18" height="7.2" rx="3.6" />
              <rect className="tabs-pneumatic__band" x="-6" y="-3.6" width="2" height="7.2" />
              <rect className="tabs-pneumatic__band" x="4" y="-3.6" width="2" height="7.2" />
              <rect className="tabs-pneumatic__cap" x="-9" y="-2.4" width="2.4" height="4.8" rx="1" />
            </g>
          </svg>
          <div className="tabs-pneumatic__stations" role="tablist" aria-label="Departments" onKeyDown={onKeyDown}>
            {STATIONS.map((s, i) => (
              <button
                key={s.label}
                ref={(el) => {
                  tabs.current[i] = el;
                }}
                className="tabs-pneumatic__station"
                type="button"
                role="tab"
                id={`${id}-tab-${i}`}
                aria-selected={sel === i}
                aria-controls={`${id}-panel-${i}`}
                tabIndex={sel === i ? 0 : -1}
                data-thud={sel === i && thud ? '' : undefined}
                style={{ left: X[i] }}
                onClick={() => pick(i)}
              >
                {s.label}
              </button>
            ))}
          </div>
        </div>
        {STATIONS.map((s, i) => (
          <div key={s.label} className="tabs-pneumatic__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-pneumatic
Source on GitHub

About this component

An office tube system: the valves turn toward the department you pick and the capsule shoots through the glass tubes to it, then lands with a jolt.

Dependencies
None
Difficulty
Advanced
Triggers
click, state change, spring
Techniques
svg, stroke dashoffset, state machine, keyframes
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 capsule is simply at the new station.

More tabs

See all
A bellied vase with a flared neck, 30 cm tall.
Pottery
Full on the fifteenth, rising over the east wall at dusk.
Shoji
pH 7.9 and cloudy, with algae and a little silt.
Test Tubes
Drawing 1.2 kW from the grid, on the off-peak rate until seven.
Arc