Spray

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

const css = `
  @keyframes cb-spray-can { 0% { offset-distance: 0%; opacity: 0; } 8% { opacity: 1; } 85% { offset-distance: 100%; opacity: 1; } 100% { offset-distance: 100%; opacity: 0; } }
  @keyframes cb-spray-mist { 0% { opacity: 0.8; transform: translate(0, 0) scale(0.4); } 100% { opacity: 0; transform: translate(var(--x), var(--y)) scale(1.2); } }
  .cb-spray { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
  .cb-spray__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
  .cb-spray__art { position: relative; display: block; width: 88px; height: 58px; border-radius: 8px; }
  .cb-spray__input:focus-visible + .cb-spray__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
  .cb-spray__wall { position: absolute; inset: 0; width: 88px; height: 58px; overflow: visible; }
  .cb-spray__brick { fill: light-dark(#ebedf0, #232529); }
  .cb-spray__mortar { fill: none; stroke: light-dark(#f3f4f7, #1a1c1f); stroke-width: 1.2; }
  .cb-spray__haze { fill: none; stroke: light-dark(#42454c, #e5e6e9); stroke-width: 11; stroke-linecap: round; stroke-linejoin: round; opacity: 0.18; filter: blur(2px); }
  .cb-spray__paint { fill: none; stroke: light-dark(#42454c, #e5e6e9); stroke-width: 5; stroke-linecap: round; stroke-linejoin: round; }
  .cb-spray__haze, .cb-spray__paint { stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 0.2s, opacity 0.2s; }
  .cb-spray__drip { fill: none; stroke: light-dark(#42454c, #e5e6e9); stroke-width: 1.8; stroke-linecap: round; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 0.2s; }
  .cb-spray__drop { fill: light-dark(#42454c, #e5e6e9); opacity: 0; transition: opacity 0.2s; }
  .cb-spray__input:checked + .cb-spray__art .cb-spray__paint { stroke-dashoffset: 0; transition: stroke-dashoffset 0.7s cubic-bezier(0.4, 0, 0.6, 1); }
  .cb-spray__input:checked + .cb-spray__art .cb-spray__haze { stroke-dashoffset: 0; transition: stroke-dashoffset 0.7s cubic-bezier(0.4, 0, 0.6, 1); }
  /* A beat after the paint goes on, the heaviest corner lets a drip run down. */
  .cb-spray__input:checked + .cb-spray__art .cb-spray__drip { stroke-dashoffset: 0; transition: stroke-dashoffset 0.9s cubic-bezier(0.3, 0, 0.7, 1) 0.9s; }
  .cb-spray__input:checked + .cb-spray__art .cb-spray__drop { opacity: 1; transition-delay: 1.6s; }
  /* The can follows the tick as it paints it, puffing mist at the wall. */
  .cb-spray__can { position: absolute; left: 0; top: 0; width: 10px; height: 18px; opacity: 0; offset-path: path('M18 30L32 43L64 14'); offset-rotate: 0deg; offset-anchor: 50% 150%; }
  .cb-spray__can::before { content: ''; position: absolute; inset: 4px 0 0; border-radius: 3px; background: light-dark(#6d7179, #7d8087); box-shadow: inset -3px 0 0 light-dark(#cbced3, #393c41); }
  .cb-spray__can::after { content: ''; position: absolute; left: 3px; top: 0; width: 4px; height: 4px; border-radius: 1px; background: light-dark(#50535b, #c6c8cd); }
  .cb-spray__puff { position: absolute; left: 4px; top: 20px; width: 4px; height: 4px; border-radius: 50%; background: light-dark(#42454c, #e5e6e9); opacity: 0; }
  .cb-spray[data-armed] .cb-spray__input:checked + .cb-spray__art .cb-spray__can { animation: cb-spray-can 0.85s cubic-bezier(0.4, 0, 0.6, 1); }
  .cb-spray[data-armed] .cb-spray__input:checked + .cb-spray__art .cb-spray__puff { animation: cb-spray-mist 0.25s ease-out var(--d) 3; }
  @media (prefers-reduced-motion: reduce) {
    .cb-spray__haze, .cb-spray__paint, .cb-spray__drip, .cb-spray__drop,
    .cb-spray__input:checked + .cb-spray__art .cb-spray__paint, .cb-spray__input:checked + .cb-spray__art .cb-spray__haze,
    .cb-spray__input:checked + .cb-spray__art .cb-spray__drip, .cb-spray__input:checked + .cb-spray__art .cb-spray__drop { transition: none; }
    .cb-spray[data-armed] .cb-spray__input:checked + .cb-spray__art .cb-spray__can, .cb-spray[data-armed] .cb-spray__input:checked + .cb-spray__art .cb-spray__puff { animation: none; }
  }
`;

const TICK = 'M18 30L32 43L64 14';
const PUFFS = [
  { d: 0, x: -4, y: 4 },
  { d: 0.08, x: 3, y: 6 },
  { d: 0.16, x: -1, y: 8 },
];

export function SprayCheckbox() {
  // Set on the first change, so the can only sweeps in when someone ticks it.
  const [armed, setArmed] = useState(false);
  return (
    <>
      <style>
        {css}
      </style>
      <label className="cb-spray" data-armed={armed || undefined}>
        <input className="cb-spray__input" type="checkbox" onChange={() => setArmed(true)} />
        <span className="cb-spray__art" aria-hidden="true">
          <svg className="cb-spray__wall" viewBox="0 0 88 58">
            <rect className="cb-spray__brick" x="0" y="0" width="88" height="58" rx="8" />
            <path className="cb-spray__mortar" d="M0 14.5H88M0 29H88M0 43.5H88M22 0V14.5M66 0V14.5M44 14.5V29M11 29V43.5M55 29V43.5M33 43.5V58M77 43.5V58" />
            <path className="cb-spray__haze" pathLength={1} d={TICK} />
            <path className="cb-spray__drip" pathLength={1} d="M32.5 45V51" />
            <circle className="cb-spray__drop" cx="32.5" cy="52.5" r="1.5" />
            <path className="cb-spray__paint" pathLength={1} d={TICK} />
          </svg>
          <span className="cb-spray__can">
            {PUFFS.map((p) => (
              <span key={p.d} className="cb-spray__puff" style={{ '--d': `${p.d}s`, '--x': `${p.x}px`, '--y': `${p.y}px` } as CSSProperties} />
            ))}
          </span>
        </span>
        <span>Tag it</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-spray
Source on GitHub

About this component

A tag it checkbox drawn as spray paint on a brick wall: the can sweeps along the tick, painting it in a soft haze with puffs of mist, then a drip runs down from the heaviest corner.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, draw
Techniques
stroke dashoffset, keyframes, 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 tick appears at once with no can.

More checkboxes

See all
Strap
Stud
Ballot
Bow