Peg
import { useState } from 'react';
const css = `
@keyframes cb-peg-settle { 0% { transform: rotate(14deg); } 45% { transform: rotate(-4deg); } 70% { transform: rotate(1.5deg); } 100% { transform: rotate(0deg); } }
@keyframes cb-peg-drop { 0% { transform: rotate(0deg); } 30% { transform: rotate(20deg); } 60% { transform: rotate(10deg); } 80% { transform: rotate(16deg); } 100% { transform: rotate(14deg); } }
.cb-peg { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
.cb-peg__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.cb-peg__art { display: block; width: 96px; height: 70px; overflow: visible; border-radius: 8px; }
.cb-peg__input:focus-visible + .cb-peg__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
.cb-peg__line { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.4; }
/* Paper stays pale in both themes; the ink stays dark on it. */
.cb-peg__card { fill: light-dark(#ebedf0, #e5e6e9); stroke: light-dark(#cbced3, #e5e6e9); stroke-width: 1; }
.cb-peg__rule { stroke: light-dark(#6d7179, #a7abb2); stroke-width: 1; }
.cb-peg__tick { fill: none; stroke: light-dark(#373a40, #2e3035); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 0.15s; }
.cb-peg__wood { fill: light-dark(#6d7179, #7d8087); }
.cb-peg__spring { fill: light-dark(#50535b, #c6c8cd); }
/* One peg holds the note by a corner, so it hangs crooked; the second peg drops in, clips the
other corner, and the note swings straight. */
.cb-peg__note { transform-box: view-box; transform-origin: 30px 17px; transform: rotate(14deg); transition: transform 0.3s ease-in-out 0.1s; }
.cb-peg__second { transform: translateY(-22px); opacity: 0; transition: transform 0.25s ease-in, opacity 0.2s; }
.cb-peg__jaw { transform-box: view-box; transform-origin: 62px 12px; transform: rotate(-14deg); transition: transform 0.15s; }
.cb-peg__input:checked + .cb-peg__art .cb-peg__second { transform: none; opacity: 1; transition: transform 0.3s cubic-bezier(0.5, 0, 0.7, 1.3), opacity 0.1s; }
.cb-peg__input:checked + .cb-peg__art .cb-peg__jaw { transform: none; transition: transform 0.12s 0.3s; }
.cb-peg__input:checked + .cb-peg__art .cb-peg__note { transform: none; transition: none; }
.cb-peg__input:checked + .cb-peg__art .cb-peg__tick { stroke-dashoffset: 0; transition: stroke-dashoffset 0.3s ease-out 0.9s; }
.cb-peg[data-armed] .cb-peg__input:checked + .cb-peg__art .cb-peg__note { animation: cb-peg-settle 0.8s ease-out 0.3s backwards; }
.cb-peg[data-armed] .cb-peg__input:not(:checked) + .cb-peg__art .cb-peg__note { animation: cb-peg-drop 0.6s ease-out 0.1s backwards; }
@media (prefers-reduced-motion: reduce) {
.cb-peg__note, .cb-peg__second, .cb-peg__jaw, .cb-peg__tick,
.cb-peg__input:checked + .cb-peg__art .cb-peg__second, .cb-peg__input:checked + .cb-peg__art .cb-peg__jaw, .cb-peg__input:checked + .cb-peg__art .cb-peg__tick { transition: none; }
.cb-peg[data-armed] .cb-peg__input:checked + .cb-peg__art .cb-peg__note, .cb-peg[data-armed] .cb-peg__input:not(:checked) + .cb-peg__art .cb-peg__note { animation: none; }
}
`;
function Peg({ x }: { x: number }) {
return (
<>
<rect className="cb-peg__wood" x={x - 2.5} y="6" width="5" height="18" rx="1.5" />
<rect className="cb-peg__spring" x={x - 3.5} y="12" width="7" height="2.5" rx="1" />
</>
);
}
export function PegCheckbox() {
// The swings only play after a change, never on load.
const [armed, setArmed] = useState(false);
return (
<>
<style>
{css}
</style>
<label className="cb-peg" data-armed={armed || undefined}>
<input className="cb-peg__input" type="checkbox" onChange={() => setArmed(true)} />
<svg className="cb-peg__art" viewBox="0 0 96 70" aria-hidden="true">
<path className="cb-peg__line" d="M0 13Q48 21 96 13" />
<g className="cb-peg__note">
<rect className="cb-peg__card" x="28" y="15" width="36" height="40" rx="2" />
<path className="cb-peg__rule" d="M33 24H52M33 30H46" />
<path className="cb-peg__tick" pathLength={1} d="M37 41L43 47L56 34" />
</g>
<Peg x={31} />
<g className="cb-peg__second">
<g className="cb-peg__jaw">
<Peg x={61} />
</g>
</g>
</svg>
<span>Laundry out</span>
</label>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCbPeg(el) on the mounted element instead.
<label class="cb-peg">
<input class="cb-peg__input" type="checkbox"/>
<svg class="cb-peg__art" viewBox="0 0 96 70" aria-hidden="true">
<path class="cb-peg__line" d="M0 13Q48 21 96 13"></path>
<g class="cb-peg__note">
<rect class="cb-peg__card" x="28" y="15" width="36" height="40" rx="2"></rect>
<path class="cb-peg__rule" d="M33 24H52M33 30H46"></path>
<path class="cb-peg__tick" pathLength="1" d="M37 41L43 47L56 34"></path>
</g>
<rect class="cb-peg__wood" x="28.5" y="6" width="5" height="18" rx="1.5"></rect>
<rect class="cb-peg__spring" x="27.5" y="12" width="7" height="2.5" rx="1"></rect>
<g class="cb-peg__second">
<g class="cb-peg__jaw">
<rect class="cb-peg__wood" x="58.5" y="6" width="5" height="18" rx="1.5"></rect>
<rect class="cb-peg__spring" x="57.5" y="12" width="7" height="2.5" rx="1"></rect>
</g>
</g>
</svg>
<span>Laundry out</span>
</label>@keyframes cb-peg-settle { 0% { transform: rotate(14deg); } 45% { transform: rotate(-4deg); } 70% { transform: rotate(1.5deg); } 100% { transform: rotate(0deg); } }
@keyframes cb-peg-drop { 0% { transform: rotate(0deg); } 30% { transform: rotate(20deg); } 60% { transform: rotate(10deg); } 80% { transform: rotate(16deg); } 100% { transform: rotate(14deg); } }
.cb-peg { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
.cb-peg__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.cb-peg__art { display: block; width: 96px; height: 70px; overflow: visible; border-radius: 8px; }
.cb-peg__input:focus-visible + .cb-peg__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
.cb-peg__line { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.4; }
/* Paper stays pale in both themes; the ink stays dark on it. */
.cb-peg__card { fill: light-dark(#ebedf0, #e5e6e9); stroke: light-dark(#cbced3, #e5e6e9); stroke-width: 1; }
.cb-peg__rule { stroke: light-dark(#6d7179, #a7abb2); stroke-width: 1; }
.cb-peg__tick { fill: none; stroke: light-dark(#373a40, #2e3035); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 0.15s; }
.cb-peg__wood { fill: light-dark(#6d7179, #7d8087); }
.cb-peg__spring { fill: light-dark(#50535b, #c6c8cd); }
/* One peg holds the note by a corner, so it hangs crooked; the second peg drops in, clips the
other corner, and the note swings straight. */
.cb-peg__note { transform-box: view-box; transform-origin: 30px 17px; transform: rotate(14deg); transition: transform 0.3s ease-in-out 0.1s; }
.cb-peg__second { transform: translateY(-22px); opacity: 0; transition: transform 0.25s ease-in, opacity 0.2s; }
.cb-peg__jaw { transform-box: view-box; transform-origin: 62px 12px; transform: rotate(-14deg); transition: transform 0.15s; }
.cb-peg__input:checked + .cb-peg__art .cb-peg__second { transform: none; opacity: 1; transition: transform 0.3s cubic-bezier(0.5, 0, 0.7, 1.3), opacity 0.1s; }
.cb-peg__input:checked + .cb-peg__art .cb-peg__jaw { transform: none; transition: transform 0.12s 0.3s; }
.cb-peg__input:checked + .cb-peg__art .cb-peg__note { transform: none; transition: none; }
.cb-peg__input:checked + .cb-peg__art .cb-peg__tick { stroke-dashoffset: 0; transition: stroke-dashoffset 0.3s ease-out 0.9s; }
.cb-peg[data-armed] .cb-peg__input:checked + .cb-peg__art .cb-peg__note { animation: cb-peg-settle 0.8s ease-out 0.3s backwards; }
.cb-peg[data-armed] .cb-peg__input:not(:checked) + .cb-peg__art .cb-peg__note { animation: cb-peg-drop 0.6s ease-out 0.1s backwards; }
@media (prefers-reduced-motion: reduce) {
.cb-peg__note, .cb-peg__second, .cb-peg__jaw, .cb-peg__tick,
.cb-peg__input:checked + .cb-peg__art .cb-peg__second, .cb-peg__input:checked + .cb-peg__art .cb-peg__jaw, .cb-peg__input:checked + .cb-peg__art .cb-peg__tick { transition: none; }
.cb-peg[data-armed] .cb-peg__input:checked + .cb-peg__art .cb-peg__note, .cb-peg[data-armed] .cb-peg__input:not(:checked) + .cb-peg__art .cb-peg__note { animation: none; }
}function initCbPeg(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-peg').forEach(initCbPeg);Goal: Create a "Peg" checkbox.
Visual behavior: A laundry out checkbox drawn as a washing line: a note hangs crooked from one peg until a second peg drops in and clips the other corner, and the note swings straight to show its tick.
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 second peg drops in with an overshooting transition and its jaw closes after it; the note settles with a damped swing keyframe and the tick draws in once it hangs straight.
- Unchecking lifts the peg away and the note drops back crooked. The paper stays pale in both themes. 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 peg and note move at once.
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 "Peg" checkbox.
Visual behavior: A laundry out checkbox drawn as a washing line: a note hangs crooked from one peg until a second peg drops in and clips the other corner, and the note swings straight to show its tick.
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 second peg drops in with an overshooting transition and its jaw closes after it; the note settles with a damped swing keyframe and the tick draws in once it hangs straight.
- Unchecking lifts the peg away and the note drops back crooked. The paper stays pale in both themes. 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 peg and note move at once.
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 laundry out checkbox drawn as a washing line: a note hangs crooked from one peg until a second peg drops in and clips the other corner, and the note swings straight to show its tick.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, spring
- Techniques
- keyframes, css transition, 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 peg and note move at once.