Stud

No dependencies
Code format
React + Tailwind
import { useState } from 'react';

const css = `
  @keyframes cb-stud-ring { from { opacity: 0.8; transform: scale(1); } to { opacity: 0; transform: scale(1.6); } }
  .cb-stud { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
  .cb-stud__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
  .cb-stud__art { display: block; width: 64px; height: 64px; overflow: visible; border-radius: 12px; }
  .cb-stud__input:focus-visible + .cb-stud__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
  .cb-stud__cloth { fill: light-dark(#ebedf0, #232529); }
  .cb-stud__seam { fill: none; stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1.2; stroke-dasharray: 3 2.5; }
  .cb-stud__socket { fill: light-dark(#cbced3, #393c41); }
  .cb-stud__well { fill: light-dark(#dbdde1, #2e3035); }
  .cb-stud__cap { fill: light-dark(#50535b, #c6c8cd); }
  .cb-stud__dome { fill: light-dark(#42454c, #e5e6e9); }
  .cb-stud__tick { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 0.15s; }
  .cb-stud__shadow { fill: light-dark(#fbfcfd, #121417); opacity: 0.6; }
  .cb-stud__ring { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.5; opacity: 0; transform-box: fill-box; transform-origin: 50% 50%; }
  /* Loose, the cap hovers off its socket with a soft shadow; pressed, it drops and snaps flat. */
  .cb-stud__press { transform: translate(-3px, -6px); transition: transform 0.3s cubic-bezier(0.3, 1.6, 0.5, 1); }
  .cb-stud__shadow { transform: translate(3px, 4px); transition: transform 0.3s; }
  .cb-stud__lift { transform-box: view-box; transform-origin: 32px 32px; transform: scale(1.08); transition: transform 0.3s cubic-bezier(0.3, 1.6, 0.5, 1); }
  .cb-stud__input:checked + .cb-stud__art .cb-stud__press { transform: none; transition: transform 0.14s cubic-bezier(0.6, 0, 1, 0.6); }
  .cb-stud__input:checked + .cb-stud__art .cb-stud__shadow { transform: none; transition-duration: 0.14s; }
  .cb-stud__input:checked + .cb-stud__art .cb-stud__lift { transform: scale(0.97); transition: transform 0.3s cubic-bezier(0.3, 2.4, 0.5, 1) 0.1s; }
  .cb-stud__input:checked + .cb-stud__art .cb-stud__tick { stroke-dashoffset: 0; transition: stroke-dashoffset 0.25s ease-out 0.2s; }
  .cb-stud[data-armed] .cb-stud__input:checked + .cb-stud__art .cb-stud__ring { animation: cb-stud-ring 0.45s ease-out 0.13s; }
  @media (prefers-reduced-motion: reduce) {
    .cb-stud__press, .cb-stud__shadow, .cb-stud__lift, .cb-stud__tick,
    .cb-stud__input:checked + .cb-stud__art .cb-stud__press, .cb-stud__input:checked + .cb-stud__art .cb-stud__lift, .cb-stud__input:checked + .cb-stud__art .cb-stud__tick { transition: none; }
    .cb-stud[data-armed] .cb-stud__input:checked + .cb-stud__art .cb-stud__ring { animation: none; }
  }
`;

export function StudCheckbox() {
  // The snap ring plays only after a press, never on load.
  const [armed, setArmed] = useState(false);
  return (
    <>
      <style>
        {css}
      </style>
      <label className="cb-stud" data-armed={armed || undefined}>
        <input className="cb-stud__input" type="checkbox" onChange={() => setArmed(true)} />
        <svg className="cb-stud__art" viewBox="0 0 64 64" aria-hidden="true">
          <rect className="cb-stud__cloth" x="2" y="2" width="60" height="60" rx="12" />
          <rect className="cb-stud__seam" x="7" y="7" width="50" height="50" rx="8" />
          <circle className="cb-stud__socket" cx="32" cy="32" r="14" />
          <circle className="cb-stud__well" cx="32" cy="32" r="6" />
          <circle className="cb-stud__ring" cx="32" cy="32" r="14" />
          <circle className="cb-stud__shadow" cx="32" cy="32" r="13" />
          <g className="cb-stud__press">
            <g className="cb-stud__lift">
              <circle className="cb-stud__cap" cx="32" cy="32" r="13" />
              <circle className="cb-stud__dome" cx="32" cy="32" r="10" />
              <path className="cb-stud__tick" pathLength={1} d="M26.5 32.5L30.5 36.5L38 28" />
            </g>
          </g>
        </svg>
        <span>Saved</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-stud
Source on GitHub

About this component

A saved checkbox drawn as a press stud: the loose cap hovers over its socket with a soft shadow, then presses down and snaps flat with a ring of give and a tick; unchecking pops it back up.

Dependencies
None
Difficulty
Beginner
Triggers
click, state change, spring
Techniques
css transition, keyframes, 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 cap snaps flat at once.

More checkboxes

See all
Ballot
Bow
Bubble
Chalk