Hatch

No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/checkbox-hatch
Source on GitHub

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.

More checkboxes

See all
Icing
Peg
Putt
Rake