Peg

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

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

Install with shadcn

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

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.

More checkboxes

See all
Putt
Rake
Skate
Spray