Fairground

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

// The smaller the duck, the harder the shot.
const LEVELS = [
  { value: 'easy', label: 'Easy', x: 40, w: 62 },
  { value: 'normal', label: 'Normal', x: 102, w: 50 },
  { value: 'hard', label: 'Hard', x: 160, w: 39 },
  { value: 'expert', label: 'Expert', x: 218, w: 29 },
];
const DUCK = 'M4 28C4 21 10 19 18 20C24 21 28 22 30 20C27 17 28 8 36 7C42 7 44 11 43 14L48 15.5L43 17C42 19 40 21 38 22C42 24 43 28 42 31C40 36 34 38 24 38H13C7 38 4 34 4 28ZM5 25L1 17L11 21Z';

const css = `
  .radio-fairground { --tin: light-dark(#6d7179, #c6c8cd); --tin-hi: light-dark(#dbdde1, #f1f2f3); --tin-back: light-dark(#50535b, #7d8087); --wall: light-dark(#dbdde1, #1a1c1f); --bulb: light-dark(#f3f4f7, #f1f2f3); position: relative; width: 256px; height: 214px; cursor: crosshair; font-family: Rockwell, 'Roboto Slab', 'Courier New', serif; }
  .radio-fairground__sign { position: absolute; left: 50%; top: 0; z-index: 2; translate: -50% 0; padding: 6px 14px; border-radius: 3px; background: light-dark(#f3f4f7, #1a1c1f); color: light-dark(#373a40, #f1f2f3); font-size: 12px; font-weight: 800; letter-spacing: 0.14em; white-space: nowrap; box-shadow: 0 0 0 2px light-dark(#cbced3, #393c41), 0 4px 10px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4)); }
  /* Chase lights: two sets of bulbs round the sign, blinking in turn. */
  .radio-fairground__sign::before, .radio-fairground__sign::after { content: ''; position: absolute; inset: 2px; border-radius: 2px; background: radial-gradient(circle, var(--bulb) 1.1px, transparent 1.6px) 0 0 / 8px 8px; mask: linear-gradient(#000 0 0) top / 100% 3px no-repeat, linear-gradient(#000 0 0) bottom / 100% 3px no-repeat; animation: radio-fairground-chase 0.9s steps(1) infinite; }
  .radio-fairground__sign::after { background-position: 4px 0; animation-delay: -0.45s; }
  @keyframes radio-fairground-chase { 50% { opacity: 0.15; } }
  .radio-fairground__awning { position: absolute; left: 0; right: 0; top: 14px; height: 30px; background: repeating-linear-gradient(90deg, light-dark(#373a40, #f1f2f3) 0 16px, light-dark(#cbced3, #393c41) 16px 32px); box-shadow: inset 0 8px 6px -5px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.35)); mask: radial-gradient(circle 8px at 8px 22px, #000 98%, transparent) 0 0 / 16px 100% repeat-x, linear-gradient(#000 0 0) top / 100% 22px no-repeat; filter: drop-shadow(0 4px 3px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4))); }
  .radio-fairground__wall { position: absolute; left: 4px; right: 4px; top: 36px; bottom: 26px; background: radial-gradient(circle, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 16%, transparent) 1px, transparent 1.6px) 6px 4px / 20px 20px, repeating-linear-gradient(90deg, transparent 0 31px, color-mix(in oklab, light-dark(#fbfcfd, #121417) 22%, transparent) 31px 32px), var(--wall); box-shadow: inset 0 14px 16px -8px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6)); }
  .radio-fairground__rod { position: absolute; left: 0; right: 0; top: 12px; height: 4px; border-radius: 2px; background: light-dark(#6d7179, #7d8087); }
  .radio-fairground__rail { position: absolute; left: 4px; right: 4px; bottom: 50px; height: 5px; border-radius: 2px; background: linear-gradient(var(--tin-hi), var(--tin)); box-shadow: 0 2px 3px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4)); }
  .radio-fairground__counter { position: absolute; left: 0; right: 0; bottom: 0; height: 28px; border-radius: 3px; background: linear-gradient(light-dark(#dbdde1, #2e3035), light-dark(#ebedf0, #232529)); box-shadow: inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 20%, transparent), 0 -2px 4px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3)); }
  .radio-fairground__lane { position: absolute; bottom: 0; top: 70px; width: 58px; translate: -50% 0; cursor: crosshair; }
  .radio-fairground__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
  .radio-fairground__label { position: absolute; left: 0; right: 0; bottom: 8px; color: light-dark(#5f626a, #a7abb2); font-size: 9.5px; font-weight: 800; letter-spacing: 0.12em; text-align: center; text-transform: uppercase; transition: color 0.25s; }
  .radio-fairground__lane:hover .radio-fairground__label { color: light-dark(#42454c, #e5e6e9); }
  .radio-fairground__lane:has(input:checked) .radio-fairground__label { color: light-dark(#373a40, #f1f2f3); }
  .radio-fairground__lane:has(input:focus-visible) .radio-fairground__label { outline: 1.5px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; border-radius: 2px; }
  /* A target on its post: tin front, painted back, spun round by the hit. */
  .radio-fairground__target { position: absolute; left: 50%; bottom: 56px; width: var(--w); translate: -50% 0; perspective: 300px; animation: radio-fairground-rock 2.8s ease-in-out infinite; transform-origin: 50% 100%; }
  .radio-fairground__lane:nth-of-type(2) .radio-fairground__target { animation-delay: -0.7s; }
  .radio-fairground__lane:nth-of-type(3) .radio-fairground__target { animation-delay: -1.4s; }
  .radio-fairground__lane:nth-of-type(4) .radio-fairground__target { animation-delay: -2.1s; }
  @keyframes radio-fairground-rock { 50% { rotate: 3deg; } }
  .radio-fairground__post { position: absolute; left: 50%; bottom: -6px; width: 3px; height: 10px; margin-left: -1.5px; background: var(--tin); }
  .radio-fairground__spin { position: relative; display: block; aspect-ratio: 48 / 40; transform-style: preserve-3d; transition: transform 0.95s cubic-bezier(0.3, 0, 0.3, 1); }
  .radio-fairground__lane:has(input:checked) .radio-fairground__spin { transform: rotateY(900deg); transition: transform 1.3s cubic-bezier(0.12, 0.75, 0.25, 1); }
  .radio-fairground__face { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; backface-visibility: hidden; }
  .radio-fairground__face--back { transform: rotateY(180deg); }
  .radio-fairground__face path { fill: var(--tin); stroke: var(--tin-hi); stroke-width: 1; }
  .radio-fairground__face--back path { fill: var(--tin-back); }
  .radio-fairground__face circle { fill: none; stroke: var(--wall); stroke-width: 1.6; }
  .radio-fairground__face .radio-fairground__eye { fill: var(--wall); stroke: none; }
  .radio-fairground__star { fill: var(--tin-hi) !important; stroke: none !important; }
  /* The hit: a burst of sparks and a PING. */
  .radio-fairground__burst { position: absolute; left: 50%; top: 40%; width: 0; height: 0; pointer-events: none; }
  .radio-fairground__burst i { position: absolute; left: -1px; top: -12px; width: 2px; height: 7px; border-radius: 1px; background: light-dark(#373a40, #f1f2f3); opacity: 0; transform-origin: 1px 12px; rotate: calc(var(--k) * 60deg); }
  .radio-fairground__lane:has(input:checked) .radio-fairground__burst i { animation: radio-fairground-spark 0.45s ease-out; }
  @keyframes radio-fairground-spark { 0% { opacity: 1; transform: translateY(6px) scaleY(0.4); } 100% { opacity: 0; transform: translateY(-8px) scaleY(1); } }
  .radio-fairground__ping { position: absolute; left: 50%; top: -20px; color: light-dark(#373a40, #f1f2f3); font-size: 10px; font-weight: 800; letter-spacing: 0.08em; opacity: 0; translate: -50% 0; pointer-events: none; }
  .radio-fairground__lane:has(input:checked) .radio-fairground__ping { animation: radio-fairground-ping 0.8s cubic-bezier(0.2, 0.9, 0.3, 1); }
  @keyframes radio-fairground-ping { 0% { opacity: 1; transform: scale(0.5); } 60% { opacity: 1; transform: translateY(-6px) scale(1.1); } 100% { opacity: 0; transform: translateY(-10px); } }
  @media (prefers-reduced-motion: reduce) {
    .radio-fairground *, .radio-fairground *::before, .radio-fairground *::after { animation: none !important; transition: none !important; }
  }
`;

