Putt

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

const css = `
  @keyframes cb-putt-roll { 0% { offset-distance: 0%; transform: scale(1); opacity: 1; } 82% { offset-distance: 100%; transform: scale(1); } 100% { offset-distance: 100%; transform: translateY(2px) scale(0.5); opacity: 0; } }
  @keyframes cb-putt-pop { 0% { offset-distance: 100%; transform: translateY(2px) scale(0.5); opacity: 0; } 30% { offset-distance: 100%; transform: translateY(-8px) scale(1); opacity: 1; } 100% { offset-distance: 0%; transform: none; opacity: 1; } }
  @keyframes cb-putt-wave { 0%, 100% { transform: skewY(0deg) scaleX(1); } 50% { transform: skewY(-6deg) scaleX(0.92); } }
  .cb-putt { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
  .cb-putt__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
  .cb-putt__art { position: relative; display: block; width: 108px; height: 64px; border-radius: 8px; }
  .cb-putt__input:focus-visible + .cb-putt__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
  .cb-putt__course { position: absolute; inset: 0; width: 108px; height: 64px; overflow: visible; }
  .cb-putt__fringe { fill: light-dark(#dbdde1, #2e3035); }
  .cb-putt__green { fill: light-dark(#cbced3, #393c41); }
  .cb-putt__cup { fill: light-dark(#373a40, #121417); }
  .cb-putt__lip { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
  .cb-putt__pole { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.6; stroke-linecap: round; }
  .cb-putt__cloth { fill: light-dark(#42454c, #e5e6e9); transform-box: fill-box; transform-origin: 0 50%; }
  .cb-putt__tick { fill: none; stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 0.15s; }
  /* The flagstick lifts out of the cup once the ball has dropped, and the flag shows its tick. */
  .cb-putt__stick { transition: transform 0.35s ease-in; }
  .cb-putt__input:checked + .cb-putt__art .cb-putt__stick { transform: translateY(-7px); transition: transform 0.4s cubic-bezier(0.3, 1.5, 0.5, 1) 1.1s; }
  .cb-putt__input:checked + .cb-putt__art .cb-putt__tick { stroke-dashoffset: 0; transition: stroke-dashoffset 0.3s ease-out 1.3s; }
  .cb-putt__input:checked + .cb-putt__art .cb-putt__cloth { animation: cb-putt-wave 0.6s ease-in-out 1.3s 2; }
  .cb-putt__ball { position: absolute; left: 0; top: 0; width: 6px; height: 6px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 1px 1.5px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)); offset-path: path('M18 46C40 52 62 50 80 40'); offset-rotate: 0deg; }
  .cb-putt__input:checked + .cb-putt__art .cb-putt__ball { offset-distance: 100%; opacity: 0; }
  .cb-putt[data-armed] .cb-putt__input:checked + .cb-putt__art .cb-putt__ball { animation: cb-putt-roll 1.15s cubic-bezier(0.2, 0.6, 0.4, 1); }
  .cb-putt[data-armed] .cb-putt__input:not(:checked) + .cb-putt__art .cb-putt__ball { animation: cb-putt-pop 0.8s ease-in-out; }
  @media (prefers-reduced-motion: reduce) {
    .cb-putt__stick, .cb-putt__tick, .cb-putt__input:checked + .cb-putt__art .cb-putt__stick, .cb-putt__input:checked + .cb-putt__art .cb-putt__tick { transition: none; }
    .cb-putt__input:checked + .cb-putt__art .cb-putt__cloth,
    .cb-putt[data-armed] .cb-putt__input:checked + .cb-putt__art .cb-putt__ball,
    .cb-putt[data-armed] .cb-putt__input:not(:checked) + .cb-putt__art .cb-putt__ball { animation: none; }
  }
`;

export function PuttCheckbox() {
  // The roll and the pop back out only play after a change, never on load.
  const [armed, setArmed] = useState(false);
  return (
    <>
      <style>
        {css}
      </style>
      <label className="cb-putt" data-armed={armed || undefined}>
        <input className="cb-putt__input" type="checkbox" onChange={() => setArmed(true)} />
        <span className="cb-putt__art" aria-hidden="true">
          <svg className="cb-putt__course" viewBox="0 0 108 64">
            <ellipse className="cb-putt__fringe" cx="54" cy="44" rx="53" ry="19" />
            <ellipse className="cb-putt__green" cx="54" cy="44" rx="48" ry="16" />
            <ellipse className="cb-putt__cup" cx="83" cy="43" rx="5.5" ry="2.5" />
            <ellipse className="cb-putt__lip" cx="83" cy="43" rx="5.5" ry="2.5" />
            <g className="cb-putt__stick">
              <line className="cb-putt__pole" x1="83" y1="43" x2="83" y2="8" />
              <path className="cb-putt__cloth" d="M83.8 8H101L97 14L101 20H83.8Z" />
              <path className="cb-putt__tick" pathLength={1} d="M88 14L91 17L96 11" />
            </g>
          </svg>
          <span className="cb-putt__ball" />
        </span>
        <span>Hole 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-putt
Source on GitHub

About this component

A hole out checkbox drawn as a putting green: the ball rolls across and drops into the cup, then the flagstick lifts out showing a tick on its flag; unchecking pops the ball back out.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, keyframe
Techniques
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 ball and flag move at once.

More checkboxes

See all
Rake
Skate
Spray
Strap