Hatch
import { useState } from 'react';
const css = `
@keyframes cb-hatch-wobble { 0%, 100% { transform: rotate(0deg); } 15% { transform: rotate(-7deg); } 30% { transform: rotate(6deg); } 45% { transform: rotate(-4deg); } 60% { transform: rotate(2deg); } 75% { transform: rotate(0deg); } }
@keyframes cb-hatch-pop { 0% { transform: translateY(14px) scale(0.6); } 60% { transform: translateY(-14px) scale(1.08); } 100% { transform: translateY(-10px); } }
.cb-hatch { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
.cb-hatch__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.cb-hatch__art { display: block; width: 68px; height: 80px; overflow: visible; border-radius: 8px; }
.cb-hatch__input:focus-visible + .cb-hatch__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
.cb-hatch__nest { fill: light-dark(#cbced3, #393c41); }
.cb-hatch__straw { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.2; stroke-linecap: round; }
/* Eggshell stays pale in both themes; the outline (outer curve only, so no seam shows) holds it against a light page. */
.cb-hatch__shell { fill: light-dark(#ebedf0, #e5e6e9); }
/* The top half overlaps the seam by a hair, so the closed egg shows no line. */
.cb-hatch__top .cb-hatch__shell { stroke: light-dark(#ebedf0, #e5e6e9); stroke-width: 1.2; }
.cb-hatch__edge { fill: none; stroke: light-dark(#cbced3, #e5e6e9); stroke-width: 1; }
.cb-hatch__speck { fill: light-dark(#5f626a, #a7abb2); }
.cb-hatch__crack { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.4; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 0.2s; }
.cb-hatch__tick { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; }
/* Order on: a wobble, the crack runs round, the top tips off and the tick pops up out of the egg. */
.cb-hatch__egg { transform-box: view-box; transform-origin: 34px 66px; }
.cb-hatch__top { transform-box: view-box; transform-origin: 48px 40px; transition: transform 0.3s ease-in 0.15s; }
.cb-hatch__rise { transform: translateY(14px); transition: transform 0.2s ease-in; }
.cb-hatch__input:checked + .cb-hatch__art .cb-hatch__crack { stroke-dashoffset: 0; transition: stroke-dashoffset 0.25s ease-out 0.45s; }
.cb-hatch__input:checked + .cb-hatch__art .cb-hatch__top { transform: translate(13px, -16px) rotate(38deg); transition: transform 0.35s cubic-bezier(0.3, 1.4, 0.5, 1) 0.7s; }
.cb-hatch__input:checked + .cb-hatch__art .cb-hatch__rise { transform: translateY(-10px); transition: none; }
.cb-hatch[data-armed] .cb-hatch__input:checked + .cb-hatch__art .cb-hatch__egg { animation: cb-hatch-wobble 0.6s ease-in-out; }
.cb-hatch[data-armed] .cb-hatch__input:checked + .cb-hatch__art .cb-hatch__rise { animation: cb-hatch-pop 0.45s cubic-bezier(0.3, 1.2, 0.5, 1) 0.8s backwards; }
@media (prefers-reduced-motion: reduce) {
.cb-hatch__crack, .cb-hatch__top, .cb-hatch__rise,
.cb-hatch__input:checked + .cb-hatch__art .cb-hatch__crack, .cb-hatch__input:checked + .cb-hatch__art .cb-hatch__top { transition: none; }
.cb-hatch[data-armed] .cb-hatch__input:checked + .cb-hatch__art .cb-hatch__egg,
.cb-hatch[data-armed] .cb-hatch__input:checked + .cb-hatch__art .cb-hatch__rise { animation: none; }
}
`;
// The egg is split along a zigzag; the two halves share it, so closed they read as one shell.
const ZIG = 'M18 40L22 36L26 41L30 35L34 41L38 35L42 41L46 36L50 40';
export function HatchCheckbox() {
// The wobble and the pop only play when someone ticks it.
const [armed, setArmed] = useState(false);
return (
<>
<style>
{css}
</style>
<label className="cb-hatch" data-armed={armed || undefined}>
<input className="cb-hatch__input" type="checkbox" onChange={() => setArmed(true)} />
<svg className="cb-hatch__art" viewBox="0 0 68 80" aria-hidden="true">
<g className="cb-hatch__egg">
{/* The tick waits inside, behind the lower shell; popping up brings its top over the rim. */}
<g className="cb-hatch__rise">
<path className="cb-hatch__tick" d="M25 36L32 43L45 26" />
</g>
<path className="cb-hatch__shell" d={`${ZIG}Q51 52 46 59Q40 66 34 66Q28 66 22 59Q17 52 18 40Z`} />
<path className="cb-hatch__edge" d="M50 40Q51 52 46 59Q40 66 34 66Q28 66 22 59Q17 52 18 40" />
<circle className="cb-hatch__speck" cx="28" cy="52" r="1.3" />
<circle className="cb-hatch__speck" cx="40" cy="56" r="1" />
<g className="cb-hatch__top">
<path className="cb-hatch__shell" d={`${ZIG}Q51 26 44 19Q39 14 34 14Q29 14 24 19Q17 26 18 40Z`} />
<path className="cb-hatch__edge" d="M50 40Q51 26 44 19Q39 14 34 14Q29 14 24 19Q17 26 18 40" />
<circle className="cb-hatch__speck" cx="38" cy="24" r="1.2" />
<circle className="cb-hatch__speck" cx="29" cy="28" r="0.9" />
</g>
<path className="cb-hatch__crack" pathLength={1} d={ZIG} />
</g>
<path className="cb-hatch__nest" d="M8 60Q10 76 34 76Q58 76 60 60Q48 68 34 68Q20 68 8 60Z" />
<path className="cb-hatch__straw" d="M12 64L22 68M46 70L56 63M28 72L40 71M16 70L26 73" />
</svg>
<span>Ready</span>
</label>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCbHatch(el) on the mounted element instead.
<label class="cb-hatch">
<input class="cb-hatch__input" type="checkbox"/>
<svg class="cb-hatch__art" viewBox="0 0 68 80" aria-hidden="true">
<g class="cb-hatch__egg">
<g class="cb-hatch__rise">
<path class="cb-hatch__tick" d="M25 36L32 43L45 26"></path>
</g>
<path class="cb-hatch__shell" d="M18 40L22 36L26 41L30 35L34 41L38 35L42 41L46 36L50 40Q51 52 46 59Q40 66 34 66Q28 66 22 59Q17 52 18 40Z"></path>
<path class="cb-hatch__edge" d="M50 40Q51 52 46 59Q40 66 34 66Q28 66 22 59Q17 52 18 40"></path>
<circle class="cb-hatch__speck" cx="28" cy="52" r="1.3"></circle>
<circle class="cb-hatch__speck" cx="40" cy="56" r="1"></circle>
<g class="cb-hatch__top">
<path class="cb-hatch__shell" d="M18 40L22 36L26 41L30 35L34 41L38 35L42 41L46 36L50 40Q51 26 44 19Q39 14 34 14Q29 14 24 19Q17 26 18 40Z"></path>
<path class="cb-hatch__edge" d="M50 40Q51 26 44 19Q39 14 34 14Q29 14 24 19Q17 26 18 40"></path>
<circle class="cb-hatch__speck" cx="38" cy="24" r="1.2"></circle>
<circle class="cb-hatch__speck" cx="29" cy="28" r="0.9"></circle>
</g>
<path class="cb-hatch__crack" pathLength="1" d="M18 40L22 36L26 41L30 35L34 41L38 35L42 41L46 36L50 40"></path>
</g>
<path class="cb-hatch__nest" d="M8 60Q10 76 34 76Q58 76 60 60Q48 68 34 68Q20 68 8 60Z"></path>
<path class="cb-hatch__straw" d="M12 64L22 68M46 70L56 63M28 72L40 71M16 70L26 73"></path>
</svg>
<span>Ready</span>
</label>@keyframes cb-hatch-wobble { 0%, 100% { transform: rotate(0deg); } 15% { transform: rotate(-7deg); } 30% { transform: rotate(6deg); } 45% { transform: rotate(-4deg); } 60% { transform: rotate(2deg); } 75% { transform: rotate(0deg); } }
@keyframes cb-hatch-pop { 0% { transform: translateY(14px) scale(0.6); } 60% { transform: translateY(-14px) scale(1.08); } 100% { transform: translateY(-10px); } }
.cb-hatch { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
.cb-hatch__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.cb-hatch__art { display: block; width: 68px; height: 80px; overflow: visible; border-radius: 8px; }
.cb-hatch__input:focus-visible + .cb-hatch__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
.cb-hatch__nest { fill: light-dark(#cbced3, #393c41); }
.cb-hatch__straw { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.2; stroke-linecap: round; }
/* Eggshell stays pale in both themes; the outline (outer curve only, so no seam shows) holds it against a light page. */
.cb-hatch__shell { fill: light-dark(#ebedf0, #e5e6e9); }
/* The top half overlaps the seam by a hair, so the closed egg shows no line. */
.cb-hatch__top .cb-hatch__shell { stroke: light-dark(#ebedf0, #e5e6e9); stroke-width: 1.2; }
.cb-hatch__edge { fill: none; stroke: light-dark(#cbced3, #e5e6e9); stroke-width: 1; }
.cb-hatch__speck { fill: light-dark(#5f626a, #a7abb2); }
.cb-hatch__crack { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.4; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 0.2s; }
.cb-hatch__tick { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; }
/* Order on: a wobble, the crack runs round, the top tips off and the tick pops up out of the egg. */
.cb-hatch__egg { transform-box: view-box; transform-origin: 34px 66px; }
.cb-hatch__top { transform-box: view-box; transform-origin: 48px 40px; transition: transform 0.3s ease-in 0.15s; }
.cb-hatch__rise { transform: translateY(14px); transition: transform 0.2s ease-in; }
.cb-hatch__input:checked + .cb-hatch__art .cb-hatch__crack { stroke-dashoffset: 0; transition: stroke-dashoffset 0.25s ease-out 0.45s; }
.cb-hatch__input:checked + .cb-hatch__art .cb-hatch__top { transform: translate(13px, -16px) rotate(38deg); transition: transform 0.35s cubic-bezier(0.3, 1.4, 0.5, 1) 0.7s; }
.cb-hatch__input:checked + .cb-hatch__art .cb-hatch__rise { transform: translateY(-10px); transition: none; }
.cb-hatch[data-armed] .cb-hatch__input:checked + .cb-hatch__art .cb-hatch__egg { animation: cb-hatch-wobble 0.6s ease-in-out; }
.cb-hatch[data-armed] .cb-hatch__input:checked + .cb-hatch__art .cb-hatch__rise { animation: cb-hatch-pop 0.45s cubic-bezier(0.3, 1.2, 0.5, 1) 0.8s backwards; }
@media (prefers-reduced-motion: reduce) {
.cb-hatch__crack, .cb-hatch__top, .cb-hatch__rise,
.cb-hatch__input:checked + .cb-hatch__art .cb-hatch__crack, .cb-hatch__input:checked + .cb-hatch__art .cb-hatch__top { transition: none; }
.cb-hatch[data-armed] .cb-hatch__input:checked + .cb-hatch__art .cb-hatch__egg,
.cb-hatch[data-armed] .cb-hatch__input:checked + .cb-hatch__art .cb-hatch__rise { animation: none; }
}function initCbHatch(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-hatch').forEach(initCbHatch);Goal: Create a "Hatch" checkbox.
Visual behavior: A ready checkbox drawn as an egg in a nest: it wobbles, a crack runs round it, the top tips off and the tick pops up out of the shell; unchecking tucks it back in.
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 egg is split along a zigzag shared by both halves, so closed it reads as one shell. The crack draws along the zigzag by its dash offset, then the top tips off on an overshooting curve.
- The tick waits inside behind the lower shell and pops up with a springy keyframe. The shell 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 egg opens 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 "Hatch" checkbox.
Visual behavior: A ready checkbox drawn as an egg in a nest: it wobbles, a crack runs round it, the top tips off and the tick pops up out of the shell; unchecking tucks it back in.
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 egg is split along a zigzag shared by both halves, so closed it reads as one shell. The crack draws along the zigzag by its dash offset, then the top tips off on an overshooting curve.
- The tick waits inside behind the lower shell and pops up with a springy keyframe. The shell 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 egg opens 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 ready checkbox drawn as an egg in a nest: it wobbles, a crack runs round it, the top tips off and the tick pops up out of the shell; unchecking tucks it back in.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, keyframe, spring
- Techniques
- keyframes, stroke dashoffset, 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 egg opens at once.