Chalk
import { useId, useState } from 'react';
const DUST = [
{ x: 10, d: 0.3 },
{ x: 16, d: 0.42 },
{ x: 22, d: 0.36 },
{ x: 27, d: 0.5 },
];
const css = `
@keyframes cb-chalk-dust { from { transform: translateY(0); opacity: 0.9; } to { transform: translateY(16px); opacity: 0; } }
.cb-chalk { position: relative; display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 14px; cursor: pointer; user-select: none; }
.cb-chalk__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.cb-chalk__box { position: relative; width: 34px; height: 34px; }
.cb-chalk__input:focus-visible + .cb-chalk__box { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; border-radius: 6px; }
.cb-chalk__box svg { width: 100%; height: 100%; overflow: visible; }
.cb-chalk__frame { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 2; stroke-linecap: round; }
.cb-chalk__tick { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 3.2; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 0.2s; }
.cb-chalk__input:checked + .cb-chalk__box .cb-chalk__tick { stroke-dashoffset: 0; transition: stroke-dashoffset 0.45s cubic-bezier(0.6, 0, 0.3, 1); }
.cb-chalk__dust { position: absolute; top: 24px; width: 2px; height: 2px; border-radius: 50%; background: light-dark(#50535b, #c6c8cd); opacity: 0; }
.cb-chalk[data-armed] .cb-chalk__input:checked + .cb-chalk__box .cb-chalk__dust { animation: cb-chalk-dust 0.8s ease-in var(--d) both; }
.cb-chalk__text { font-style: italic; letter-spacing: 0.01em; }
@media (prefers-reduced-motion: reduce) {
.cb-chalk__tick { transition-duration: 0.01s !important; }
.cb-chalk__dust { animation: none !important; }
}
`;
export function ChalkCheckbox() {
const [armed, setArmed] = useState(false);
const grain = `cb-chalk-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
return (
<>
<style>
{css}
</style>
<label className="cb-chalk" data-armed={armed || undefined}>
<input className="cb-chalk__input" type="checkbox" onChange={() => setArmed(true)} />
<span className="cb-chalk__box" aria-hidden="true">
<svg viewBox="0 0 34 34">
<defs>
{/* Chalk: speckled holes eaten out of the stroke and a rough, pushed-about edge. */}
<filter id={grain} x="-10%" y="-10%" width="120%" height="120%">
<feTurbulence type="fractalNoise" baseFrequency="1.6" numOctaves="1" seed="3" result="n" />
<feDisplacementMap in="SourceGraphic" in2="n" scale="2.2" result="rough" />
<feColorMatrix in="n" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -2.4 1.9" result="holes" />
<feComposite in="rough" in2="holes" operator="in" />
</filter>
</defs>
<g filter={`url(#${grain})`}>
<path className="cb-chalk__frame" d="M5 6C12 4.5 22 5 29 5.5C29.5 13 29 21 28.6 29C20 29.4 12 29 5.4 28.6C4.8 21 5 13 5 6Z" />
<path className="cb-chalk__tick" d="M9 17.5C11 19.5 13 22 14.5 24C18 17 22.5 11 29 6" pathLength={1} />
</g>
</svg>
{DUST.map((p) => (
<span key={p.x} className="cb-chalk__dust" style={{ left: p.x, '--d': `${p.d}s` } as React.CSSProperties} />
))}
</span>
<span className="cb-chalk__text">Lesson done</span>
</label>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCbChalk(el) on the mounted element instead.
<label class="cb-chalk">
<input class="cb-chalk__input" type="checkbox"/>
<span class="cb-chalk__box" aria-hidden="true">
<svg viewBox="0 0 34 34">
<defs>
<filter id="cb-chalk-def" x="-10%" y="-10%" width="120%" height="120%">
<feTurbulence type="fractalNoise" baseFrequency="1.6" numOctaves="1" seed="3" result="n"></feTurbulence>
<feDisplacementMap in="SourceGraphic" in2="n" scale="2.2" result="rough"></feDisplacementMap>
<feColorMatrix in="n" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -2.4 1.9" result="holes"></feColorMatrix>
<feComposite in="rough" in2="holes" operator="in"></feComposite>
</filter>
</defs>
<g filter="url(#cb-chalk-def)">
<path class="cb-chalk__frame" d="M5 6C12 4.5 22 5 29 5.5C29.5 13 29 21 28.6 29C20 29.4 12 29 5.4 28.6C4.8 21 5 13 5 6Z"></path>
<path class="cb-chalk__tick" d="M9 17.5C11 19.5 13 22 14.5 24C18 17 22.5 11 29 6" pathLength="1"></path>
</g>
</svg>
<span class="cb-chalk__dust" style="left:10px;--d:0.3s"></span>
<span class="cb-chalk__dust" style="left:16px;--d:0.42s"></span>
<span class="cb-chalk__dust" style="left:22px;--d:0.36s"></span>
<span class="cb-chalk__dust" style="left:27px;--d:0.5s"></span>
</span>
<span class="cb-chalk__text">Lesson done</span>
</label>@keyframes cb-chalk-dust { from { transform: translateY(0); opacity: 0.9; } to { transform: translateY(16px); opacity: 0; } }
.cb-chalk { position: relative; display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 14px; cursor: pointer; user-select: none; }
.cb-chalk__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.cb-chalk__box { position: relative; width: 34px; height: 34px; }
.cb-chalk__input:focus-visible + .cb-chalk__box { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; border-radius: 6px; }
.cb-chalk__box svg { width: 100%; height: 100%; overflow: visible; }
.cb-chalk__frame { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 2; stroke-linecap: round; }
.cb-chalk__tick { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 3.2; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 0.2s; }
.cb-chalk__input:checked + .cb-chalk__box .cb-chalk__tick { stroke-dashoffset: 0; transition: stroke-dashoffset 0.45s cubic-bezier(0.6, 0, 0.3, 1); }
.cb-chalk__dust { position: absolute; top: 24px; width: 2px; height: 2px; border-radius: 50%; background: light-dark(#50535b, #c6c8cd); opacity: 0; }
.cb-chalk[data-armed] .cb-chalk__input:checked + .cb-chalk__box .cb-chalk__dust { animation: cb-chalk-dust 0.8s ease-in var(--d) both; }
.cb-chalk__text { font-style: italic; letter-spacing: 0.01em; }
@media (prefers-reduced-motion: reduce) {
.cb-chalk__tick { transition-duration: 0.01s !important; }
.cb-chalk__dust { animation: none !important; }
}function initCbChalk(root) {
var input = root.querySelector('input');
if (!input) return;
// Animate only after the first change, so nothing plays on page load.
function arm() { root.setAttribute('data-armed', ''); }
input.addEventListener('change', arm);
return function () { input.removeEventListener('change', arm); };
}
document.querySelectorAll('.cb-chalk').forEach(initCbChalk);Goal: Create a "Chalk" checkbox.
Visual behavior: A lesson done checkbox drawn in chalk: ticking it scrawls a grainy tick inside a hand-drawn box and a little dust falls from it.
Technique:
- A real native checkbox (visually hidden: absolute, 1x1, opacity 0) inside a <label>; every visual is aria-hidden and styled from the input with :checked + sibling selectors.
- The box and tick are SVG paths under one filter: feTurbulence noise displaces the strokes for a rough edge and is turned into speckled holes with feColorMatrix and feComposite, which reads as chalk.
- The tick draws in by dash offset (pathLength="1") and four specks of dust fall under it with staggered delays.
- The keyframes only apply once a data-armed flag is set on the first change, so nothing plays on page load; ticking and unticking use differently named keyframes, so each change restarts the right one.
Accessibility: A native checkbox inside a <label>, so Space toggles it and the visible label names it; :focus-visible on the input outlines the visual. Under prefers-reduced-motion the tick appears almost at once and no dust falls.
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 "Chalk" checkbox.
Visual behavior: A lesson done checkbox drawn in chalk: ticking it scrawls a grainy tick inside a hand-drawn box and a little dust falls from it.
Technique:
- A real native checkbox (visually hidden: absolute, 1x1, opacity 0) inside a <label>; every visual is aria-hidden and styled from the input with :checked + sibling selectors.
- The box and tick are SVG paths under one filter: feTurbulence noise displaces the strokes for a rough edge and is turned into speckled holes with feColorMatrix and feComposite, which reads as chalk.
- The tick draws in by dash offset (pathLength="1") and four specks of dust fall under it with staggered delays.
- The keyframes only apply once a data-armed flag is set on the first change, so nothing plays on page load; ticking and unticking use differently named keyframes, so each change restarts the right one.
Accessibility: A native checkbox inside a <label>, so Space toggles it and the visible label names it; :focus-visible on the input outlines the visual. Under prefers-reduced-motion the tick appears almost at once and no dust falls.
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 lesson done checkbox drawn in chalk: ticking it scrawls a grainy tick inside a hand-drawn box and a little dust falls from it.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, draw
- Techniques
- svg, svg filter, stroke dashoffset, keyframes
- 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 visual. Under prefers-reduced-motion the tick appears almost at once and no dust falls.