Test Tubes

pH 7.9 and cloudy, with algae and a little silt.
No dependencies
Code format
React + Tailwind
import { useId, useRef, useState, type CSSProperties } from 'react';

const SAMPLES = [
  { label: 'Pond', kind: 'pond', level: 62, body: 'pH 7.9 and cloudy, with algae and a little silt.' },
  { label: 'Tap', kind: 'tap', level: 70, body: 'pH 7.2 and clear. Hard water, 210 mg of calcium a litre.' },
  { label: 'Rain', kind: 'rain', level: 54, body: 'pH 5.6 and clear: a touch acidic, as rain always is.' },
  { label: 'Sea', kind: 'sea', level: 66, body: 'pH 8.1, and 35 g of salt a litre settling to the bottom.' },
];
const X = [46, 92, 138, 184];

const css = `
  .tabs-test-tubes { --shade: light-dark(#373a40, #121417); --wood: light-dark(#cbced3, #393c41); width: 230px; }
  .tabs-test-tubes__rack { position: relative; height: 154px; }
  .tabs-test-tubes__board { position: absolute; left: 8px; right: 8px; z-index: 1; border-radius: 3px; background: linear-gradient(color-mix(in oklab, var(--wood) 80%, light-dark(#373a40, #f1f2f3)), var(--wood) 30%, color-mix(in oklab, var(--wood) 75%, var(--shade))); box-shadow: 0 3px 5px -2px light-dark(rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.55)); pointer-events: none; }
  .tabs-test-tubes__board--top { top: 66px; height: 9px; }
  .tabs-test-tubes__board--base { top: 126px; height: 12px; }
  .tabs-test-tubes__post { position: absolute; top: 66px; width: 6px; height: 64px; border-radius: 2px; background: var(--wood); filter: brightness(0.8); }
  /* Each tube is a tab, its name on the front of the rack below it. */
  .tabs-test-tubes__slot { position: absolute; top: 0; width: 40px; height: 154px; margin-left: -20px; padding: 0; border: 0; background: none; cursor: pointer; }
  .tabs-test-tubes__slot:focus-visible { outline: none; }
  .tabs-test-tubes__slot:focus-visible .tabs-test-tubes__name { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; border-radius: 2px; }
  .tabs-test-tubes__lift { position: absolute; left: 11px; top: 34px; width: 18px; height: 96px; transition: transform 0.42s cubic-bezier(0.5, 0, 0.45, 1.45); }
  .tabs-test-tubes__slot[aria-selected='true'] .tabs-test-tubes__lift { transform: translateY(-34px); transition: transform 0.5s cubic-bezier(0.3, 1.35, 0.5, 1) 0.12s; }
  .tabs-test-tubes__slot:hover:not([aria-selected='true']) .tabs-test-tubes__lift { transform: translateY(-4px); }
  .tabs-test-tubes__tilt { position: absolute; inset: 0; transform-origin: 50% 18%; }
  .tabs-test-tubes__slot[aria-selected='true'] .tabs-test-tubes__tilt { animation: tabs-test-tubes-swirl 1.4s ease-in-out 0.5s; }
  .tabs-test-tubes__glass { position: absolute; inset: 0; overflow: hidden; border-radius: 2px 2px 9px 9px; background: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 7%, transparent); box-shadow: inset 0 0 0 1px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 32%, transparent); }
  .tabs-test-tubes__glass::after { content: ''; position: absolute; left: 3px; top: 4px; bottom: 10px; width: 2.5px; border-radius: 2px; background: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 30%, transparent); }
  .tabs-test-tubes__lip { position: absolute; left: -2px; right: -2px; top: -2px; height: 4px; border-radius: 2px; background: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 26%, transparent); }
  /* The liquid is wider than the tube and turns against the tilt, so its surface stays level. */
  .tabs-test-tubes__liquid { position: absolute; left: -60%; right: -60%; bottom: -12px; height: calc(var(--level) + 12px); transform-origin: 50% 0; }
  .tabs-test-tubes__slot[aria-selected='true'] .tabs-test-tubes__liquid { animation: tabs-test-tubes-level 1.4s ease-in-out 0.5s; }
  .tabs-test-tubes__liquid::before { content: ''; position: absolute; left: 0; right: 0; top: -1.5px; height: 3px; border-radius: 50%; background: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 45%, transparent); }
  .tabs-test-tubes__liquid::after { content: ''; position: absolute; left: 50%; top: -1px; width: 10px; height: 8px; margin-left: -5px; border-radius: 0 0 50% 50%; background: color-mix(in oklab, var(--shade) 55%, transparent); transform: scale(0); transform-origin: 50% 0; }
  .tabs-test-tubes__slot[aria-selected='true'] .tabs-test-tubes__liquid::after { animation: tabs-test-tubes-vortex 1.4s ease-in-out 0.5s; }
  .tabs-test-tubes__liquid[data-kind='pond'] { background: radial-gradient(circle, color-mix(in oklab, light-dark(#42454c, #e5e6e9) 70%, transparent) 0.7px, transparent 1.1px) 0 0 / 5px 6px, radial-gradient(circle, color-mix(in oklab, light-dark(#50535b, #c6c8cd) 60%, transparent) 0.6px, transparent 1px) 2px 3px / 7px 5px, linear-gradient(transparent calc(100% - 20px), color-mix(in oklab, light-dark(#5f626a, #a7abb2) 70%, transparent) calc(100% - 18px)), color-mix(in oklab, light-dark(#6d7179, #7d8087) 55%, transparent); }
  .tabs-test-tubes__slot[aria-selected='true'] .tabs-test-tubes__liquid[data-kind='pond'] { animation: tabs-test-tubes-level 1.4s ease-in-out 0.5s, tabs-test-tubes-silt 1.4s ease-in-out 0.5s; }
  .tabs-test-tubes__liquid[data-kind='tap'] { background: color-mix(in oklab, light-dark(#50535b, #c6c8cd) 20%, transparent); }
  .tabs-test-tubes__liquid[data-kind='rain'] { background: radial-gradient(circle at 30% 30%, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 55%, transparent) 0 1.2px, transparent 1.6px) 4px 10px / 9px 13px, color-mix(in oklab, light-dark(#42454c, #e5e6e9) 13%, transparent); }
  .tabs-test-tubes__slot[aria-selected='true'] .tabs-test-tubes__liquid[data-kind='rain'] { animation: tabs-test-tubes-level 1.4s ease-in-out 0.5s, tabs-test-tubes-fizz 1.4s linear 0.5s; }
  .tabs-test-tubes__liquid[data-kind='sea'] { background: linear-gradient(transparent 0 58%, color-mix(in oklab, light-dark(#42454c, #e5e6e9) 40%, transparent) 72%), color-mix(in oklab, light-dark(#50535b, #c6c8cd) 26%, transparent); }
  .tabs-test-tubes__name { position: absolute; left: 0; right: 0; top: 140px; z-index: 2; color: light-dark(#5f626a, #a7abb2); font: 600 9.5px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.12em; text-transform: uppercase; transition: color 0.2s; }
  .tabs-test-tubes__slot:hover .tabs-test-tubes__name { color: light-dark(#42454c, #e5e6e9); }
  .tabs-test-tubes__slot[aria-selected='true'] .tabs-test-tubes__name { color: light-dark(#373a40, #f1f2f3); }
  .tabs-test-tubes__panel { min-height: 3em; margin-top: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12.5px; line-height: 1.5; }
  @keyframes tabs-test-tubes-swirl { 0%, 100% { transform: none; } 18% { transform: rotate(8deg) translateX(1px); } 38% { transform: rotate(-7deg) translateX(-1px); } 58% { transform: rotate(5deg); } 78% { transform: rotate(-2.5deg); } }
  @keyframes tabs-test-tubes-level { 0%, 100% { transform: none; } 18% { transform: rotate(-8deg); } 38% { transform: rotate(7deg); } 58% { transform: rotate(-5deg); } 78% { transform: rotate(2.5deg); } }
  @keyframes tabs-test-tubes-vortex { 0%, 100% { transform: scale(0); } 30%, 60% { transform: scale(1); } }
  @keyframes tabs-test-tubes-silt { to { background-position: 18px -6px, -14px 9px, 0 0, 0 0; } }
  @keyframes tabs-test-tubes-fizz { to { background-position: 4px -40px, 0 0; } }
  @media (prefers-reduced-motion: reduce) {
    .tabs-test-tubes__lift, .tabs-test-tubes__slot[aria-selected='true'] .tabs-test-tubes__lift { transition: none; }
    .tabs-test-tubes__slot[aria-selected='true'] .tabs-test-tubes__tilt, .tabs-test-tubes__slot[aria-selected='true'] .tabs-test-tubes__liquid, .tabs-test-tubes__slot[aria-selected='true'] .tabs-test-tubes__liquid::after { animation: none !important; }
  }
`;

