Pill Box

WednesdayVitamin D, iron, and a fish oil with dinner.
No dependencies
Code format
React + Tailwind
import { useId, useRef, useState, type CSSProperties } from 'react';

type Pill = { k: 'cap' | 'tab' | 'oval'; x: number; y: number; r: number };
const D: Pill = { k: 'tab', x: 4, y: 24, r: 0 };
const IRON: Pill = { k: 'oval', x: 14, y: 14, r: 30 };
const MAG: Pill = { k: 'cap', x: 9, y: 30, r: -20 };
const FISH: Pill = { k: 'cap', x: 6, y: 10, r: 60 };
const DAYS = [
  { label: 'Mon', name: 'Monday', pills: [D, IRON], body: 'Vitamin D with breakfast and iron after lunch.' },
  { label: 'Tue', name: 'Tuesday', pills: [D, MAG], body: 'Vitamin D with breakfast, magnesium at night.' },
  { label: 'Wed', name: 'Wednesday', pills: [D, IRON, FISH], body: 'Vitamin D, iron, and a fish oil with dinner.' },
  { label: 'Thu', name: 'Thursday', pills: [D, MAG], body: 'Vitamin D with breakfast, magnesium at night.' },
  { label: 'Fri', name: 'Friday', pills: [D, IRON], body: 'Vitamin D and iron, both with lunch.' },
  { label: 'Sat', name: 'Saturday', pills: [D], body: 'Just the vitamin D, any time before noon.' },
  { label: 'Sun', name: 'Sunday', pills: [], body: 'A day off: nothing in the box but air.' },
];

const css = `
  .tabs-pill-box { --shade: light-dark(#373a40, #121417); --frost: light-dark(#dbdde1, #7d8087); --white: light-dark(#fbfcfd, #e5e6e9); width: 230px; font-family: system-ui, sans-serif; }
  .tabs-pill-box__box { position: relative; display: flex; gap: 4px; margin-top: 48px; padding: 5px 4px 6px; border-radius: 10px; background: linear-gradient(light-dark(#dbdde1, #2e3035), light-dark(#ebedf0, #232529)); box-shadow: inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 14%, transparent), 0 10px 16px -10px light-dark(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7)); }
  /* Each day is a well under a frosted lid hinged along its back edge. */
  .tabs-pill-box__day { position: relative; flex: 1; height: 50px; padding: 0; border: 0; background: none; perspective: 170px; perspective-origin: 50% 0; cursor: pointer; }
  .tabs-pill-box__day:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; border-radius: 6px; }
  .tabs-pill-box__well { position: absolute; inset: 0; overflow: hidden; border-radius: 5px 5px 7px 7px; background: radial-gradient(ellipse at 50% 30%, light-dark(#ebedf0, #232529), light-dark(#f3f4f7, #1a1c1f)); box-shadow: inset 0 3px 6px light-dark(rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.6)); }
  .tabs-pill-box__pills { position: absolute; inset: 0; filter: blur(1.6px); opacity: 0.45; transition: filter 0.3s, opacity 0.3s; }
  .tabs-pill-box__day[aria-selected='true'] .tabs-pill-box__pills { filter: none; opacity: 1; transition-delay: 0.12s; }
  .tabs-pill-box__pill { position: absolute; left: var(--x); top: var(--y); rotate: var(--r); }
  .tabs-pill-box__pill[data-k='tab'] { width: 9px; height: 9px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, var(--white), color-mix(in oklab, var(--white) 75%, light-dark(#6d7179, #7d8087))); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--white) 80%, light-dark(#6d7179, #7d8087)); }
  .tabs-pill-box__pill[data-k='tab']::after { content: ''; position: absolute; left: 4px; top: 1.5px; bottom: 1.5px; width: 1px; background: color-mix(in oklab, var(--white) 70%, light-dark(#6d7179, #7d8087)); }
  .tabs-pill-box__pill[data-k='cap'] { width: 14px; height: 6px; border-radius: 3px; background: linear-gradient(90deg, light-dark(#50535b, #c6c8cd) 50%, var(--white) 50%); box-shadow: inset 0 -1px 0 color-mix(in oklab, var(--shade) 25%, transparent); }
  .tabs-pill-box__pill[data-k='oval'] { width: 11px; height: 7px; border-radius: 50%; background: radial-gradient(ellipse at 40% 30%, light-dark(#6d7179, #7d8087), light-dark(#dbdde1, #2e3035)); }
  .tabs-pill-box__day[aria-selected='true'] .tabs-pill-box__pill { animation: tabs-pill-box-rattle 0.5s cubic-bezier(0.3, 1.5, 0.5, 1) var(--t) both; }
  .tabs-pill-box__lid { position: absolute; inset: -1px; transform-origin: 50% 0; transform-style: preserve-3d; transition: transform 0.3s cubic-bezier(0.55, 0, 0.85, 0.35); }
  .tabs-pill-box__day[aria-selected='true'] .tabs-pill-box__lid { transform: rotateX(116deg); transition: transform 0.6s cubic-bezier(0.25, 1.4, 0.45, 1); }
  .tabs-pill-box__face, .tabs-pill-box__under { position: absolute; inset: 0; border-radius: 6px 6px 8px 8px; backface-visibility: hidden; }
  .tabs-pill-box__face { display: flex; align-items: flex-end; justify-content: center; padding-bottom: 6px; background: linear-gradient(170deg, color-mix(in oklab, var(--frost) 40%, transparent), color-mix(in oklab, var(--frost) 62%, transparent)); box-shadow: inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 30%, transparent), inset 0 0 0 1px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 10%, transparent); color: light-dark(#373a40, #f1f2f3); font-size: 8px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; }
  .tabs-pill-box__face::before { content: ''; position: absolute; left: 4px; right: 4px; top: 3px; height: 8px; border-radius: 4px; background: linear-gradient(color-mix(in oklab, light-dark(#373a40, #f1f2f3) 22%, transparent), transparent); }
  .tabs-pill-box__under { transform: rotateX(180deg); background: linear-gradient(color-mix(in oklab, var(--frost) 75%, var(--shade)), color-mix(in oklab, var(--frost) 55%, transparent)); box-shadow: inset 0 0 0 1px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 12%, transparent); }
  .tabs-pill-box__day:hover:not([aria-selected='true']) .tabs-pill-box__lid { transform: rotateX(14deg); transition-duration: 0.2s; }
  .tabs-pill-box__panel { min-height: 3em; margin-top: 12px; color: light-dark(#50535b, #c6c8cd); font-size: 12.5px; line-height: 1.5; }
  .tabs-pill-box__panel b { display: block; color: light-dark(#373a40, #f1f2f3); font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
  @keyframes tabs-pill-box-rattle { 0% { translate: 0 0; } 35% { translate: 1px -4px; } 70% { translate: -1px 0; } 100% { translate: 0 0; } }
  @media (prefers-reduced-motion: reduce) {
    .tabs-pill-box__lid, .tabs-pill-box__day[aria-selected='true'] .tabs-pill-box__lid, .tabs-pill-box__pills { transition: none; }
    .tabs-pill-box__day[aria-selected='true'] .tabs-pill-box__pill { animation: none; }
  }
`;

