Strap
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCbStrap(el) on the mounted element instead.
<label class="cb-strap">
<input class="cb-strap__input" type="checkbox"/>
<svg class="cb-strap__art" viewBox="0 0 96 56" aria-hidden="true">
<rect class="cb-strap__patch" x="10" y="34" width="72" height="13" rx="3"></rect>
<circle class="cb-strap__fuzz" cx="14" cy="37" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="51" cy="39" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="28" cy="41" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="65" cy="43" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="42" cy="45" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="19" cy="38" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="56" cy="40" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="33" cy="42" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="70" cy="44" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="47" cy="37" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="24" cy="39" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="61" cy="41" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="38" cy="43" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="15" cy="45" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="52" cy="38" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="29" cy="40" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="66" cy="42" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="43" cy="44" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="20" cy="37" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="57" cy="39" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="34" cy="41" r="0.9"></circle>
<circle class="cb-strap__fuzz" cx="71" cy="43" r="0.9"></circle>
<line class="cb-strap__fibre" style="--d:0ms" x1="24" y1="35" x2="25" y2="27"></line>
<line class="cb-strap__fibre" style="--d:45ms" x1="34" y1="35" x2="35" y2="22"></line>
<line class="cb-strap__fibre" style="--d:90ms" x1="44" y1="35" x2="45" y2="17"></line>
<line class="cb-strap__fibre" style="--d:135ms" x1="54" y1="35" x2="55" y2="12"></line>
<line class="cb-strap__fibre" style="--d:180ms" x1="64" y1="35" x2="65" y2="7"></line>
<line class="cb-strap__fibre" style="--d:225ms" x1="72" y1="35" x2="73" y2="3"></line>
<g class="cb-strap__flap">
<rect class="cb-strap__band" x="4" y="24" width="82" height="10" rx="3"></rect>
<rect class="cb-strap__hooks" x="12" y="32" width="70" height="2.5" rx="1"></rect>
<path class="cb-strap__tick" pathLength="1" d="M60 29L64 32.5L71 25.5"></path>
</g>
</svg>
<span>Fasten</span>
</label>@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; }
}function initCbStrap(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-strap').forEach(initCbStrap);Goal: Create a "Strap" checkbox.
Visual behavior: 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.
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 strap turns down about its fold with a transition and a press keyframe; the patch is a fixed scatter of loops, so the texture is the same every time.
- Pulling it off stretches the loops up with the strap for a moment. 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 strap moves 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 "Strap" checkbox.
Visual behavior: 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.
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 strap turns down about its fold with a transition and a press keyframe; the patch is a fixed scatter of loops, so the texture is the same every time.
- Pulling it off stretches the loops up with the strap for a moment. 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 strap moves 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 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.