Stickers

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

// Six die-cut stickers on a backing sheet. Each is drawn in a 44×44 box; the
// first path is the vinyl (with its white cut margin as a stroke), the rest
// are the printed details.
const STICKERS = [
  { value: 'bolt', label: 'Bolt', d: ['M25 4L9 25H21L17 40L35 17H23Z'] },
  { value: 'heart', label: 'Heart', d: ['M22 38C9 29 5 22 5 15.5C5 10 9 6 14 6C17.5 6 20.5 8 22 11C23.5 8 26.5 6 30 6C35 6 39 10 39 15.5C39 22 35 29 22 38Z', 'M12 13.5C12.6 11.5 14 10.5 15.8 10.3'] },
  { value: 'smile', label: 'Smile', d: ['M22 5A17 17 0 1 1 21.9 5Z', 'M15.5 17V19.5M28.5 17V19.5M14 26C17.5 31 26.5 31 30 26'] },
  { value: 'star', label: 'Star', d: ['M22 4.5L27 15.6L39 16.9L30 25L32.6 37L22 30.9L11.4 37L14 25L5 16.9L17 15.6Z'] },
  { value: 'ghost', label: 'Ghost', d: ['M8 38V19C8 11 14 5.5 22 5.5C30 5.5 36 11 36 19V38L31.3 34L26.6 38L22 34L17.4 38L12.7 34Z', 'M17 17.5V20.5M27 17.5V20.5'] },
  { value: 'flower', label: 'Flower', d: ['M22 7.5A6.5 6.5 0 0 1 28.1 16.3A6.5 6.5 0 0 1 32.3 27.5A6.5 6.5 0 0 1 22 33.8A6.5 6.5 0 0 1 11.7 27.5A6.5 6.5 0 0 1 15.9 16.3A6.5 6.5 0 0 1 22 7.5Z', 'M22 18.5A3.2 3.2 0 1 1 21.9 18.5Z'] },
];
// Sheet slots (centres) and the spot on the laptop lid they land on.
const SLOT = STICKERS.map((_, i) => ({ x: 158 + (i % 2) * 48, y: 42 + Math.floor(i / 2) * 47 }));
const LID = { x: 58, y: 84 };
const TILT = [-9, 7, -5, 10, -7, 6];
const at = (n: number) => `.radio-stickers:has(.radio-stickers__slot:nth-child(${n + 1}) input:checked)`;