export function Preview() {
  const id = useId();
  const [sel, setSel] = useState(2);
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const n = DAYS.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-pill-box" precedence="kit">
        {css}
      </style>
      <div className="tabs-pill-box">
        <div className="tabs-pill-box__box" role="tablist" aria-label="Days" onKeyDown={onKeyDown}>
          {DAYS.map((d, i) => (
            <button
              key={d.name}
              ref={(el) => {
                tabs.current[i] = el;
              }}
              className="tabs-pill-box__day"
              type="button"
              role="tab"
              id={`${id}-tab-${i}`}
              aria-label={d.name}
              aria-selected={sel === i}
              aria-controls={`${id}-panel-${i}`}
              tabIndex={sel === i ? 0 : -1}
              onClick={() => setSel(i)}
            >
              <span className="tabs-pill-box__well" aria-hidden="true">
                <span className="tabs-pill-box__pills">
                  {d.pills.map((p, k) => (
                    <span key={k} className="tabs-pill-box__pill" data-k={p.k} style={{ '--x': `${p.x}px`, '--y': `${p.y}px`, '--r': `${p.r}deg`, '--t': `${0.28 + k * 0.06}s` } as CSSProperties} />
                  ))}
                </span>
              </span>
              <span className="tabs-pill-box__lid" aria-hidden="true">
                <span className="tabs-pill-box__face">{d.label}</span>
                <span className="tabs-pill-box__under" />
              </span>
            </button>
          ))}
        </div>
        {DAYS.map((d, i) => (
          <div key={d.name} className="tabs-pill-box__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
            <b>{d.name}</b>
            {d.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-pill-box
Source on GitHub

About this component

A weekly pill organiser: pick a day and its frosted lid snaps open on the hinge, the pills inside give a little rattle, and the day you left shuts.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, 3d, spring
Techniques
3d transform, perspective, preserve 3d, css transition, 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. Each day is named in full. The drawing is aria-hidden. Under prefers-reduced-motion the lids open and close at once and the pills stay still.

More tabs

See all
The May invoices are signed and on their way back.
Pneumatic
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