Icing

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

const css = `
  @keyframes cb-icing-bag { 0% { offset-distance: 0%; opacity: 0; } 8% { opacity: 1; } 88% { offset-distance: 100%; opacity: 1; } 100% { offset-distance: 100%; opacity: 0; } }
  .cb-icing { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
  .cb-icing__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
  .cb-icing__art { position: relative; display: block; width: 92px; height: 60px; border-radius: 10px; }
  .cb-icing__input:focus-visible + .cb-icing__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
  .cb-icing__cake { position: absolute; inset: 0; width: 92px; height: 60px; overflow: visible; }
  .cb-icing__top { fill: light-dark(#dbdde1, #2e3035); }
  .cb-icing__edge { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 3; stroke-dasharray: 0.1 6; stroke-linecap: round; }
  .cb-icing__sprinkle { stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.6; stroke-linecap: round; }
  /* Each bead of icing swells out of the nozzle in turn; unticked, they melt back the other way. */
  .cb-icing__bead { transform-box: fill-box; transform-origin: 50% 50%; transform: scale(0); transition: transform 0.12s ease-in calc((13 - var(--i)) * 18ms); }
  .cb-icing__blob { fill: light-dark(#373a40, #f1f2f3); }
  .cb-icing__shine { fill: light-dark(#fbfcfd, #121417); opacity: 0.25; }
  .cb-icing__input:checked + .cb-icing__art .cb-icing__bead { transform: none; transition: transform 0.25s cubic-bezier(0.3, 1.8, 0.5, 1) calc(var(--i) * 55ms); }
  .cb-icing__bag { position: absolute; left: 0; top: 0; width: 14px; height: 22px; opacity: 0; offset-path: path('M20 31L34 44L70 14'); offset-rotate: 0deg; offset-anchor: 50% 100%; }
  .cb-icing__bag::before { content: ''; position: absolute; inset: 0 0 4px; background: light-dark(#5f626a, #a7abb2); clip-path: polygon(0 0, 100% 0, 60% 100%, 40% 100%); border-radius: 4px 4px 0 0; }
  .cb-icing__bag::after { content: ''; position: absolute; left: 5px; bottom: 0; width: 4px; height: 5px; background: light-dark(#50535b, #c6c8cd); clip-path: polygon(0 0, 100% 0, 75% 100%, 25% 100%); }
  .cb-icing[data-armed] .cb-icing__input:checked + .cb-icing__art .cb-icing__bag { animation: cb-icing-bag 0.85s linear; }
  @media (prefers-reduced-motion: reduce) {
    .cb-icing__bead, .cb-icing__input:checked + .cb-icing__art .cb-icing__bead { transition: none; }
    .cb-icing[data-armed] .cb-icing__input:checked + .cb-icing__art .cb-icing__bag { animation: none; }
  }
`;

// Fourteen beads spaced evenly along the tick's two strokes, worked out once.
const PTS = [
  [20, 31],
  [34, 44],
  [70, 14],
];
const SEGS = [Math.hypot(14, 13), Math.hypot(36, 30)];
const BEADS = Array.from({ length: 14 }, (_, i) => {
  let d = (i / 13) * (SEGS[0] + SEGS[1]);
  const k = d > SEGS[0] ? 1 : 0;
  if (k) d -= SEGS[0];
  const [a, b] = [PTS[k], PTS[k + 1]];
  const t = d / SEGS[k];
  return { i, x: a[0] + (b[0] - a[0]) * t, y: a[1] + (b[1] - a[1]) * t };
});

export function IcingCheckbox() {
  // The piping bag only comes out when someone ticks it.
  const [armed, setArmed] = useState(false);
  return (
    <>
      <style>
        {css}
      </style>
      <label className="cb-icing" data-armed={armed || undefined}>
        <input className="cb-icing__input" type="checkbox" onChange={() => setArmed(true)} />
        <span className="cb-icing__art" aria-hidden="true">
          <svg className="cb-icing__cake" viewBox="0 0 92 60">
            <rect className="cb-icing__top" x="0" y="0" width="92" height="60" rx="10" />
            <rect className="cb-icing__edge" x="5" y="5" width="82" height="50" rx="7" />
            <path className="cb-icing__sprinkle" d="M12 14l3 1M78 46l2 2M74 10l3-1M14 48l2-2M52 50l3 0M80 26l1 3" />
            {BEADS.map((b) => (
              <g key={b.i} transform={`translate(${b.x.toFixed(2)} ${b.y.toFixed(2)})`}>
                <g className="cb-icing__bead" style={{ '--i': b.i } as CSSProperties}>
                  <circle className="cb-icing__blob" r="3.6" />
                  <circle className="cb-icing__shine" cx="1" cy="1.2" r="1.6" />
                </g>
              </g>
            ))}
          </svg>
          <span className="cb-icing__bag" />
        </span>
        <span>Ice the cake</span>
      </label>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/checkbox-icing
Source on GitHub

About this component

An ice the cake checkbox: a piping bag moves along the tick and pipes it on bead by bead; unchecking melts the beads back the other way.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, delay stagger
Techniques
stagger, css variables, svg
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A native checkbox inside a <label>, so Space toggles it and the visible label names it; :focus-visible on the input outlines the drawing, which is aria-hidden. Under prefers-reduced-motion the beads appear at once with no bag.

More checkboxes

See all
Peg
Putt
Rake
Skate