Skate
import { useState } from 'react';
import type { CSSProperties } from 'react';
const css = `
@keyframes cb-skate-blade { 0% { offset-distance: 0%; opacity: 0; } 10% { opacity: 1; } 80% { offset-distance: 100%; opacity: 1; } 100% { offset-distance: 100%; opacity: 0; } }
@keyframes cb-skate-chip { 0% { opacity: 0; transform: translate(0, 0); } 15% { opacity: 1; } 100% { opacity: 0; transform: translate(var(--x), var(--y)); } }
.cb-skate { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
.cb-skate__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.cb-skate__art { position: relative; display: block; width: 92px; height: 60px; border-radius: 8px; }
.cb-skate__input:focus-visible + .cb-skate__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
.cb-skate__rink { position: absolute; inset: 0; width: 92px; height: 60px; overflow: visible; }
.cb-skate__ice { fill: light-dark(#ebedf0, #232529); }
.cb-skate__sheen { fill: none; stroke: color-mix(in srgb, light-dark(#373a40, #f1f2f3) 8%, transparent); stroke-width: 5; stroke-linecap: round; }
.cb-skate__scratch { fill: none; stroke: light-dark(#dbdde1, #2e3035); stroke-width: 0.8; stroke-linecap: round; }
.cb-skate__frost { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 6; stroke-linecap: round; stroke-linejoin: round; opacity: 0.12; }
.cb-skate__cut { fill: none; stroke: light-dark(#42454c, #e5e6e9); stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
.cb-skate__frost, .cb-skate__cut { stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 0.3s, opacity 0.3s; }
.cb-skate__input:checked + .cb-skate__art .cb-skate__frost,
.cb-skate__input:checked + .cb-skate__art .cb-skate__cut { stroke-dashoffset: 0; transition: stroke-dashoffset 0.6s cubic-bezier(0.35, 0, 0.25, 1); }
.cb-skate__blade { position: absolute; left: 0; top: 0; width: 18px; height: 5px; opacity: 0; offset-path: path('M18 31L34 45L72 13'); offset-rotate: auto; offset-anchor: 50% 100%; }
.cb-skate__blade::before { content: ''; position: absolute; inset: 0 0 1.5px 2px; border-radius: 3px 6px 1px 1px; background: light-dark(#5f626a, #a7abb2); }
.cb-skate__blade::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 1.5px; border-radius: 1px; background: light-dark(#373a40, #f1f2f3); }
/* The skater stops hard at the end of the stroke, and the blade throws up a spray of shavings. */
.cb-skate__chip { position: absolute; left: 70px; top: 13px; width: 2.5px; height: 2.5px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); opacity: 0; }
.cb-skate[data-armed] .cb-skate__input:checked + .cb-skate__art .cb-skate__blade { animation: cb-skate-blade 0.72s cubic-bezier(0.35, 0, 0.25, 1); }
.cb-skate[data-armed] .cb-skate__input:checked + .cb-skate__art .cb-skate__chip { animation: cb-skate-chip 0.5s ease-out var(--d); }
@media (prefers-reduced-motion: reduce) {
.cb-skate__frost, .cb-skate__cut, .cb-skate__input:checked + .cb-skate__art .cb-skate__frost, .cb-skate__input:checked + .cb-skate__art .cb-skate__cut { transition: none; }
.cb-skate[data-armed] .cb-skate__input:checked + .cb-skate__art .cb-skate__blade, .cb-skate[data-armed] .cb-skate__input:checked + .cb-skate__art .cb-skate__chip { animation: none; }
}
`;
const TICK = 'M18 31L34 45L72 13';
const CHIPS = [
{ x: 9, y: -9, d: 0.5 },
{ x: 13, y: -2, d: 0.52 },
{ x: 6, y: -13, d: 0.55 },
{ x: 11, y: 4, d: 0.57 },
{ x: 15, y: -6, d: 0.6 },
{ x: 4, y: 2, d: 0.62 },
];
export function SkateCheckbox() {
// The blade and its spray only play when someone ticks it, never on load.
const [armed, setArmed] = useState(false);
return (
<>
<style>
{css}
</style>
<label className="cb-skate" data-armed={armed || undefined}>
<input className="cb-skate__input" type="checkbox" onChange={() => setArmed(true)} />
<span className="cb-skate__art" aria-hidden="true">
<svg className="cb-skate__rink" viewBox="0 0 92 60">
<rect className="cb-skate__ice" x="0" y="0" width="92" height="60" rx="8" />
<path className="cb-skate__sheen" d="M10 50L30 8M24 52L40 18" />
<path className="cb-skate__scratch" d="M8 18Q30 12 50 22M44 52Q64 40 86 44M12 40Q22 36 30 42" />
<path className="cb-skate__frost" pathLength={1} d={TICK} />
<path className="cb-skate__cut" pathLength={1} d={TICK} />
</svg>
<span className="cb-skate__blade" />
{CHIPS.map((c) => (
<span key={c.d} className="cb-skate__chip" style={{ '--x': `${c.x}px`, '--y': `${c.y}px`, '--d': `${c.d}s` } as CSSProperties} />
))}
</span>
<span>Morning skate</span>
</label>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCbSkate(el) on the mounted element instead.
<label class="cb-skate">
<input class="cb-skate__input" type="checkbox"/>
<span class="cb-skate__art" aria-hidden="true">
<svg class="cb-skate__rink" viewBox="0 0 92 60">
<rect class="cb-skate__ice" x="0" y="0" width="92" height="60" rx="8"></rect>
<path class="cb-skate__sheen" d="M10 50L30 8M24 52L40 18"></path>
<path class="cb-skate__scratch" d="M8 18Q30 12 50 22M44 52Q64 40 86 44M12 40Q22 36 30 42"></path>
<path class="cb-skate__frost" pathLength="1" d="M18 31L34 45L72 13"></path>
<path class="cb-skate__cut" pathLength="1" d="M18 31L34 45L72 13"></path>
</svg>
<span class="cb-skate__blade"></span>
<span class="cb-skate__chip" style="--x:9px;--y:-9px;--d:0.5s"></span>
<span class="cb-skate__chip" style="--x:13px;--y:-2px;--d:0.52s"></span>
<span class="cb-skate__chip" style="--x:6px;--y:-13px;--d:0.55s"></span>
<span class="cb-skate__chip" style="--x:11px;--y:4px;--d:0.57s"></span>
<span class="cb-skate__chip" style="--x:15px;--y:-6px;--d:0.6s"></span>
<span class="cb-skate__chip" style="--x:4px;--y:2px;--d:0.62s"></span>
</span>
<span>Morning skate</span>
</label>@keyframes cb-skate-blade { 0% { offset-distance: 0%; opacity: 0; } 10% { opacity: 1; } 80% { offset-distance: 100%; opacity: 1; } 100% { offset-distance: 100%; opacity: 0; } }
@keyframes cb-skate-chip { 0% { opacity: 0; transform: translate(0, 0); } 15% { opacity: 1; } 100% { opacity: 0; transform: translate(var(--x), var(--y)); } }
.cb-skate { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
.cb-skate__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.cb-skate__art { position: relative; display: block; width: 92px; height: 60px; border-radius: 8px; }
.cb-skate__input:focus-visible + .cb-skate__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
.cb-skate__rink { position: absolute; inset: 0; width: 92px; height: 60px; overflow: visible; }
.cb-skate__ice { fill: light-dark(#ebedf0, #232529); }
.cb-skate__sheen { fill: none; stroke: color-mix(in srgb, light-dark(#373a40, #f1f2f3) 8%, transparent); stroke-width: 5; stroke-linecap: round; }
.cb-skate__scratch { fill: none; stroke: light-dark(#dbdde1, #2e3035); stroke-width: 0.8; stroke-linecap: round; }
.cb-skate__frost { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 6; stroke-linecap: round; stroke-linejoin: round; opacity: 0.12; }
.cb-skate__cut { fill: none; stroke: light-dark(#42454c, #e5e6e9); stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
.cb-skate__frost, .cb-skate__cut { stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 0.3s, opacity 0.3s; }
.cb-skate__input:checked + .cb-skate__art .cb-skate__frost,
.cb-skate__input:checked + .cb-skate__art .cb-skate__cut { stroke-dashoffset: 0; transition: stroke-dashoffset 0.6s cubic-bezier(0.35, 0, 0.25, 1); }
.cb-skate__blade { position: absolute; left: 0; top: 0; width: 18px; height: 5px; opacity: 0; offset-path: path('M18 31L34 45L72 13'); offset-rotate: auto; offset-anchor: 50% 100%; }
.cb-skate__blade::before { content: ''; position: absolute; inset: 0 0 1.5px 2px; border-radius: 3px 6px 1px 1px; background: light-dark(#5f626a, #a7abb2); }
.cb-skate__blade::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 1.5px; border-radius: 1px; background: light-dark(#373a40, #f1f2f3); }
/* The skater stops hard at the end of the stroke, and the blade throws up a spray of shavings. */
.cb-skate__chip { position: absolute; left: 70px; top: 13px; width: 2.5px; height: 2.5px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); opacity: 0; }
.cb-skate[data-armed] .cb-skate__input:checked + .cb-skate__art .cb-skate__blade { animation: cb-skate-blade 0.72s cubic-bezier(0.35, 0, 0.25, 1); }
.cb-skate[data-armed] .cb-skate__input:checked + .cb-skate__art .cb-skate__chip { animation: cb-skate-chip 0.5s ease-out var(--d); }
@media (prefers-reduced-motion: reduce) {
.cb-skate__frost, .cb-skate__cut, .cb-skate__input:checked + .cb-skate__art .cb-skate__frost, .cb-skate__input:checked + .cb-skate__art .cb-skate__cut { transition: none; }
.cb-skate[data-armed] .cb-skate__input:checked + .cb-skate__art .cb-skate__blade, .cb-skate[data-armed] .cb-skate__input:checked + .cb-skate__art .cb-skate__chip { animation: none; }
}function initCbSkate(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-skate').forEach(initCbSkate);Goal: Create a "Skate" checkbox.
Visual behavior: A morning skate checkbox drawn as an ice rink: a blade carves the tick into the ice, stops hard at the end and throws up a spray of shavings.
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 cut and a soft frost line are drawn in by dash offset on the tick path while the blade rides the same path on offset-path.
- Six shavings fly off the end of the stroke with staggered delays. 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 cut appears at once with no blade.
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 "Skate" checkbox.
Visual behavior: A morning skate checkbox drawn as an ice rink: a blade carves the tick into the ice, stops hard at the end and throws up a spray of shavings.
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 cut and a soft frost line are drawn in by dash offset on the tick path while the blade rides the same path on offset-path.
- Six shavings fly off the end of the stroke with staggered delays. 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 cut appears at once with no blade.
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 morning skate checkbox drawn as an ice rink: a blade carves the tick into the ice, stops hard at the end and throws up a spray of shavings.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, draw
- Techniques
- stroke dashoffset, 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 cut appears at once with no blade.