Pillbox
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>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
<div class="radio-pillbox">
<div class="radio-pillbox__case" role="radiogroup" aria-label="Day">
<label class="radio-pillbox__cell">
<input class="radio-pillbox__input" type="radio" aria-label="Monday" name="radio-pillbox" value="mon"/>
<span class="radio-pillbox__well" aria-hidden="true">
<span class="radio-pillbox__pill radio-pillbox__pill--c" style="--x:5px;--y:6px;--r:-24deg"></span>
<span class="radio-pillbox__pill radio-pillbox__pill--t" style="--x:7px;--y:19px;--r:14deg"></span>
</span>
<span class="radio-pillbox__lid" aria-hidden="true">
<span class="radio-pillbox__top">M</span>
</span>
</label>
<label class="radio-pillbox__cell">
<input class="radio-pillbox__input" type="radio" aria-label="Tuesday" name="radio-pillbox" value="tue"/>
<span class="radio-pillbox__well" aria-hidden="true">
<span class="radio-pillbox__pill radio-pillbox__pill--c" style="--x:5px;--y:6px;--r:-17deg"></span>
<span class="radio-pillbox__pill radio-pillbox__pill--o" style="--x:7px;--y:19px;--r:21deg"></span>
</span>
<span class="radio-pillbox__lid" aria-hidden="true">
<span class="radio-pillbox__top">T</span>
</span>
</label>
<label class="radio-pillbox__cell">
<input class="radio-pillbox__input" type="radio" aria-label="Wednesday" name="radio-pillbox" checked="" value="wed"/>
<span class="radio-pillbox__well" aria-hidden="true">
<span class="radio-pillbox__pill radio-pillbox__pill--c" style="--x:5px;--y:6px;--r:-10deg"></span>
<span class="radio-pillbox__pill radio-pillbox__pill--t" style="--x:7px;--y:19px;--r:28deg"></span>
<span class="radio-pillbox__pill radio-pillbox__pill--o" style="--x:11px;--y:28px;--r:6deg"></span>
</span>
<span class="radio-pillbox__lid" aria-hidden="true">
<span class="radio-pillbox__top">W</span>
</span>
</label>
<label class="radio-pillbox__cell">
<input class="radio-pillbox__input" type="radio" aria-label="Thursday" name="radio-pillbox" value="thu"/>
<span class="radio-pillbox__well" aria-hidden="true">
<span class="radio-pillbox__pill radio-pillbox__pill--t" style="--x:5px;--y:6px;--r:-3deg"></span>
<span class="radio-pillbox__pill radio-pillbox__pill--t" style="--x:7px;--y:19px;--r:35deg"></span>
</span>
<span class="radio-pillbox__lid" aria-hidden="true">
<span class="radio-pillbox__top">T</span>
</span>
</label>
<label class="radio-pillbox__cell">
<input class="radio-pillbox__input" type="radio" aria-label="Friday" name="radio-pillbox" value="fri"/>
<span class="radio-pillbox__well" aria-hidden="true">
<span class="radio-pillbox__pill radio-pillbox__pill--c" style="--x:5px;--y:6px;--r:4deg"></span>
<span class="radio-pillbox__pill radio-pillbox__pill--o" style="--x:7px;--y:19px;--r:42deg"></span>
<span class="radio-pillbox__pill radio-pillbox__pill--t" style="--x:11px;--y:28px;--r:20deg"></span>
</span>
<span class="radio-pillbox__lid" aria-hidden="true">
<span class="radio-pillbox__top">F</span>
</span>
</label>
<label class="radio-pillbox__cell">
<input class="radio-pillbox__input" type="radio" aria-label="Saturday" name="radio-pillbox" value="sat"/>
<span class="radio-pillbox__well" aria-hidden="true">
<span class="radio-pillbox__pill radio-pillbox__pill--o" style="--x:5px;--y:6px;--r:11deg"></span>
</span>
<span class="radio-pillbox__lid" aria-hidden="true">
<span class="radio-pillbox__top">S</span>
</span>
</label>
<label class="radio-pillbox__cell">
<input class="radio-pillbox__input" type="radio" aria-label="Sunday" name="radio-pillbox" value="sun"/>
<span class="radio-pillbox__well" aria-hidden="true">
<span class="radio-pillbox__pill radio-pillbox__pill--c" style="--x:5px;--y:6px;--r:18deg"></span>
<span class="radio-pillbox__pill radio-pillbox__pill--t" style="--x:7px;--y:19px;--r:56deg"></span>
</span>
<span class="radio-pillbox__lid" aria-hidden="true">
<span class="radio-pillbox__top">S</span>
</span>
</label>
</div>
<p class="radio-pillbox__read" aria-hidden="true">
<span>
<b>Monday</b>
· 2 pills
</span>
<span>
<b>Tuesday</b>
· 2 pills
</span>
<span>
<b>Wednesday</b>
· 3 pills
</span>
<span>
<b>Thursday</b>
· 2 pills
</span>
<span>
<b>Friday</b>
· 3 pills
</span>
<span>
<b>Saturday</b>
· 1 pill
</span>
<span>
<b>Sunday</b>
· 2 pills
</span>
</p>
</div>.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 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); }
.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; }
.radio-pillbox:has(.radio-pillbox__cell:nth-child(1) input:checked) .radio-pillbox__read > :nth-child(1), .radio-pillbox:has(.radio-pillbox__cell:nth-child(2) input:checked) .radio-pillbox__read > :nth-child(2), .radio-pillbox:has(.radio-pillbox__cell:nth-child(3) input:checked) .radio-pillbox__read > :nth-child(3), .radio-pillbox:has(.radio-pillbox__cell:nth-child(4) input:checked) .radio-pillbox__read > :nth-child(4), .radio-pillbox:has(.radio-pillbox__cell:nth-child(5) input:checked) .radio-pillbox__read > :nth-child(5), .radio-pillbox:has(.radio-pillbox__cell:nth-child(6) input:checked) .radio-pillbox__read > :nth-child(6), .radio-pillbox:has(.radio-pillbox__cell:nth-child(7) input:checked) .radio-pillbox__read > :nth-child(7) { opacity: 1; transform: none; transition-delay: 0.12s; }
@media (prefers-reduced-motion: reduce) {
.radio-pillbox * { animation: none !important; transition: none !important; }
}Goal: Create a "Pillbox" radio.
Visual behavior: 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.
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.
- The case tilts back in perspective and each lid is hinged on its far edge, opening to rotateX(118deg) on a linear() spring and closing with a quick ease-in snap. The lid has a lettered top and a plain underside, each with backface-visibility hidden.
- The lids are frosted with a translucent fill and backdrop-filter, so the pills show through faintly, and a short keyframe rattles them as the lid opens.
Accessibility: 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.
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 "Pillbox" radio.
Visual behavior: 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.
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.
- The case tilts back in perspective and each lid is hinged on its far edge, opening to rotateX(118deg) on a linear() spring and closing with a quick ease-in snap. The lid has a lettered top and a plain underside, each with backface-visibility hidden.
- The lids are frosted with a translucent fill and backdrop-filter, so the pills show through faintly, and a short keyframe rattles them as the lid opens.
Accessibility: 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.
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
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.