Spray
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCbSpray(el) on the mounted element instead.
<label class="cb-spray">
<input class="cb-spray__input" type="checkbox"/>
<span class="cb-spray__art" aria-hidden="true">
<svg class="cb-spray__wall" viewBox="0 0 88 58">
<rect class="cb-spray__brick" x="0" y="0" width="88" height="58" rx="8"></rect>
<path class="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>
<path class="cb-spray__haze" pathLength="1" d="M18 30L32 43L64 14"></path>
<path class="cb-spray__drip" pathLength="1" d="M32.5 45V51"></path>
<circle class="cb-spray__drop" cx="32.5" cy="52.5" r="1.5"></circle>
<path class="cb-spray__paint" pathLength="1" d="M18 30L32 43L64 14"></path>
</svg>
<span class="cb-spray__can">
<span class="cb-spray__puff" style="--d:0s;--x:-4px;--y:4px"></span>
<span class="cb-spray__puff" style="--d:0.08s;--x:3px;--y:6px"></span>
<span class="cb-spray__puff" style="--d:0.16s;--x:-1px;--y:8px"></span>
</span>
</span>
<span>Tag it</span>
</label>@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; }
}function initCbSpray(root) {
// The motion keyframes only run once the control has been used, never on page load.
function onChange() {
root.setAttribute('data-armed', '');
}
root.addEventListener('change', onChange);
return function () {
root.removeEventListener('change', onChange);
};
}
document.querySelectorAll('.cb-spray').forEach(initCbSpray);Goal: Create a "Spray" checkbox.
Visual behavior: 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.
Technique:
- A native checkbox, visually hidden but focusable, sits inside a <label> before an aria-hidden drawing and the visible text; the checked state is styled from :checked through the adjacent sibling combinator.
- The tick is two strokes on the same path, a wide soft haze and the paint, drawn in by their dash offsets; a drip and its drop follow a beat later.
- The can rides the tick on offset-path, puffing mist as it goes. Keyframes that must not play on page load hang off a data-armed attribute set on the first change.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Spray" checkbox.
Visual behavior: 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.
Technique:
- A native checkbox, visually hidden but focusable, sits inside a <label> before an aria-hidden drawing and the visible text; the checked state is styled from :checked through the adjacent sibling combinator.
- The tick is two strokes on the same path, a wide soft haze and the paint, drawn in by their dash offsets; a drip and its drop follow a beat later.
- The can rides the tick on offset-path, puffing mist as it goes. Keyframes that must not play on page load hang off a data-armed attribute set on the first change.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.