export function Preview() {
  const id = useId();
  const [sel, setSel] = useState(0);
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const n = SAMPLES.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-test-tubes" precedence="kit">
        {css}
      </style>
      <div className="tabs-test-tubes">
        <div className="tabs-test-tubes__rack" role="tablist" aria-label="Samples" onKeyDown={onKeyDown}>
          <span className="tabs-test-tubes__post" style={{ left: 10 }} aria-hidden="true" />
          <span className="tabs-test-tubes__post" style={{ right: 10 }} aria-hidden="true" />
          {SAMPLES.map((s, i) => (
            <button
              key={s.label}
              ref={(el) => {
                tabs.current[i] = el;
              }}
              className="tabs-test-tubes__slot"
              type="button"
              role="tab"
              id={`${id}-tab-${i}`}
              aria-selected={sel === i}
              aria-controls={`${id}-panel-${i}`}
              tabIndex={sel === i ? 0 : -1}
              style={{ left: X[i] }}
              onClick={() => setSel(i)}
            >
              <span className="tabs-test-tubes__lift" aria-hidden="true">
                <span className="tabs-test-tubes__tilt">
                  <span className="tabs-test-tubes__glass">
                    <span className="tabs-test-tubes__liquid" data-kind={s.kind} style={{ '--level': `${s.level}px` } as CSSProperties} />
                  </span>
                  <span className="tabs-test-tubes__lip" />
                </span>
              </span>
              <span className="tabs-test-tubes__name">{s.label}</span>
            </button>
          ))}
          <span className="tabs-test-tubes__board tabs-test-tubes__board--top" aria-hidden="true" />
          <span className="tabs-test-tubes__board tabs-test-tubes__board--base" aria-hidden="true" />
        </div>
        {SAMPLES.map((s, i) => (
          <div key={s.label} className="tabs-test-tubes__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-test-tubes
Source on GitHub

About this component

Water samples in a rack: the tube you pick lifts out and swirls, its contents spinning while the surface stays level, and the last one drops back with a clink.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, spring, keyframe
Techniques
keyframes, css transition, css variables, radial gradient
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 tube lifts at once and nothing swirls.

More tabs

See all
Drawing 1.2 kW from the grid, on the off-peak rate until seven.
Arc
Grasslands. Eight levels, a hidden pipe in the fourth.
Blocks
Tape hiss, a count-in and the first chord. Forty seconds long.
Cassette
Fractions today: halves, quarters and why a third is awkward.
Chalk