Sewing

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

// Each stitch is one repeating tile, `w` px long, drawn along y = 16.
const STITCHES = [
  { value: 'straight', label: 'Straight', w: 12, tile: (x: number) => `M${x + 2} 16h8` },
  { value: 'zigzag', label: 'Zigzag', w: 10, tile: (x: number) => `M${x} 10L${x + 5} 22L${x + 10} 10` },
  { value: 'cross', label: 'Cross', w: 14, tile: (x: number) => `M${x + 3} 10l8 12M${x + 11} 10l-8 12` },
  { value: 'scallop', label: 'Scallop', w: 16, tile: (x: number) => `M${x} 20q8 -15 16 0` },
];
const W = 262;
const NEEDLE = 176;
const SPEED = 36; // px per second, the fabric's feed
const run = (s: (typeof STITCHES)[number], from: number, to: number) => {
  let d = '';
  for (let x = from; x < to; x += s.w) d += s.tile(x);
  return d;
};

const css = `
  .radio-sewing { --cloth: light-dark(#dbdde1, #232529); --weave: color-mix(in oklab, var(--cloth) 82%, light-dark(#fbfcfd, #121417)); --thread: light-dark(#373a40, #f1f2f3); --steel: light-dark(#6d7179, #a7abb2); position: relative; width: ${W}px; height: 186px; }
  /* The cloth feeds left under the foot; its weave moves with it. */
  .radio-sewing__cloth { position: absolute; left: 0; right: 0; top: 54px; height: 66px; border-radius: 3px; background: repeating-linear-gradient(0deg, transparent 0 2px, var(--weave) 2px 3px), repeating-linear-gradient(90deg, transparent 0 2px, color-mix(in oklab, var(--weave) 60%, transparent) 2px 3px), var(--cloth); box-shadow: 0 8px 14px -8px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6)); animation: radio-sewing-feed ${(24 / SPEED).toFixed(3)}s linear infinite; mask: linear-gradient(90deg, transparent, #000 16px, #000 calc(100% - 16px), transparent); }
  @keyframes radio-sewing-feed { to { background-position: -24px 0, -24px 0, 0 0; } }
  .radio-sewing__guide { position: absolute; left: ${NEEDLE}px; right: 12px; top: 87px; border-top: 1.2px dashed color-mix(in oklab, light-dark(#373a40, #f1f2f3) 25%, transparent); }
  .radio-sewing__layer { position: absolute; left: 0; top: 71px; width: ${W}px; height: 32px; overflow: hidden; clip-path: inset(0 ${W - NEEDLE}px 0 0); display: none; }
  .radio-sewing__layer svg { display: block; width: ${W + 40}px; height: 32px; overflow: visible; fill: none; stroke: var(--thread); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; filter: drop-shadow(0 1px 0 light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35))); }
  .radio-sewing__layer g { animation: radio-sewing-run var(--t) linear infinite; }
  @keyframes radio-sewing-run { to { transform: translateX(calc(var(--w) * -1px)); } }
  .radio-sewing__layer--prev { display: block; z-index: 1; }
  .radio-sewing__layer--cur { display: block; z-index: 2; }
  /* The new stitch starts at the needle and its edge travels off with the cloth. */
  .radio-sewing__layer--cur[data-n='0'] { animation: radio-sewing-start-a ${(NEEDLE / SPEED).toFixed(2)}s linear forwards; }
  .radio-sewing__layer--cur[data-n='1'] { animation: radio-sewing-start-b ${(NEEDLE / SPEED).toFixed(2)}s linear forwards; }
  /* ...while the old one is cut back at the same pace, so the two never overlap. */
  .radio-sewing__layer--prev[data-n='0'] { animation: radio-sewing-end-a ${(NEEDLE / SPEED).toFixed(2)}s linear forwards; }
  .radio-sewing__layer--prev[data-n='1'] { animation: radio-sewing-end-b ${(NEEDLE / SPEED).toFixed(2)}s linear forwards; }
  @keyframes radio-sewing-end-a { from { clip-path: inset(0 ${W - NEEDLE}px 0 0); } to { clip-path: inset(0 ${W}px 0 0); } }
  @keyframes radio-sewing-end-b { from { clip-path: inset(0 ${W - NEEDLE}px 0 0); } to { clip-path: inset(0 ${W}px 0 0); } }
  @keyframes radio-sewing-start-a { from { clip-path: inset(0 ${W - NEEDLE}px 0 ${NEEDLE}px); } to { clip-path: inset(0 ${W - NEEDLE}px 0 0); } }
  @keyframes radio-sewing-start-b { from { clip-path: inset(0 ${W - NEEDLE}px 0 ${NEEDLE}px); } to { clip-path: inset(0 ${W - NEEDLE}px 0 0); } }
  /* The machine: needle bar, presser foot and a needle that goes down once per stitch. */
  .radio-sewing__machine { position: absolute; left: 0; top: 0; width: ${W}px; height: 120px; overflow: visible; pointer-events: none; }
  .radio-sewing__bar { fill: var(--steel); }
  .radio-sewing__foot { fill: color-mix(in oklab, var(--steel) 85%, light-dark(#fbfcfd, #121417)); stroke: color-mix(in oklab, var(--steel) 60%, light-dark(#373a40, #f1f2f3)); stroke-width: 0.8; }
  .radio-sewing__needle { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.6; stroke-linecap: round; animation: radio-sewing-stab var(--t) cubic-bezier(0.45, 0, 0.55, 1) infinite; }
  .radio-sewing__spool { stroke: var(--thread); stroke-width: 0.8; fill: none; opacity: 0.7; }
  @keyframes radio-sewing-stab { 50% { transform: translateY(12px); } }
  .radio-sewing__opts { position: absolute; left: 0; right: 0; bottom: 0; display: flex; justify-content: center; gap: 6px; }
  .radio-sewing__opt { position: relative; display: grid; justify-items: center; gap: 2px; width: 56px; padding: 6px 0 5px; border-radius: 6px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); color: light-dark(#6d7179, #7d8087); cursor: pointer; transition: background-color 0.25s, color 0.25s; }
  .radio-sewing__opt:hover { color: light-dark(#42454c, #e5e6e9); }
  .radio-sewing__opt:has(input:checked) { background: light-dark(#ebedf0, #232529); color: light-dark(#373a40, #f1f2f3); box-shadow: inset 0 0 0 1px light-dark(#6d7179, #7d8087); }
  .radio-sewing__opt:has(input:focus-visible) { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
  .radio-sewing__opt::before { content: ''; position: absolute; right: 6px; top: 6px; width: 4px; height: 4px; border-radius: 50%; background: currentColor; opacity: 0; transition: opacity 0.25s; }
  .radio-sewing__opt:has(input:checked)::before { opacity: 1; }
  .radio-sewing__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
  .radio-sewing__glyph { width: 40px; height: 14px; overflow: visible; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
  .radio-sewing__num { font: 600 8.5px ui-monospace, 'SF Mono', Menlo, Consolas, monospace; letter-spacing: 0.06em; }
  @media (prefers-reduced-motion: reduce) {
    .radio-sewing *, .radio-sewing *::before { animation: none !important; transition: none !important; }
    .radio-sewing__layer--cur { clip-path: inset(0 ${W - NEEDLE}px 0 0); }
  }
`;

