Monday · 2 pillsTuesday · 2 pillsWednesday · 3 pillsThursday · 2 pillsFriday · 3 pillsSaturday · 1 pillSunday · 2 pills
Pigeonhole
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>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
HTML
<div class="radio-pigeonhole" role="radiogroup" aria-label="Send to">
<label class="radio-pigeonhole__cubby">
<input class="radio-pigeonhole__input" type="radio" name="radio-pigeonhole" value="design"/>
<span class="radio-pigeonhole__hole" aria-hidden="true">
<span class="radio-pigeonhole__stack"></span>
</span>
<span class="radio-pigeonhole__plate">Design</span>
</label>
<label class="radio-pigeonhole__cubby">
<input class="radio-pigeonhole__input" type="radio" name="radio-pigeonhole" checked="" value="sales"/>
<span class="radio-pigeonhole__hole" aria-hidden="true"></span>
<span class="radio-pigeonhole__plate">Sales</span>
</label>
<label class="radio-pigeonhole__cubby">
<input class="radio-pigeonhole__input" type="radio" name="radio-pigeonhole" value="legal"/>
<span class="radio-pigeonhole__hole" aria-hidden="true"></span>
<span class="radio-pigeonhole__plate">Legal</span>
</label>
<label class="radio-pigeonhole__cubby">
<input class="radio-pigeonhole__input" type="radio" name="radio-pigeonhole" value="press"/>
<span class="radio-pigeonhole__hole" aria-hidden="true"></span>
<span class="radio-pigeonhole__plate">Press</span>
</label>
<label class="radio-pigeonhole__cubby">
<input class="radio-pigeonhole__input" type="radio" name="radio-pigeonhole" value="ops"/>
<span class="radio-pigeonhole__hole" aria-hidden="true">
<span class="radio-pigeonhole__roll"></span>
</span>
<span class="radio-pigeonhole__plate">Ops</span>
</label>
<label class="radio-pigeonhole__cubby">
<input class="radio-pigeonhole__input" type="radio" name="radio-pigeonhole" value="studio"/>
<span class="radio-pigeonhole__hole" aria-hidden="true">
<span class="radio-pigeonhole__stack" style="left:30px"></span>
</span>
<span class="radio-pigeonhole__plate">Studio</span>
</label>
<span class="radio-pigeonhole__air" aria-hidden="true">
<span class="radio-pigeonhole__letter">
<svg class="radio-pigeonhole__env" viewBox="0 0 50 32">
<rect x="0.5" y="0.5" width="49" height="31" rx="2"></rect>
<path d="M1 1.5L25 18L49 1.5"></path>
<line x1="8" y1="22" x2="26" y2="22"></line>
<line x1="8" y1="26" x2="20" y2="26"></line>
<rect class="radio-pigeonhole__stamp" x="39" y="20" width="7" height="8"></rect>
</svg>
</span>
</span>
</div>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, 70px); gap: 6px; padding: 7px; 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: 48px 16px; 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; }
.radio-pigeonhole:has(.radio-pigeonhole__cubby:nth-child(1) input:checked) { --x: 17px; --y: 22px; }
.radio-pigeonhole:has(.radio-pigeonhole__cubby:nth-child(1) input:checked) .radio-pigeonhole__env { animation: radio-pigeonhole-fly0 0.62s cubic-bezier(0.4, 0, 0.3, 1); }
.radio-pigeonhole:has(.radio-pigeonhole__cubby:nth-child(1) input:checked) { animation: radio-pigeonhole-knock0 0.28s ease-out 0.56s; }
@keyframes radio-pigeonhole-fly0 { 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; } }
@keyframes radio-pigeonhole-knock0 { 30% { transform: translateY(1.5px); } }
.radio-pigeonhole:has(.radio-pigeonhole__cubby:nth-child(2) input:checked) { --x: 93px; --y: 22px; }
.radio-pigeonhole:has(.radio-pigeonhole__cubby:nth-child(2) input:checked) .radio-pigeonhole__env { animation: radio-pigeonhole-fly1 0.62s cubic-bezier(0.4, 0, 0.3, 1); }
.radio-pigeonhole:has(.radio-pigeonhole__cubby:nth-child(2) input:checked) { animation: radio-pigeonhole-knock1 0.28s ease-out 0.56s; }
@keyframes radio-pigeonhole-fly1 { 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; } }
@keyframes radio-pigeonhole-knock1 { 30% { transform: translateY(1.5px); } }
.radio-pigeonhole:has(.radio-pigeonhole__cubby:nth-child(3) input:checked) { --x: 169px; --y: 22px; }
.radio-pigeonhole:has(.radio-pigeonhole__cubby:nth-child(3) input:checked) .radio-pigeonhole__env { animation: radio-pigeonhole-fly2 0.62s cubic-bezier(0.4, 0, 0.3, 1); }
.radio-pigeonhole:has(.radio-pigeonhole__cubby:nth-child(3) input:checked) { animation: radio-pigeonhole-knock2 0.28s ease-out 0.56s; }
@keyframes radio-pigeonhole-fly2 { 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; } }
@keyframes radio-pigeonhole-knock2 { 30% { transform: translateY(1.5px); } }
.radio-pigeonhole:has(.radio-pigeonhole__cubby:nth-child(4) input:checked) { --x: 17px; --y: 92px; }
.radio-pigeonhole:has(.radio-pigeonhole__cubby:nth-child(4) input:checked) .radio-pigeonhole__env { animation: radio-pigeonhole-fly3 0.62s cubic-bezier(0.4, 0, 0.3, 1); }
.radio-pigeonhole:has(.radio-pigeonhole__cubby:nth-child(4) input:checked) { animation: radio-pigeonhole-knock3 0.28s ease-out 0.56s; }
@keyframes radio-pigeonhole-fly3 { 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; } }
@keyframes radio-pigeonhole-knock3 { 30% { transform: translateY(1.5px); } }
.radio-pigeonhole:has(.radio-pigeonhole__cubby:nth-child(5) input:checked) { --x: 93px; --y: 92px; }
.radio-pigeonhole:has(.radio-pigeonhole__cubby:nth-child(5) input:checked) .radio-pigeonhole__env { animation: radio-pigeonhole-fly4 0.62s cubic-bezier(0.4, 0, 0.3, 1); }
.radio-pigeonhole:has(.radio-pigeonhole__cubby:nth-child(5) input:checked) { animation: radio-pigeonhole-knock4 0.28s ease-out 0.56s; }
@keyframes radio-pigeonhole-fly4 { 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; } }
@keyframes radio-pigeonhole-knock4 { 30% { transform: translateY(1.5px); } }
.radio-pigeonhole:has(.radio-pigeonhole__cubby:nth-child(6) input:checked) { --x: 169px; --y: 92px; }
.radio-pigeonhole:has(.radio-pigeonhole__cubby:nth-child(6) input:checked) .radio-pigeonhole__env { animation: radio-pigeonhole-fly5 0.62s cubic-bezier(0.4, 0, 0.3, 1); }
.radio-pigeonhole:has(.radio-pigeonhole__cubby:nth-child(6) input:checked) { animation: radio-pigeonhole-knock5 0.28s ease-out 0.56s; }
@keyframes radio-pigeonhole-fly5 { 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; } }
@keyframes radio-pigeonhole-knock5 { 30% { transform: translateY(1.5px); } }
.radio-pigeonhole__air { position: absolute; inset: 0; pointer-events: none; }
@media (prefers-reduced-motion: reduce) {
.radio-pigeonhole * { animation: none !important; transition: none !important; }
}Goal: Create a "Pigeonhole" radio.
Visual behavior: 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.
Technique:
- Native radio inputs sharing one name inside a labelled role="radiogroup", visually hidden but focusable; each option is a <label> wrapping its input, so the arrow keys and clicks both work.
- The scene reacts with :has() on the root, one selector per option (such as :has(.opt:nth-child(2) input:checked)), so no script is needed.
- One envelope serves the whole rack. :has() gives it the chosen cubby as two variables and a transform transition carries it there, while a keyframe per cubby (one name each, so it replays on every pick) scales it up as if pulled toward you and back down as it goes in.
- The rack nudges down a pixel as the letter lands. Each hole is a recess drawn with an inset shadow, and the chosen team's name plate lights once the letter arrives.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Pigeonhole" radio.
Visual behavior: 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.
Technique:
- Native radio inputs sharing one name inside a labelled role="radiogroup", visually hidden but focusable; each option is a <label> wrapping its input, so the arrow keys and clicks both work.
- The scene reacts with :has() on the root, one selector per option (such as :has(.opt:nth-child(2) input:checked)), so no script is needed.
- One envelope serves the whole rack. :has() gives it the chosen cubby as two variables and a transform transition carries it there, while a keyframe per cubby (one name each, so it replays on every pick) scales it up as if pulled toward you and back down as it goes in.
- The rack nudges down a pixel as the letter lands. Each hole is a recess drawn with an inset shadow, and the chosen team's name plate lights once the letter arrives.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.