Fortune

No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

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

Install with shadcn

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

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.

More radios

See all
Glassware
Multitool
Paper Stock
Pigeonhole