Pillbox

No dependencies
Code format
React + Tailwind
import { useId, type CSSProperties } from 'react';

// What is in each compartment: c = capsule, t = round tablet, o = oval.
const DAYS = [
  { value: 'mon', label: 'Monday', pills: 'ct' },
  { value: 'tue', label: 'Tuesday', pills: 'co' },
  { value: 'wed', label: 'Wednesday', pills: 'cto' },
  { value: 'thu', label: 'Thursday', pills: 'tt' },
  { value: 'fri', label: 'Friday', pills: 'cot' },
  { value: 'sat', label: 'Saturday', pills: 'o' },
  { value: 'sun', label: 'Sunday', pills: 'ct' },
];
const PLACE = [
  [5, 6, -24],
  [7, 19, 14],
  [11, 28, -8],
];
const at = (n: number) => `.radio-pillbox:has(.radio-pillbox__cell:nth-child(${n + 1}) input:checked)`;
const SNAP = 'linear(0, 0.071, 0.231, 0.42, 0.601, 0.753, 0.871, 0.953, 1.005, 1.033, 1.045, 1.045, 1.04, 1.032, 1.023, 1.015, 1.009, 1.004, 1.001, 0.999, 0.998, 0.998, 0.998, 0.998, 1)';

const css = `
  .radio-pillbox { --case: light-dark(#dbdde1, #2e3035); --side: light-dark(#cbced3, #232529); --lid: color-mix(in oklab, light-dark(#6d7179, #a7abb2) 62%, transparent); display: grid; justify-items: center; gap: 22px; perspective: 700px; font-family: ui-rounded, 'SF Pro Rounded', 'Arial Rounded MT Bold', system-ui, sans-serif; }
  .radio-pillbox__case { display: flex; gap: 2px; padding: 5px; border-radius: 11px; background: linear-gradient(var(--case), color-mix(in oklab, var(--case) 88%, light-dark(#fbfcfd, #121417))); box-shadow: inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 16%, transparent), 0 9px 0 -1px var(--side), 0 16px 18px -6px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5)); transform: rotateX(26deg); transform-style: preserve-3d; }
  .radio-pillbox__cell { position: relative; width: 30px; height: 40px; cursor: pointer; transform-style: preserve-3d; }
  .radio-pillbox__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
  .radio-pillbox__well { position: absolute; inset: 0; overflow: hidden; border-radius: 6px; background: light-dark(#ebedf0, #1a1c1f); box-shadow: inset 0 6px 8px -2px light-dark(rgba(0, 0, 0, 0.17), rgba(0, 0, 0, 0.55)); }
  .radio-pillbox__pill { position: absolute; left: var(--x); top: var(--y); rotate: var(--r); }
  .radio-pillbox__pill--c { width: 15px; height: 6px; border-radius: 3px; background: linear-gradient(90deg, light-dark(#373a40, #f1f2f3) 50%, light-dark(#6d7179, #7d8087) 50%); }
  .radio-pillbox__pill--t { width: 8px; height: 8px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, light-dark(#373a40, #f1f2f3), light-dark(#5f626a, #a7abb2)); }
  .radio-pillbox__pill--o { width: 12px; height: 7px; border-radius: 50%; background: light-dark(#50535b, #c6c8cd); box-shadow: inset 0 -1px 0 light-dark(#6d7179, #7d8087); }
  .radio-pillbox__pill { box-shadow: 0 1px 1px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)); }
  .radio-pillbox__cell:has(input:checked) .radio-pillbox__pill { animation: radio-pillbox-rattle 0.42s ease-out 0.22s; }
  .radio-pillbox__pill:nth-child(2) { animation-delay: 0.27s !important; }
  .radio-pillbox__pill:nth-child(3) { animation-delay: 0.31s !important; }
  @keyframes radio-pillbox-rattle { 20% { translate: 1.5px -2px; } 45% { translate: -1px 1px; } 70% { translate: 0.5px -0.5px; } }
  /* The lid: frosted plastic hinged on its far edge, letter on top, plain underneath. */
  .radio-pillbox__lid { position: absolute; inset: 0; border-radius: 6px; transform-origin: 50% 0; transform-style: preserve-3d; transition: transform 0.36s cubic-bezier(0.6, 0, 0.8, 0.4); }
  .radio-pillbox__lid::after { content: ''; position: absolute; inset: 0; border-radius: inherit; background: color-mix(in oklab, var(--lid) 70%, light-dark(#fbfcfd, #121417)); transform: rotateY(180deg); backface-visibility: hidden; }
  .radio-pillbox__top { position: absolute; inset: 0; display: grid; place-items: center; border-radius: inherit; background: linear-gradient(160deg, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 28%, transparent), transparent 45%), var(--lid); box-shadow: inset 0 0 0 1px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 18%, transparent); backdrop-filter: blur(1.5px); color: light-dark(#373a40, #f1f2f3); font-size: 14px; font-weight: 800; backface-visibility: hidden; }
  .radio-pillbox__cell:hover .radio-pillbox__lid { transform: rotateX(14deg); transition-duration: 0.25s; transition-timing-function: ease-out; }
  .radio-pillbox__cell:has(input:checked) .radio-pillbox__lid { transform: rotateX(118deg); transition: transform 0.75s ${SNAP}; }
  .radio-pillbox__cell:has(input:focus-visible) .radio-pillbox__well { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
  .radio-pillbox__read { display: grid; color: light-dark(#50535b, #c6c8cd); font-size: 12px; font-weight: 600; }
  .radio-pillbox__read > span { grid-area: 1 / 1; text-align: center; opacity: 0; transform: translateY(4px); transition: opacity 0.2s, transform 0.3s; }
  .radio-pillbox__read b { color: light-dark(#373a40, #f1f2f3); font-weight: 800; }
  ${DAYS.map((_, i) => `${at(i)} .radio-pillbox__read > :nth-child(${i + 1})`).join(', ')} { opacity: 1; transform: none; transition-delay: 0.12s; }
  @media (prefers-reduced-motion: reduce) {
    .radio-pillbox * { animation: none !important; transition: none !important; }
  }
`;