const css = `
  .radio-stickers { --vinyl: light-dark(#373a40, #232529); --cut: light-dark(#fbfcfd, #f1f2f3); --sheet: light-dark(#ebedf0, #c6c8cd); --lid: light-dark(#dbdde1, #2e3035); position: relative; width: 254px; height: 196px; perspective: 600px; }
  .radio-stickers__laptop { position: absolute; left: 2px; top: 38px; width: 112px; height: 80px; border-radius: 9px; background: radial-gradient(130% 90% at 30% 10%, color-mix(in oklab, var(--lid) 80%, light-dark(#373a40, #f1f2f3)), var(--lid) 60%); box-shadow: inset 0 0 0 1px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 12%, transparent), 0 12px 18px -8px light-dark(rgba(0, 0, 0, 0.17), rgba(0, 0, 0, 0.55)); }
  .radio-stickers__laptop::after { content: ''; position: absolute; left: 50%; bottom: -6px; width: 124px; height: 6px; translate: -50% 0; border-radius: 0 0 6px 6px; background: color-mix(in oklab, var(--lid) 80%, light-dark(#fbfcfd, #121417)); }
  .radio-stickers__read { position: absolute; left: 2px; top: 136px; width: 112px; display: grid; color: light-dark(#373a40, #f1f2f3); font: 900 13px/1 ui-rounded, 'SF Pro Rounded', 'Arial Rounded MT Bold', system-ui, sans-serif; letter-spacing: 0.04em; text-align: center; text-transform: uppercase; }
  .radio-stickers__read > span { grid-area: 1 / 1; opacity: 0; transform: translateY(4px); transition: opacity 0.2s, transform 0.3s; }
  .radio-stickers__read small { display: block; margin-top: 4px; color: light-dark(#6d7179, #7d8087); font-size: 9px; font-weight: 600; letter-spacing: 0.06em; text-transform: none; }
  ${STICKERS.map((_, i) => `${at(i)} .radio-stickers__read > :nth-child(${i + 1})`).join(', ')} { opacity: 1; transform: none; transition-delay: 0.55s; }
  .radio-stickers__sheet { position: absolute; left: 128px; top: 12px; width: 108px; height: 150px; border-radius: 6px; background: linear-gradient(155deg, color-mix(in oklab, var(--sheet) 80%, light-dark(#fbfcfd, #121417)), var(--sheet) 40%); box-shadow: 0 1px 2px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.25)), 0 10px 18px -8px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5)); transform: rotate(1.2deg); }
  .radio-stickers__slot { position: absolute; width: 44px; height: 44px; cursor: pointer; }
  .radio-stickers__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
  .radio-stickers__ghost { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; fill: none; stroke: color-mix(in oklab, var(--sheet) 60%, light-dark(#fbfcfd, #121417)); stroke-width: 1; stroke-dasharray: 2 2; }
  /* The sticker: peeled by a corner, flown over, slapped down on the lid. */
  .radio-stickers__sticker { position: absolute; inset: 0; transform-origin: 70% 70%; transition: transform 0.55s cubic-bezier(0.45, 0, 0.2, 1); filter: drop-shadow(0 1px 1px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35))); }
  .radio-stickers__sticker svg { display: block; width: 100%; height: 100%; overflow: visible; }
  .radio-stickers__sticker path:first-child { fill: var(--vinyl); stroke: var(--cut); stroke-width: 4.5; stroke-linejoin: round; paint-order: stroke; }
  .radio-stickers__sticker path + path { fill: none; stroke: var(--cut); stroke-width: 2.6; stroke-linecap: round; }
  .radio-stickers__slot:hover .radio-stickers__sticker { transform: rotate(-4deg) scale(1.06); }
  .radio-stickers__slot:has(input:focus-visible) .radio-stickers__ghost { stroke: light-dark(#373a40, #f1f2f3); stroke-dasharray: none; stroke-width: 2; }
  .radio-stickers__slot:has(input:checked) { z-index: 2; }
  .radio-stickers__slot:has(input:checked) .radio-stickers__sticker { transform: translate(var(--tx), var(--ty)) rotate(var(--rot)) scale(1.32); transition: none; animation: radio-stickers-peel 0.85s cubic-bezier(0.4, 0, 0.2, 1); }
  @keyframes radio-stickers-peel {
    0% { transform: none; }
    22% { transform: translate(-3px, -5px) rotate3d(1, -1, 0, 38deg) scale(1.06); filter: drop-shadow(0 6px 5px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3))); }
    68% { transform: translate(calc(var(--tx) + 4px), calc(var(--ty) - 16px)) rotate(var(--rot)) scale(1.48); filter: drop-shadow(0 16px 12px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3))); }
    86% { transform: translate(var(--tx), var(--ty)) rotate(var(--rot)) scale(1.24); }
    100% { transform: translate(var(--tx), var(--ty)) rotate(var(--rot)) scale(1.32); }
  }
  .radio-stickers__slot:has(input:checked) .radio-stickers__shine { animation: radio-stickers-shine 0.6s ease-out 0.8s; }
  .radio-stickers__shine { position: absolute; inset: 0; border-radius: 50%; background: linear-gradient(115deg, transparent 35%, color-mix(in oklab, var(--cut) 55%, transparent) 50%, transparent 65%) -60px 0 / 200% 100% no-repeat; opacity: 0; pointer-events: none; mix-blend-mode: overlay; }
  @keyframes radio-stickers-shine { 0% { opacity: 1; background-position: -60px 0; } 100% { opacity: 1; background-position: 60px 0; } }
  @media (prefers-reduced-motion: reduce) {
    .radio-stickers * { animation: none !important; transition: none !important; }
  }
`;

export function Preview() {
  const name = useId();
  return (
    <>
      <style href="kit-radios-stickers" precedence="kit">
        {css}
      </style>
      <div className="radio-stickers">
        <span className="radio-stickers__laptop" aria-hidden="true" />
        <span className="radio-stickers__sheet" aria-hidden="true" />
        <p className="radio-stickers__read" aria-hidden="true">
          {STICKERS.map((s) => (
            <span key={s.value}>
              {s.label}
              <small>on your laptop</small>
            </span>
          ))}
        </p>
        <div role="radiogroup" aria-label="Badge">
          {STICKERS.map((s, i) => (
            <label
              key={s.value}
              className="radio-stickers__slot"
              style={{ left: SLOT[i].x - 22, top: SLOT[i].y - 22, '--tx': `${LID.x - SLOT[i].x}px`, '--ty': `${LID.y - SLOT[i].y}px`, '--rot': `${TILT[i]}deg` } as CSSProperties}
            >
              <input className="radio-stickers__input" type="radio" name={name} value={s.value} aria-label={s.label} defaultChecked={i === 4} />
              <svg className="radio-stickers__ghost" viewBox="0 0 44 44" aria-hidden="true">
                <path d={s.d[0]} />
              </svg>
              <span className="radio-stickers__sticker" aria-hidden="true">
                <svg viewBox="0 0 44 44">
                  {s.d.map((p, k) => (
                    <path key={k} d={p} />
                  ))}
                </svg>
                <span className="radio-stickers__shine" />
              </span>
            </label>
          ))}
        </div>
      </div>
    </>
  );
}

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

Install with shadcn

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

About this component

Pick a badge from a sticker sheet: it peels up by its corner, flies over and slaps onto the laptop with a glint, and the old one goes back to its spot on the sheet.

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

A role="radiogroup" labelled "Badge" around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once. Each input is named by its sticker and focus outlines the empty slot; the name under the laptop is aria-hidden. Under prefers-reduced-motion the sticker moves at once.

More radios

See all
Strings
Units
Egg
Elevation