export function Preview() {
  const name = useId();
  const [s, setS] = useState({ cur: 1, prev: -1, n: 0 });
  const pick = (i: number) => setS((v) => (v.cur === i ? v : { cur: i, prev: v.cur, n: v.n + 1 }));
  const cur = STITCHES[s.cur];
  return (
    <>
      <style href="kit-radios-sewing" precedence="kit">
        {css}
      </style>
      <div className="radio-sewing" style={{ '--t': `${(cur.w / SPEED).toFixed(3)}s` } as CSSProperties}>
        <span className="radio-sewing__cloth" aria-hidden="true" />
        <span className="radio-sewing__guide" aria-hidden="true" />
        {STITCHES.map((st, i) => (
          <span
            key={st.value}
            className={`radio-sewing__layer${i === s.cur ? ' radio-sewing__layer--cur' : i === s.prev ? ' radio-sewing__layer--prev' : ''}`}
            data-n={s.n % 2}
            style={{ '--w': st.w, '--t': `${(st.w / SPEED).toFixed(3)}s` } as CSSProperties}
            aria-hidden="true"
          >
            <svg viewBox={`0 0 ${W + 40} 32`}>
              <g>
                <path d={run(st, 0, W + 40)} />
              </g>
            </svg>
          </span>
        ))}
        <svg className="radio-sewing__machine" viewBox={`0 0 ${W} 120`} aria-hidden="true">
          <path className="radio-sewing__spool" d={`M${NEEDLE + 9} 0V22L${NEEDLE + 1} 40`} />
          <rect className="radio-sewing__bar" x={NEEDLE + 6} y={-4} width={7} height={52} rx={2} />
          <rect className="radio-sewing__bar" x={NEEDLE - 4} y={8} width={8} height={16} rx={2} />
          <line className="radio-sewing__needle" x1={NEEDLE} y1={22} x2={NEEDLE} y2={68} />
          <path className="radio-sewing__foot" d={`M${NEEDLE - 18} 74Q${NEEDLE - 22} 74 ${NEEDLE - 22} 70Q${NEEDLE - 22} 64 ${NEEDLE - 14} 64H${NEEDLE - 3}V78H${NEEDLE + 3}V64H${NEEDLE + 20}Q${NEEDLE + 24} 64 ${NEEDLE + 24} 69V74ZM${NEEDLE + 7} 46H${NEEDLE + 12}V64H${NEEDLE + 7}Z`} />
        </svg>
        <div className="radio-sewing__opts" role="radiogroup" aria-label="Stitch">
          {STITCHES.map((st, i) => (
            <label key={st.value} className="radio-sewing__opt">
              <input className="radio-sewing__input" type="radio" name={name} value={st.value} aria-label={st.label} checked={s.cur === i} onChange={() => pick(i)} />
              <svg className="radio-sewing__glyph" viewBox="2 4 40 24" aria-hidden="true">
                <path d={run(st, 2, 40)} />
              </svg>
              <span className="radio-sewing__num" aria-hidden="true">
                {String(i + 1).padStart(2, '0')}
              </span>
            </label>
          ))}
        </div>
      </div>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/radio-sewing
Source on GitHub

About this component

A stitch picker on a sewing machine: the needle keeps sewing as the cloth feeds by, and the new stitch starts right at the needle while the old one travels off with the fabric.

Dependencies
None
Difficulty
Advanced
Triggers
click, state change, continuous, loop
Techniques
clip path, keyframes, css variables, svg, state machine
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A role="radiogroup" labelled "Stitch" around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once. Each input is named by its stitch, and focus outlines the option. The machine and cloth are aria-hidden. Under prefers-reduced-motion everything is still and the stitch is drawn up to the needle.

More radios

See all
Stickers
Strings
Units
Egg