Fortune
import { useId, type CSSProperties } from 'react';
// The playground game: the teller opens and shuts once per letter of the sign
// you pick, then you read your fortune. Each sign counts its own letters.
const OPTIONS = [
{ value: 'star', label: 'Star', fortune: 'Big news by Friday', icon: 'M12 3.5l2.5 5.3 5.8.7-4.3 4 1.1 5.7L12 16.4l-5.1 2.8 1.1-5.7-4.3-4 5.8-.7z' },
{ value: 'moon', label: 'Moon', fortune: 'Sleep on it', icon: 'M18.5 14.6A7.4 7.4 0 1 1 9.4 5.2a5.9 5.9 0 0 0 9.1 9.4z' },
{ value: 'sun', label: 'Sun', fortune: 'Say yes', icon: 'M12 8.2a3.8 3.8 0 1 0 0 7.6 3.8 3.8 0 0 0 0-7.6zM12 2.5v2.3M12 19.2v2.3M2.5 12h2.3M19.2 12h2.3M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M5.3 18.7l1.6-1.6M17.1 6.9l1.6-1.6' },
{ value: 'cloud', label: 'Cloud', fortune: 'Wait a week', icon: 'M7.2 18.2h9.9a3.9 3.9 0 0 0 .4-7.8 5.9 5.9 0 0 0-11.2 1.6 3.1 3.1 0 0 0 .9 6.2z' },
];
const CORNERS = ['tl', 'tr', 'bl', 'br'] as const;
const STEP = 0.3; // seconds per pinch
const done = (o: (typeof OPTIONS)[number]) => `${(o.label.length * STEP).toFixed(2)}s`;
const at = (n: number) => `.radio-fortune:has(.radio-fortune__flap:nth-child(${n + 2}) input:checked)`;
// The numbers written on the inside, round the centre where the gaps open.
const NUMBERS = Array.from({ length: 8 }, (_, k) => {
const a = ((22.5 + 45 * k) * Math.PI) / 180;
return { n: [3, 8, 1, 6, 4, 7, 2, 5][k], x: 60 + 31 * Math.cos(a), y: 60 + 31 * Math.sin(a) };
});
// One open-and-shut per letter, alternating up/down and left/right.
const pinch = (name: string, n: number) => {
let flap = `@keyframes radio-fortune-${name} {`;
let hand = `@keyframes radio-fortune-${name}-hand {`;
for (let k = 0; k < n; k++) {
const a = ((k / n) * 100).toFixed(1);
const b = (((k + 0.5) / n) * 100).toFixed(1);
flap += ` ${a}% { translate: 0 0; } ${b}% { translate: ${k % 2 ? 'calc(var(--sx) * 11px) 0' : '0 calc(var(--sy) * 11px)'}; }`;
hand += ` ${a}% { transform: none; } ${b}% { transform: rotate(${k % 2 ? -2.5 : 2.5}deg) scale(0.97); }`;
}
return `${flap} 100% { translate: 0 0; } }\n ${hand} 100% { transform: none; } }`;
};
const css = `
.radio-fortune { --paper: light-dark(#f3f4f7, #e5e6e9); --ink: light-dark(#373a40, #121417); --fold: color-mix(in oklab, var(--paper) 84%, var(--ink)); --inside: color-mix(in oklab, var(--paper) 58%, var(--ink)); display: grid; justify-items: center; gap: 14px; font-family: 'Bradley Hand', 'Segoe Print', 'Chalkboard SE', 'Comic Sans MS', cursive; }
.radio-fortune__teller { position: relative; width: 148px; height: 148px; filter: drop-shadow(0 6px 8px light-dark(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.32))); }
.radio-fortune__inside { position: absolute; inset: 14px; border-radius: 3px; background: radial-gradient(closest-side, var(--inside), color-mix(in oklab, var(--inside) 70%, var(--ink))); color: var(--paper); font-size: 12px; font-weight: 700; }
.radio-fortune__inside span { position: absolute; translate: -50% -50%; }
.radio-fortune__flap { --sx: -1; --sy: -1; position: absolute; left: 0; top: 0; width: 74px; height: 74px; cursor: pointer; transition: translate 0.3s cubic-bezier(0.3, 1.5, 0.5, 1); }
.radio-fortune__flap--tr { --sx: 1; left: 74px; }
.radio-fortune__flap--bl { --sy: 1; top: 74px; }
.radio-fortune__flap--br { --sx: 1; --sy: 1; left: 74px; top: 74px; }
.radio-fortune__flap:hover { translate: calc(var(--sx) * 2px) calc(var(--sy) * 2px); }
.radio-fortune__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
/* Each square is a folded peak: two shades either side of the crease that runs to the centre. */
.radio-fortune__face { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 1px; background: linear-gradient(45deg, var(--fold) 50%, var(--paper) 50%); color: var(--ink); font-size: 15px; font-weight: 700; line-height: 1; }
.radio-fortune__flap--tr .radio-fortune__face, .radio-fortune__flap--bl .radio-fortune__face { background: linear-gradient(-45deg, var(--paper) 50%, var(--fold) 50%); }
.radio-fortune__flap--tl .radio-fortune__face { border-top-left-radius: 7px; }
.radio-fortune__flap--tr .radio-fortune__face { border-top-right-radius: 7px; }
.radio-fortune__flap--bl .radio-fortune__face { border-bottom-left-radius: 7px; }
.radio-fortune__flap--br .radio-fortune__face { border-bottom-right-radius: 7px; }
.radio-fortune__face > * { translate: calc(var(--sx) * 4px) calc(var(--sy) * 4px); }
.radio-fortune__icon { width: 22px; height: 22px; fill: none; stroke: var(--ink); stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.radio-fortune__icon path { transition: fill 0.3s; fill: transparent; }
.radio-fortune__flap:has(input:checked) .radio-fortune__icon path { fill: var(--ink); transition-delay: var(--done); }
.radio-fortune__flap:has(input:focus-visible) .radio-fortune__face { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -5px; }
/* A pencil ring goes round the sign you picked once the counting is over. */
.radio-fortune__ring { position: absolute; left: 50%; top: 50%; width: 64px; height: 26px; translate: calc(-50% + var(--sx) * 4px) calc(-50% + var(--sy) * 4px + 12px); overflow: visible; fill: none; stroke: var(--ink); stroke-width: 1.6; stroke-linecap: round; pointer-events: none; }
.radio-fortune__ring path { stroke-dasharray: 1; stroke-dashoffset: 1; opacity: 0; transition: opacity 0.2s, stroke-dashoffset 0s 0.2s; }
.radio-fortune__flap:has(input:checked) .radio-fortune__ring path { opacity: 1; stroke-dashoffset: 0; transition: opacity 0s var(--done), stroke-dashoffset 0.45s cubic-bezier(0.4, 0, 0.3, 1) var(--done); }
${OPTIONS.map((o, i) => pinch(o.value, o.label.length)).join('\n ')}
${OPTIONS.map((o, i) => `${at(i)} .radio-fortune__flap { animation: radio-fortune-${o.value} ${done(o)} cubic-bezier(0.45, 0, 0.55, 1); }\n ${at(i)} .radio-fortune__teller { animation: radio-fortune-${o.value}-hand ${done(o)} cubic-bezier(0.45, 0, 0.55, 1); }`).join('\n ')}
.radio-fortune__slip { display: grid; min-width: 196px; padding: 7px 14px 6px; background: var(--paper); color: var(--ink); font-size: 16px; font-weight: 700; text-align: center; box-shadow: 0 3px 6px light-dark(rgba(0, 0, 0, 0.07), rgba(0, 0, 0, 0.24)); transform: rotate(-1.2deg); }
.radio-fortune__slip > span { grid-area: 1 / 1; opacity: 0; clip-path: inset(0 100% 0 0); }
${OPTIONS.map((o, i) => `${at(i)} .radio-fortune__slip > :nth-child(${i + 1}) { opacity: 1; clip-path: inset(0 0 0 0); transition: clip-path 0.7s steps(14) var(--done); }`).join('\n ')}
@media (prefers-reduced-motion: reduce) {
.radio-fortune, .radio-fortune * { animation: none !important; transition: none !important; }
}
`;
export function Preview() {
const name = useId();
return (
<>
<style href="kit-radios-fortune" precedence="kit">
{css}
</style>
<div className="radio-fortune" role="radiogroup" aria-label="Pick a sign">
<div className="radio-fortune__teller">
<span className="radio-fortune__inside" aria-hidden="true">
{NUMBERS.map((d) => (
<span key={d.n} style={{ left: d.x, top: d.y }}>
{d.n}
</span>
))}
</span>
{OPTIONS.map((o, i) => (
<label key={o.value} className={`radio-fortune__flap radio-fortune__flap--${CORNERS[i]}`} style={{ '--done': done(o) } as CSSProperties}>
<input className="radio-fortune__input" type="radio" name={name} value={o.value} defaultChecked={i === 0} />
<span className="radio-fortune__face">
<svg className="radio-fortune__icon" viewBox="0 0 24 24" aria-hidden="true">
<path d={o.icon} />
</svg>
{o.label}
</span>
<svg className="radio-fortune__ring" viewBox="0 0 64 26" aria-hidden="true">
<path pathLength={1} d="M8 14C7 6 21 3 32 3C46 3 57 7 57 14C57 21 45 24 31 24C17 24 6 21 7 13C8 8 15 5 23 4" />
</svg>
</label>
))}
</div>
<p className="radio-fortune__slip" aria-hidden="true">
{OPTIONS.map((o) => (
<span key={o.value} style={{ '--done': done(o) } as CSSProperties}>
{o.fortune}
</span>
))}
</p>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
<div class="radio-fortune" role="radiogroup" aria-label="Pick a sign">
<div class="radio-fortune__teller">
<span class="radio-fortune__inside" aria-hidden="true">
<span style="left:88.64026550784989px;top:71.86318640331778px">3</span>
<span style="left:71.86318640331778px;top:88.64026550784989px">8</span>
<span style="left:48.13681359668222px;top:88.64026550784989px">1</span>
<span style="left:31.35973449215011px;top:71.86318640331778px">6</span>
<span style="left:31.359734492150103px;top:48.13681359668223px">4</span>
<span style="left:48.136813596682224px;top:31.359734492150107px">7</span>
<span style="left:71.8631864033178px;top:31.359734492150114px">2</span>
<span style="left:88.64026550784989px;top:48.136813596682224px">5</span>
</span>
<label class="radio-fortune__flap radio-fortune__flap--tl" style="--done:1.20s">
<input class="radio-fortune__input" type="radio" name="radio-fortune" checked="" value="star"/>
<span class="radio-fortune__face">
<svg class="radio-fortune__icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3.5l2.5 5.3 5.8.7-4.3 4 1.1 5.7L12 16.4l-5.1 2.8 1.1-5.7-4.3-4 5.8-.7z"></path>
</svg>
Star
</span>
<svg class="radio-fortune__ring" viewBox="0 0 64 26" aria-hidden="true">
<path pathLength="1" d="M8 14C7 6 21 3 32 3C46 3 57 7 57 14C57 21 45 24 31 24C17 24 6 21 7 13C8 8 15 5 23 4"></path>
</svg>
</label>
<label class="radio-fortune__flap radio-fortune__flap--tr" style="--done:1.20s">
<input class="radio-fortune__input" type="radio" name="radio-fortune" value="moon"/>
<span class="radio-fortune__face">
<svg class="radio-fortune__icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M18.5 14.6A7.4 7.4 0 1 1 9.4 5.2a5.9 5.9 0 0 0 9.1 9.4z"></path>
</svg>
Moon
</span>
<svg class="radio-fortune__ring" viewBox="0 0 64 26" aria-hidden="true">
<path pathLength="1" d="M8 14C7 6 21 3 32 3C46 3 57 7 57 14C57 21 45 24 31 24C17 24 6 21 7 13C8 8 15 5 23 4"></path>
</svg>
</label>
<label class="radio-fortune__flap radio-fortune__flap--bl" style="--done:0.90s">
<input class="radio-fortune__input" type="radio" name="radio-fortune" value="sun"/>
<span class="radio-fortune__face">
<svg class="radio-fortune__icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 8.2a3.8 3.8 0 1 0 0 7.6 3.8 3.8 0 0 0 0-7.6zM12 2.5v2.3M12 19.2v2.3M2.5 12h2.3M19.2 12h2.3M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M5.3 18.7l1.6-1.6M17.1 6.9l1.6-1.6"></path>
</svg>
Sun
</span>
<svg class="radio-fortune__ring" viewBox="0 0 64 26" aria-hidden="true">
<path pathLength="1" d="M8 14C7 6 21 3 32 3C46 3 57 7 57 14C57 21 45 24 31 24C17 24 6 21 7 13C8 8 15 5 23 4"></path>
</svg>
</label>
<label class="radio-fortune__flap radio-fortune__flap--br" style="--done:1.50s">
<input class="radio-fortune__input" type="radio" name="radio-fortune" value="cloud"/>
<span class="radio-fortune__face">
<svg class="radio-fortune__icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M7.2 18.2h9.9a3.9 3.9 0 0 0 .4-7.8 5.9 5.9 0 0 0-11.2 1.6 3.1 3.1 0 0 0 .9 6.2z"></path>
</svg>
Cloud
</span>
<svg class="radio-fortune__ring" viewBox="0 0 64 26" aria-hidden="true">
<path pathLength="1" d="M8 14C7 6 21 3 32 3C46 3 57 7 57 14C57 21 45 24 31 24C17 24 6 21 7 13C8 8 15 5 23 4"></path>
</svg>
</label>
</div>
<p class="radio-fortune__slip" aria-hidden="true">
<span style="--done:1.20s">Big news by Friday</span>
<span style="--done:1.20s">Sleep on it</span>
<span style="--done:0.90s">Say yes</span>
<span style="--done:1.50s">Wait a week</span>
</p>
</div>.radio-fortune { --paper: light-dark(#f3f4f7, #e5e6e9); --ink: light-dark(#373a40, #121417); --fold: color-mix(in oklab, var(--paper) 84%, var(--ink)); --inside: color-mix(in oklab, var(--paper) 58%, var(--ink)); display: grid; justify-items: center; gap: 14px; font-family: 'Bradley Hand', 'Segoe Print', 'Chalkboard SE', 'Comic Sans MS', cursive; }
.radio-fortune__teller { position: relative; width: 148px; height: 148px; filter: drop-shadow(0 6px 8px light-dark(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.32))); }
.radio-fortune__inside { position: absolute; inset: 14px; border-radius: 3px; background: radial-gradient(closest-side, var(--inside), color-mix(in oklab, var(--inside) 70%, var(--ink))); color: var(--paper); font-size: 12px; font-weight: 700; }
.radio-fortune__inside span { position: absolute; translate: -50% -50%; }
.radio-fortune__flap { --sx: -1; --sy: -1; position: absolute; left: 0; top: 0; width: 74px; height: 74px; cursor: pointer; transition: translate 0.3s cubic-bezier(0.3, 1.5, 0.5, 1); }
.radio-fortune__flap--tr { --sx: 1; left: 74px; }
.radio-fortune__flap--bl { --sy: 1; top: 74px; }
.radio-fortune__flap--br { --sx: 1; --sy: 1; left: 74px; top: 74px; }
.radio-fortune__flap:hover { translate: calc(var(--sx) * 2px) calc(var(--sy) * 2px); }
.radio-fortune__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
/* Each square is a folded peak: two shades either side of the crease that runs to the centre. */
.radio-fortune__face { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 1px; background: linear-gradient(45deg, var(--fold) 50%, var(--paper) 50%); color: var(--ink); font-size: 15px; font-weight: 700; line-height: 1; }
.radio-fortune__flap--tr .radio-fortune__face, .radio-fortune__flap--bl .radio-fortune__face { background: linear-gradient(-45deg, var(--paper) 50%, var(--fold) 50%); }
.radio-fortune__flap--tl .radio-fortune__face { border-top-left-radius: 7px; }
.radio-fortune__flap--tr .radio-fortune__face { border-top-right-radius: 7px; }
.radio-fortune__flap--bl .radio-fortune__face { border-bottom-left-radius: 7px; }
.radio-fortune__flap--br .radio-fortune__face { border-bottom-right-radius: 7px; }
.radio-fortune__face > * { translate: calc(var(--sx) * 4px) calc(var(--sy) * 4px); }
.radio-fortune__icon { width: 22px; height: 22px; fill: none; stroke: var(--ink); stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.radio-fortune__icon path { transition: fill 0.3s; fill: transparent; }
.radio-fortune__flap:has(input:checked) .radio-fortune__icon path { fill: var(--ink); transition-delay: var(--done); }
.radio-fortune__flap:has(input:focus-visible) .radio-fortune__face { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -5px; }
/* A pencil ring goes round the sign you picked once the counting is over. */
.radio-fortune__ring { position: absolute; left: 50%; top: 50%; width: 64px; height: 26px; translate: calc(-50% + var(--sx) * 4px) calc(-50% + var(--sy) * 4px + 12px); overflow: visible; fill: none; stroke: var(--ink); stroke-width: 1.6; stroke-linecap: round; pointer-events: none; }
.radio-fortune__ring path { stroke-dasharray: 1; stroke-dashoffset: 1; opacity: 0; transition: opacity 0.2s, stroke-dashoffset 0s 0.2s; }
.radio-fortune__flap:has(input:checked) .radio-fortune__ring path { opacity: 1; stroke-dashoffset: 0; transition: opacity 0s var(--done), stroke-dashoffset 0.45s cubic-bezier(0.4, 0, 0.3, 1) var(--done); }
@keyframes radio-fortune-star { 0.0% { translate: 0 0; } 12.5% { translate: 0 calc(var(--sy) * 11px); } 25.0% { translate: 0 0; } 37.5% { translate: calc(var(--sx) * 11px) 0; } 50.0% { translate: 0 0; } 62.5% { translate: 0 calc(var(--sy) * 11px); } 75.0% { translate: 0 0; } 87.5% { translate: calc(var(--sx) * 11px) 0; } 100% { translate: 0 0; } }
@keyframes radio-fortune-star-hand { 0.0% { transform: none; } 12.5% { transform: rotate(2.5deg) scale(0.97); } 25.0% { transform: none; } 37.5% { transform: rotate(-2.5deg) scale(0.97); } 50.0% { transform: none; } 62.5% { transform: rotate(2.5deg) scale(0.97); } 75.0% { transform: none; } 87.5% { transform: rotate(-2.5deg) scale(0.97); } 100% { transform: none; } }
@keyframes radio-fortune-moon { 0.0% { translate: 0 0; } 12.5% { translate: 0 calc(var(--sy) * 11px); } 25.0% { translate: 0 0; } 37.5% { translate: calc(var(--sx) * 11px) 0; } 50.0% { translate: 0 0; } 62.5% { translate: 0 calc(var(--sy) * 11px); } 75.0% { translate: 0 0; } 87.5% { translate: calc(var(--sx) * 11px) 0; } 100% { translate: 0 0; } }
@keyframes radio-fortune-moon-hand { 0.0% { transform: none; } 12.5% { transform: rotate(2.5deg) scale(0.97); } 25.0% { transform: none; } 37.5% { transform: rotate(-2.5deg) scale(0.97); } 50.0% { transform: none; } 62.5% { transform: rotate(2.5deg) scale(0.97); } 75.0% { transform: none; } 87.5% { transform: rotate(-2.5deg) scale(0.97); } 100% { transform: none; } }
@keyframes radio-fortune-sun { 0.0% { translate: 0 0; } 16.7% { translate: 0 calc(var(--sy) * 11px); } 33.3% { translate: 0 0; } 50.0% { translate: calc(var(--sx) * 11px) 0; } 66.7% { translate: 0 0; } 83.3% { translate: 0 calc(var(--sy) * 11px); } 100% { translate: 0 0; } }
@keyframes radio-fortune-sun-hand { 0.0% { transform: none; } 16.7% { transform: rotate(2.5deg) scale(0.97); } 33.3% { transform: none; } 50.0% { transform: rotate(-2.5deg) scale(0.97); } 66.7% { transform: none; } 83.3% { transform: rotate(2.5deg) scale(0.97); } 100% { transform: none; } }
@keyframes radio-fortune-cloud { 0.0% { translate: 0 0; } 10.0% { translate: 0 calc(var(--sy) * 11px); } 20.0% { translate: 0 0; } 30.0% { translate: calc(var(--sx) * 11px) 0; } 40.0% { translate: 0 0; } 50.0% { translate: 0 calc(var(--sy) * 11px); } 60.0% { translate: 0 0; } 70.0% { translate: calc(var(--sx) * 11px) 0; } 80.0% { translate: 0 0; } 90.0% { translate: 0 calc(var(--sy) * 11px); } 100% { translate: 0 0; } }
@keyframes radio-fortune-cloud-hand { 0.0% { transform: none; } 10.0% { transform: rotate(2.5deg) scale(0.97); } 20.0% { transform: none; } 30.0% { transform: rotate(-2.5deg) scale(0.97); } 40.0% { transform: none; } 50.0% { transform: rotate(2.5deg) scale(0.97); } 60.0% { transform: none; } 70.0% { transform: rotate(-2.5deg) scale(0.97); } 80.0% { transform: none; } 90.0% { transform: rotate(2.5deg) scale(0.97); } 100% { transform: none; } }
.radio-fortune:has(.radio-fortune__flap:nth-child(2) input:checked) .radio-fortune__flap { animation: radio-fortune-star 1.20s cubic-bezier(0.45, 0, 0.55, 1); }
.radio-fortune:has(.radio-fortune__flap:nth-child(2) input:checked) .radio-fortune__teller { animation: radio-fortune-star-hand 1.20s cubic-bezier(0.45, 0, 0.55, 1); }
.radio-fortune:has(.radio-fortune__flap:nth-child(3) input:checked) .radio-fortune__flap { animation: radio-fortune-moon 1.20s cubic-bezier(0.45, 0, 0.55, 1); }
.radio-fortune:has(.radio-fortune__flap:nth-child(3) input:checked) .radio-fortune__teller { animation: radio-fortune-moon-hand 1.20s cubic-bezier(0.45, 0, 0.55, 1); }
.radio-fortune:has(.radio-fortune__flap:nth-child(4) input:checked) .radio-fortune__flap { animation: radio-fortune-sun 0.90s cubic-bezier(0.45, 0, 0.55, 1); }
.radio-fortune:has(.radio-fortune__flap:nth-child(4) input:checked) .radio-fortune__teller { animation: radio-fortune-sun-hand 0.90s cubic-bezier(0.45, 0, 0.55, 1); }
.radio-fortune:has(.radio-fortune__flap:nth-child(5) input:checked) .radio-fortune__flap { animation: radio-fortune-cloud 1.50s cubic-bezier(0.45, 0, 0.55, 1); }
.radio-fortune:has(.radio-fortune__flap:nth-child(5) input:checked) .radio-fortune__teller { animation: radio-fortune-cloud-hand 1.50s cubic-bezier(0.45, 0, 0.55, 1); }
.radio-fortune__slip { display: grid; min-width: 196px; padding: 7px 14px 6px; background: var(--paper); color: var(--ink); font-size: 16px; font-weight: 700; text-align: center; box-shadow: 0 3px 6px light-dark(rgba(0, 0, 0, 0.07), rgba(0, 0, 0, 0.24)); transform: rotate(-1.2deg); }
.radio-fortune__slip > span { grid-area: 1 / 1; opacity: 0; clip-path: inset(0 100% 0 0); }
.radio-fortune:has(.radio-fortune__flap:nth-child(2) input:checked) .radio-fortune__slip > :nth-child(1) { opacity: 1; clip-path: inset(0 0 0 0); transition: clip-path 0.7s steps(14) var(--done); }
.radio-fortune:has(.radio-fortune__flap:nth-child(3) input:checked) .radio-fortune__slip > :nth-child(2) { opacity: 1; clip-path: inset(0 0 0 0); transition: clip-path 0.7s steps(14) var(--done); }
.radio-fortune:has(.radio-fortune__flap:nth-child(4) input:checked) .radio-fortune__slip > :nth-child(3) { opacity: 1; clip-path: inset(0 0 0 0); transition: clip-path 0.7s steps(14) var(--done); }
.radio-fortune:has(.radio-fortune__flap:nth-child(5) input:checked) .radio-fortune__slip > :nth-child(4) { opacity: 1; clip-path: inset(0 0 0 0); transition: clip-path 0.7s steps(14) var(--done); }
@media (prefers-reduced-motion: reduce) {
.radio-fortune, .radio-fortune * { animation: none !important; transition: none !important; }
}Goal: Create a "Fortune" radio.
Visual behavior: A paper fortune teller. Pick a sign and it pinches open and shut once for each letter of it, the way the playground game counts, then a pencil rings the sign and your fortune is written out on a slip.
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 four folded squares are the options. One keyframe set per sign, generated from its letter count, opens the squares up and down, then left and right, in turn; each square reads its direction from two variables (--sx, --sy), so one keyframe moves all four, and a second one tilts the whole teller like a hand working it.
- Each square is two shades split on its diagonal crease, which reads as a folded peak, and the inside with its numbers shows through the gap whenever it opens.
- Once the count is over a hand-drawn ring draws round the sign by its dash offset and the fortune is revealed with a stepped clip-path wipe, both delayed by the length of the count.
Accessibility: A role="radiogroup" labelled "Pick a sign" around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once. Each square is a <label> named by its sign, and focus outlines the square. The numbers inside and the fortune slip are aria-hidden. Under prefers-reduced-motion nothing moves and the ring and fortune show 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 "Fortune" radio.
Visual behavior: A paper fortune teller. Pick a sign and it pinches open and shut once for each letter of it, the way the playground game counts, then a pencil rings the sign and your fortune is written out on a slip.
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 four folded squares are the options. One keyframe set per sign, generated from its letter count, opens the squares up and down, then left and right, in turn; each square reads its direction from two variables (--sx, --sy), so one keyframe moves all four, and a second one tilts the whole teller like a hand working it.
- Each square is two shades split on its diagonal crease, which reads as a folded peak, and the inside with its numbers shows through the gap whenever it opens.
- Once the count is over a hand-drawn ring draws round the sign by its dash offset and the fortune is revealed with a stepped clip-path wipe, both delayed by the length of the count.
Accessibility: A role="radiogroup" labelled "Pick a sign" around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once. Each square is a <label> named by its sign, and focus outlines the square. The numbers inside and the fortune slip are aria-hidden. Under prefers-reduced-motion nothing moves and the ring and fortune show 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 paper fortune teller. Pick a sign and it pinches open and shut once for each letter of it, the way the playground game counts, then a pencil rings the sign and your fortune is written out on a slip.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, state change, keyframe, draw
- Techniques
- keyframes, css variables, stroke dashoffset, clip path, svg
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A role="radiogroup" labelled "Pick a sign" around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once. Each square is a <label> named by its sign, and focus outlines the square. The numbers inside and the fortune slip are aria-hidden. Under prefers-reduced-motion nothing moves and the ring and fortune show at once.