Pigeonhole

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

const TEAMS = ['Design', 'Sales', 'Legal', 'Press', 'Ops', 'Studio'];
const CUBBY = { w: 70, h: 48, gap: 6, pad: 7, plate: 16 };
// Where the letter rests in each cubby: centred, sitting on the floor.
const SPOT = TEAMS.map((_, i) => {
  const c = i % 3;
  const r = Math.floor(i / 3);
  return { x: CUBBY.pad + c * (CUBBY.w + CUBBY.gap) + CUBBY.w / 2 - 25, y: CUBBY.pad + r * (CUBBY.h + CUBBY.plate + CUBBY.gap) + CUBBY.h / 2 - 9 };
});
const at = (n: number) => `.radio-pigeonhole:has(.radio-pigeonhole__cubby:nth-child(${n + 1}) input:checked)`;

const css = `
  .radio-pigeonhole { --wood: light-dark(#cbced3, #2e3035); --paper: light-dark(#fbfcfd, #e5e6e9); --line: light-dark(#6d7179, #7d8087); position: relative; display: grid; grid-template-columns: repeat(3, ${CUBBY.w}px); gap: ${CUBBY.gap}px; padding: ${CUBBY.pad}px; border-radius: 4px; background: repeating-linear-gradient(90deg, transparent 0 9px, color-mix(in oklab, light-dark(#fbfcfd, #121417) 8%, transparent) 9px 10px, transparent 10px 23px), linear-gradient(var(--wood), color-mix(in oklab, var(--wood) 80%, light-dark(#fbfcfd, #121417))); box-shadow: inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 14%, transparent), 0 10px 22px -10px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6)); font-family: Georgia, 'Times New Roman', serif; }
  .radio-pigeonhole__cubby { position: relative; display: grid; grid-template-rows: ${CUBBY.h}px ${CUBBY.plate}px; justify-items: center; cursor: pointer; }
  .radio-pigeonhole__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
  .radio-pigeonhole__hole { position: relative; width: 100%; height: 100%; overflow: hidden; border-radius: 2px; background: light-dark(#ebedf0, #1a1c1f); box-shadow: inset 0 12px 14px -6px light-dark(rgba(0, 0, 0, 0.17), rgba(0, 0, 0, 0.55)), inset 0 0 0 1px light-dark(rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0.2)); transition: background-color 0.25s; }
  .radio-pigeonhole__cubby:hover .radio-pigeonhole__hole { background: light-dark(#dbdde1, #232529); }
  .radio-pigeonhole__cubby:has(input:focus-visible) .radio-pigeonhole__hole { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
  /* What is already in some of the holes. */
  .radio-pigeonhole__stack { position: absolute; left: 10px; right: 12px; bottom: 0; height: 5px; border-radius: 1px; background: var(--paper); opacity: 0.6; box-shadow: 0 -4px 0 -1px color-mix(in oklab, var(--paper) 70%, transparent); }
  .radio-pigeonhole__roll { position: absolute; right: 8px; bottom: 0; width: 12px; height: 12px; border-radius: 50%; background: radial-gradient(circle, var(--line) 0 2px, var(--paper) 2.5px); opacity: 0.6; }
  .radio-pigeonhole__plate { align-self: center; padding: 1px 6px; border-radius: 2px; background: light-dark(#6d7179, #a7abb2); color: light-dark(#fbfcfd, #121417); font-size: 7.5px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; box-shadow: inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 30%, transparent), 0 1px 1px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35)); transition: background-color 0.3s 0.45s; }
  .radio-pigeonhole__cubby:has(input:checked) .radio-pigeonhole__plate { background: light-dark(#373a40, #f1f2f3); }
  /* The letter: x and y ease along one path while it comes out toward you and goes back in. */
  .radio-pigeonhole__letter { position: absolute; left: 0; top: 0; width: 50px; height: 32px; pointer-events: none; transform: translate(var(--x), var(--y)); transition: transform 0.62s cubic-bezier(0.55, 0, 0.25, 1); }
  .radio-pigeonhole__env { display: block; width: 100%; height: 100%; scale: 0.8; filter: drop-shadow(0 1px 1px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4))); }
  .radio-pigeonhole__env rect:first-child { fill: var(--paper); }
  .radio-pigeonhole__env path, .radio-pigeonhole__env line { fill: none; stroke: var(--line); stroke-width: 1.1; stroke-linecap: round; }
  .radio-pigeonhole__env .radio-pigeonhole__stamp { fill: none; stroke: var(--line); stroke-width: 1; stroke-dasharray: 1.4 1; }
  ${SPOT.map((s, i) => `${at(i)} { --x: ${s.x}px; --y: ${s.y}px; }\n  ${at(i)} .radio-pigeonhole__env { animation: radio-pigeonhole-fly${i} 0.62s cubic-bezier(0.4, 0, 0.3, 1); }\n  ${at(i)} { animation: radio-pigeonhole-knock${i} 0.28s ease-out 0.56s; }\n  @keyframes radio-pigeonhole-fly${i} { 40% { scale: 1.14; rotate: -5deg; filter: drop-shadow(0 12px 10px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35))); } 85% { scale: 0.78; rotate: 0deg; } }\n  @keyframes radio-pigeonhole-knock${i} { 30% { transform: translateY(1.5px); } }`).join('\n  ')}
  .radio-pigeonhole__air { position: absolute; inset: 0; pointer-events: none; }
  @media (prefers-reduced-motion: reduce) {
    .radio-pigeonhole * { animation: none !important; transition: none !important; }
  }
`;

export function Preview() {
  const name = useId();
  return (
    <>
      <style href="kit-radios-pigeonhole" precedence="kit">
        {css}
      </style>
      <div className="radio-pigeonhole" role="radiogroup" aria-label="Send to">
        {TEAMS.map((t, i) => (
          <label key={t} className="radio-pigeonhole__cubby">
            <input className="radio-pigeonhole__input" type="radio" name={name} value={t.toLowerCase()} defaultChecked={i === 1} />
            <span className="radio-pigeonhole__hole" aria-hidden="true">
              {i === 0 && <span className="radio-pigeonhole__stack" />}
              {i === 4 && <span className="radio-pigeonhole__roll" />}
              {i === 5 && <span className="radio-pigeonhole__stack" style={{ left: 30 }} />}
            </span>
            <span className="radio-pigeonhole__plate">{t}</span>
          </label>
        ))}
        <span className="radio-pigeonhole__air" aria-hidden="true">
          <span className="radio-pigeonhole__letter">
            <svg className="radio-pigeonhole__env" viewBox="0 0 50 32">
              <rect x={0.5} y={0.5} width={49} height={31} rx={2} />
              <path d="M1 1.5L25 18L49 1.5" />
              <line x1={8} y1={22} x2={26} y2={22} />
              <line x1={8} y1={26} x2={20} y2={26} />
              <rect className="radio-pigeonhole__stamp" x={39} y={20} width={7} height={8} />
            </svg>
          </span>
        </span>
      </div>
    </>
  );
}

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

Install with shadcn

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

About this component

Route a letter to a team: pick a cubby and the envelope comes out of its hole toward you, crosses the rack and slides into the new one, which gives a little knock as it lands.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, keyframe, hover
Techniques
css variables, css transition, keyframes, svg
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A role="radiogroup" labelled "Send to" around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once. Each cubby is a <label> named by the team on its plate, and focus outlines the hole. The envelope is aria-hidden. Under prefers-reduced-motion the letter moves at once.

More radios

See all
Pillbox
Sewing
Stickers
Strings