export function Preview() {
  const name = useId();
  return (
    <>
      <style href="kit-radios-pillbox" precedence="kit">
        {css}
      </style>
      <div className="radio-pillbox">
        <div className="radio-pillbox__case" role="radiogroup" aria-label="Day">
          {DAYS.map((d, i) => (
            <label key={d.value} className="radio-pillbox__cell">
              <input className="radio-pillbox__input" type="radio" name={name} value={d.value} aria-label={d.label} defaultChecked={i === 2} />
              <span className="radio-pillbox__well" aria-hidden="true">
                {[...d.pills].map((p, k) => (
                  <span key={k} className={`radio-pillbox__pill radio-pillbox__pill--${p}`} style={{ '--x': `${PLACE[k][0]}px`, '--y': `${PLACE[k][1]}px`, '--r': `${PLACE[k][2] + i * 7}deg` } as CSSProperties} />
                ))}
              </span>
              <span className="radio-pillbox__lid" aria-hidden="true">
                <span className="radio-pillbox__top">{d.label[0]}</span>
              </span>
            </label>
          ))}
        </div>
        <p className="radio-pillbox__read" aria-hidden="true">
          {DAYS.map((d) => (
            <span key={d.value}>
              <b>{d.label}</b> · {d.pills.length} {d.pills.length === 1 ? 'pill' : 'pills'}
            </span>
          ))}
        </p>
      </div>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/radio-pillbox
Source on GitHub

About this component

A day picker as a weekly pill organiser: the chosen lid flips open on its hinge with a little spring and the pills inside rattle, while the last lid snaps shut.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, 3d, spring
Techniques
preserve 3d, perspective, css transition, spring, keyframes
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A role="radiogroup" labelled "Day" around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once. Each input is named with the full day, since the lid only shows its initial, and focus outlines the compartment. Under prefers-reduced-motion the lids open and close at once.

More radios

See all
Sewing
Stickers
Strings
Units