function Duck({ back }: { back?: boolean }) {
  return (
    <svg className={`radio-fairground__face${back ? ' radio-fairground__face--back' : ''}`} viewBox="0 0 48 40" aria-hidden="true">
      <path d={DUCK} />
      {back ? (
        <path className="radio-fairground__star" d="M24 21.5l2.1 4.3 4.7.6-3.4 3.3.8 4.6-4.2-2.2-4.2 2.2.8-4.6-3.4-3.3 4.7-.6z" />
      ) : (
        <>
          <circle cx={19} cy={29} r={5} />
          <circle cx={19} cy={29} r={1.4} />
          <circle className="radio-fairground__eye" cx={37} cy={12} r={1.3} />
        </>
      )}
    </svg>
  );
}

export function Preview() {
  const name = useId();
  return (
    <>
      <style href="kit-radios-fairground" precedence="kit">
        {css}
      </style>
      <div className="radio-fairground" role="radiogroup" aria-label="Difficulty">
        <span className="radio-fairground__wall" aria-hidden="true" />
        <span className="radio-fairground__awning" aria-hidden="true" />
        <span className="radio-fairground__rod" aria-hidden="true" />
        <span className="radio-fairground__sign" aria-hidden="true">
          PICK A LEVEL
        </span>
        <span className="radio-fairground__rail" aria-hidden="true" />
        <span className="radio-fairground__counter" aria-hidden="true" />
        {LEVELS.map((l, i) => (
          <label key={l.value} className="radio-fairground__lane" style={{ left: l.x, '--w': `${l.w}px` } as CSSProperties}>
            <input className="radio-fairground__input" type="radio" name={name} value={l.value} defaultChecked={i === 1} />
            <span className="radio-fairground__target" aria-hidden="true">
              <span className="radio-fairground__post" />
              <span className="radio-fairground__spin">
                <Duck />
                <Duck back />
              </span>
              <span className="radio-fairground__burst">
                {[0, 1, 2, 3, 4, 5].map((k) => (
                  <i key={k} style={{ '--k': k } as CSSProperties} />
                ))}
              </span>
              <span className="radio-fairground__ping">PING</span>
            </span>
            <span className="radio-fairground__label">{l.label}</span>
          </label>
        ))}
      </div>
    </>
  );
}

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

Install with shadcn

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

About this component

A shooting gallery difficulty picker where the smaller the duck, the harder the shot. Hit a tin target and it spins round on its post with sparks and a PING to show its star, while the last one spins back.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, 3d, keyframe
Techniques
preserve 3d, 3d transform, css transition, keyframes, radial gradient
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A role="radiogroup" labelled "Difficulty" around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once. The level names on the counter are the visible labels and focus outlines them; the ducks are aria-hidden. Under prefers-reduced-motion the targets turn at once and the lights stop.

More radios

See all
Fortune
Glassware
Multitool
Paper Stock