Strap

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

const css = `
  @keyframes cb-strap-press { 0% { transform: rotate(-30deg); } 60% { transform: rotate(1.5deg); } 80% { transform: rotate(-0.5deg); } 100% { transform: rotate(0deg); } }
  @keyframes cb-strap-fibre { 0% { opacity: 0; transform: scaleY(0); } 25% { opacity: 1; } 70% { opacity: 1; transform: scaleY(1); } 100% { opacity: 0; transform: scaleY(1.2); } }
  .cb-strap { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
  .cb-strap__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
  .cb-strap__art { display: block; width: 96px; height: 56px; overflow: visible; border-radius: 8px; }
  .cb-strap__input:focus-visible + .cb-strap__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
  .cb-strap__patch { fill: light-dark(#dbdde1, #2e3035); }
  .cb-strap__fuzz { fill: light-dark(#cbced3, #393c41); }
  .cb-strap__band { fill: light-dark(#6d7179, #7d8087); }
  .cb-strap__hooks { fill: light-dark(#cbced3, #393c41); }
  .cb-strap__fibre { stroke: light-dark(#5f626a, #a7abb2); stroke-width: 0.8; opacity: 0; transform-box: fill-box; transform-origin: 50% 100%; }
  .cb-strap__tick { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 0.15s; }
  /* Peeled back, the strap stands off the patch; checked, it folds down and presses flat. */
  .cb-strap__flap { transform-box: view-box; transform-origin: 8px 34px; transform: rotate(-30deg); transition: transform 0.45s cubic-bezier(0.3, 0, 0.5, 1); }
  .cb-strap__input:checked + .cb-strap__art .cb-strap__flap { transform: none; animation: cb-strap-press 0.4s cubic-bezier(0.5, 0, 0.6, 1); }
  .cb-strap__input:checked + .cb-strap__art .cb-strap__tick { stroke-dashoffset: 0; transition: stroke-dashoffset 0.25s ease-out 0.35s; }
  /* Pulling it off, the hooks drag loops up with them for a moment before they let go. */
  .cb-strap[data-armed] .cb-strap__input:not(:checked) + .cb-strap__art .cb-strap__fibre { animation: cb-strap-fibre 0.4s ease-out var(--d); }
  @media (prefers-reduced-motion: reduce) {
    .cb-strap__flap, .cb-strap__tick, .cb-strap__input:checked + .cb-strap__art .cb-strap__tick { transition: none; }
    .cb-strap__input:checked + .cb-strap__art .cb-strap__flap, .cb-strap[data-armed] .cb-strap__input:not(:checked) + .cb-strap__art .cb-strap__fibre { animation: none; }
  }
`;

// Loops on the patch: a fixed scatter so the texture is the same every time.
const FUZZ = Array.from({ length: 22 }, (_, i) => ({ x: 14 + ((i * 37) % 60), y: 37 + ((i * 11) % 9) }));
const FIBRES = [24, 34, 44, 54, 64, 72];

export function StrapCheckbox() {
  // The fibres only stretch after the strap has been pressed down and pulled off again.
  const [armed, setArmed] = useState(false);
  return (
    <>
      <style>
        {css}
      </style>
      <label className="cb-strap" data-armed={armed || undefined}>
        <input className="cb-strap__input" type="checkbox" onChange={() => setArmed(true)} />
        <svg className="cb-strap__art" viewBox="0 0 96 56" aria-hidden="true">
          <rect className="cb-strap__patch" x="10" y="34" width="72" height="13" rx="3" />
          {FUZZ.map((f, i) => (
            <circle key={i} className="cb-strap__fuzz" cx={f.x} cy={f.y} r="0.9" />
          ))}
          {FIBRES.map((x, i) => (
            <line key={x} className="cb-strap__fibre" style={{ '--d': `${i * 45}ms` } as CSSProperties} x1={x} y1="35" x2={x + 1} y2={35 - (x - 8) * 0.5} />
          ))}
          <g className="cb-strap__flap">
            <rect className="cb-strap__band" x="4" y="24" width="82" height="10" rx="3" />
            <rect className="cb-strap__hooks" x="12" y="32" width="70" height="2.5" rx="1" />
            <path className="cb-strap__tick" pathLength={1} d="M60 29L64 32.5L71 25.5" />
          </g>
        </svg>
        <span>Fasten</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-strap
Source on GitHub

About this component

A fasten checkbox drawn as hook-and-loop tape: the strap folds down and presses flat onto the patch; unchecking peels it back up, the loops stretching after it before they let go.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change
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 strap moves at once.

More checkboxes

See all
Stud
Ballot
Bow
Bubble