Putt
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCbPutt(el) on the mounted element instead.
<label class="cb-putt">
<input class="cb-putt__input" type="checkbox"/>
<span class="cb-putt__art" aria-hidden="true">
<svg class="cb-putt__course" viewBox="0 0 108 64">
<ellipse class="cb-putt__fringe" cx="54" cy="44" rx="53" ry="19"></ellipse>
<ellipse class="cb-putt__green" cx="54" cy="44" rx="48" ry="16"></ellipse>
<ellipse class="cb-putt__cup" cx="83" cy="43" rx="5.5" ry="2.5"></ellipse>
<ellipse class="cb-putt__lip" cx="83" cy="43" rx="5.5" ry="2.5"></ellipse>
<g class="cb-putt__stick">
<line class="cb-putt__pole" x1="83" y1="43" x2="83" y2="8"></line>
<path class="cb-putt__cloth" d="M83.8 8H101L97 14L101 20H83.8Z"></path>
<path class="cb-putt__tick" pathLength="1" d="M88 14L91 17L96 11"></path>
</g>
</svg>
<span class="cb-putt__ball"></span>
</span>
<span>Hole out</span>
</label>@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; }
}function initCbPutt(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-putt').forEach(initCbPutt);Goal: Create a "Putt" checkbox.
Visual behavior: 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.
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 ball rolls along an offset-path and drops into the cup, and unchecking plays a pop that throws it back out. The cup stays dark in both themes.
- The flagstick lifts once the ball is down and the tick on the flag draws in. 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 ball and flag move 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 "Putt" checkbox.
Visual behavior: 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.
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 ball rolls along an offset-path and drops into the cup, and unchecking plays a pop that throws it back out. The cup stays dark in both themes.
- The flagstick lifts once the ball is down and the tick on the flag draws in. 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 ball and flag move 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 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.