Skate

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

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

Install with shadcn

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

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.

More checkboxes

See all
Spray
Strap
Stud
Ballot