Etch
import { useState } from 'react';
const css = `
@keyframes cb-etch-x { 0% { transform: rotate(0deg); } 32% { transform: rotate(120deg); } 100% { transform: rotate(360deg); } }
@keyframes cb-etch-y { 0% { transform: rotate(0deg); } 32% { transform: rotate(120deg); } 100% { transform: rotate(-160deg); } }
@keyframes cb-etch-shake {
0%, 100% { transform: none; }
12% { transform: translate(-3px, 1px) rotate(-6deg); }
26% { transform: translate(3px, -1px) rotate(5deg); }
40% { transform: translate(-3px, 0) rotate(-5deg); }
54% { transform: translate(2px, 1px) rotate(4deg); }
70% { transform: translate(-1px, 0) rotate(-2deg); }
}
.cb-etch { position: relative; display: inline-flex; align-items: center; gap: 12px; color: light-dark(#42454c, #e5e6e9); font-size: 14px; cursor: pointer; user-select: none; }
.cb-etch__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.cb-etch__toy {
position: relative;
width: 44px;
height: 36px;
border-radius: 7px;
background: linear-gradient(light-dark(#6d7179, #7d8087), light-dark(#cbced3, #393c41));
box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2);
}
.cb-etch__input:focus-visible + .cb-etch__toy { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
.cb-etch__screen { position: absolute; left: 5px; top: 4px; width: 34px; height: 22px; border-radius: 3px; background: light-dark(#ebedf0, #232529); box-shadow: inset 0 1px 3px light-dark(#fbfcfd, #121417); }
.cb-etch__screen svg { position: absolute; inset: 0; width: 34px; height: 22px; }
.cb-etch__screen path {
fill: none;
stroke: light-dark(#50535b, #c6c8cd);
stroke-width: 1.3;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
opacity: 0;
transition: opacity 0.45s ease 0.05s, stroke-dashoffset 0s linear 0.6s;
}
.cb-etch__knob {
position: absolute;
bottom: 1px;
width: 8px;
height: 8px;
border-radius: 50%;
background: light-dark(#42454c, #e5e6e9);
box-shadow: inset 0 -1px 0 light-dark(#5f626a, #a7abb2);
}
.cb-etch__knob::after { content: ''; position: absolute; left: 3.25px; top: 0.5px; width: 1.5px; height: 3px; border-radius: 1px; background: light-dark(#cbced3, #393c41); }
.cb-etch__knob--x { left: 2px; }
.cb-etch__knob--y { right: 2px; }
.cb-etch__input:checked + .cb-etch__toy .cb-etch__screen path { stroke-dashoffset: 0; opacity: 1; transition: stroke-dashoffset 0.8s linear, opacity 0s; }
.cb-etch[data-armed] .cb-etch__input:checked + .cb-etch__toy .cb-etch__knob--x { animation: cb-etch-x 0.8s linear; }
.cb-etch[data-armed] .cb-etch__input:checked + .cb-etch__toy .cb-etch__knob--y { animation: cb-etch-y 0.8s linear; }
.cb-etch[data-armed] .cb-etch__input:not(:checked) + .cb-etch__toy { animation: cb-etch-shake 0.55s ease-in-out; }
@media (prefers-reduced-motion: reduce) {
.cb-etch__toy, .cb-etch__toy * { animation: none !important; transition-duration: 0.01s !important; transition-delay: 0s !important; }
}
`;
export function EtchCheckbox() {
const [armed, setArmed] = useState(false);
return (
<>
<style>
{css}
</style>
<label className="cb-etch" data-armed={armed || undefined}>
<input className="cb-etch__input" type="checkbox" onChange={() => setArmed(true)} />
<span className="cb-etch__toy" aria-hidden="true">
<span className="cb-etch__screen">
<svg viewBox="0 0 34 22">
<path pathLength={1} d="M9 11L14 16L25 5" />
</svg>
</span>
<span className="cb-etch__knob cb-etch__knob--x" />
<span className="cb-etch__knob cb-etch__knob--y" />
</span>
Sketch mode
</label>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCbEtch(el) on the mounted element instead.
<label class="cb-etch"><input class="cb-etch__input" type="checkbox"/><span class="cb-etch__toy" aria-hidden="true"><span class="cb-etch__screen"><svg viewBox="0 0 34 22"><path pathLength="1" d="M9 11L14 16L25 5"></path></svg></span><span class="cb-etch__knob cb-etch__knob--x"></span><span class="cb-etch__knob cb-etch__knob--y"></span></span>Sketch mode</label>@keyframes cb-etch-x { 0% { transform: rotate(0deg); } 32% { transform: rotate(120deg); } 100% { transform: rotate(360deg); } }
@keyframes cb-etch-y { 0% { transform: rotate(0deg); } 32% { transform: rotate(120deg); } 100% { transform: rotate(-160deg); } }
@keyframes cb-etch-shake {
0%, 100% { transform: none; }
12% { transform: translate(-3px, 1px) rotate(-6deg); }
26% { transform: translate(3px, -1px) rotate(5deg); }
40% { transform: translate(-3px, 0) rotate(-5deg); }
54% { transform: translate(2px, 1px) rotate(4deg); }
70% { transform: translate(-1px, 0) rotate(-2deg); }
}
.cb-etch { position: relative; display: inline-flex; align-items: center; gap: 12px; color: light-dark(#42454c, #e5e6e9); font-size: 14px; cursor: pointer; user-select: none; }
.cb-etch__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.cb-etch__toy {
position: relative;
width: 44px;
height: 36px;
border-radius: 7px;
background: linear-gradient(light-dark(#6d7179, #7d8087), light-dark(#cbced3, #393c41));
box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2);
}
.cb-etch__input:focus-visible + .cb-etch__toy { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
.cb-etch__screen { position: absolute; left: 5px; top: 4px; width: 34px; height: 22px; border-radius: 3px; background: light-dark(#ebedf0, #232529); box-shadow: inset 0 1px 3px light-dark(#fbfcfd, #121417); }
.cb-etch__screen svg { position: absolute; inset: 0; width: 34px; height: 22px; }
.cb-etch__screen path {
fill: none;
stroke: light-dark(#50535b, #c6c8cd);
stroke-width: 1.3;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
opacity: 0;
transition: opacity 0.45s ease 0.05s, stroke-dashoffset 0s linear 0.6s;
}
.cb-etch__knob {
position: absolute;
bottom: 1px;
width: 8px;
height: 8px;
border-radius: 50%;
background: light-dark(#42454c, #e5e6e9);
box-shadow: inset 0 -1px 0 light-dark(#5f626a, #a7abb2);
}
.cb-etch__knob::after { content: ''; position: absolute; left: 3.25px; top: 0.5px; width: 1.5px; height: 3px; border-radius: 1px; background: light-dark(#cbced3, #393c41); }
.cb-etch__knob--x { left: 2px; }
.cb-etch__knob--y { right: 2px; }
.cb-etch__input:checked + .cb-etch__toy .cb-etch__screen path { stroke-dashoffset: 0; opacity: 1; transition: stroke-dashoffset 0.8s linear, opacity 0s; }
.cb-etch[data-armed] .cb-etch__input:checked + .cb-etch__toy .cb-etch__knob--x { animation: cb-etch-x 0.8s linear; }
.cb-etch[data-armed] .cb-etch__input:checked + .cb-etch__toy .cb-etch__knob--y { animation: cb-etch-y 0.8s linear; }
.cb-etch[data-armed] .cb-etch__input:not(:checked) + .cb-etch__toy { animation: cb-etch-shake 0.55s ease-in-out; }
@media (prefers-reduced-motion: reduce) {
.cb-etch__toy, .cb-etch__toy * { animation: none !important; transition-duration: 0.01s !important; transition-delay: 0s !important; }
}function initCbEtch(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-etch').forEach(initCbEtch);Goal: Create a "Etch" checkbox.
Visual behavior: A sketch toy checkbox: ticking turns the two knobs and a line draws the tick on the screen; unticking shakes the toy and the drawing fades away.
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 line draws with stroke-dashoffset over 0.8s while each knob turns on its own keyframe, timed to the horizontal and vertical parts of the drawing.
- Unticking shakes the whole toy while the line fades.
- 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 line appears and disappears instantly.
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 "Etch" checkbox.
Visual behavior: A sketch toy checkbox: ticking turns the two knobs and a line draws the tick on the screen; unticking shakes the toy and the drawing fades away.
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 line draws with stroke-dashoffset over 0.8s while each knob turns on its own keyframe, timed to the horizontal and vertical parts of the drawing.
- Unticking shakes the whole toy while the line fades.
- 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 line appears and disappears instantly.
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 sketch toy checkbox: ticking turns the two knobs and a line draws the tick on the screen; unticking shakes the toy and the drawing fades away.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, draw
- Techniques
- svg, 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 line appears and disappears instantly.