Beads

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

const MAX = 10;
const P0 = [14, 44];
const C = [131, 124];
const P2 = [248, 44];
const PITCH = 22; // bead width plus the gap, along the cord
// Sample the cord once: points with their distance along it, for placing beads by length.
const SAMPLES = Array.from({ length: 241 }, (_, i) => {
  const t = i / 240;
  const u = 1 - t;
  return { x: u * u * P0[0] + 2 * u * t * C[0] + t * t * P2[0], y: u * u * P0[1] + 2 * u * t * C[1] + t * t * P2[1], s: 0 };
});
for (let i = 1; i < SAMPLES.length; i++) SAMPLES[i].s = SAMPLES[i - 1].s + Math.hypot(SAMPLES[i].x - SAMPLES[i - 1].x, SAMPLES[i].y - SAMPLES[i - 1].y);
const TOTAL = SAMPLES.at(-1)!.s;
function at(s: number) {
  const i = Math.max(1, Math.min(SAMPLES.length - 1, SAMPLES.findIndex((p) => p.s >= s)));
  const a = SAMPLES[i - 1];
  const b = SAMPLES[i];
  return { x: b.x, y: b.y, deg: (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI };
}
// Beads sit centred on the lowest point of the cord.
const place = (n: number, k: number) => at(TOTAL / 2 - (n * PITCH) / 2 + PITCH / 2 + k * PITCH);
const END = at(TOTAL - 4);
const pose = (p: { x: number; y: number; deg: number }) => `translate(${p.x.toFixed(1)}px, ${p.y.toFixed(1)}px) rotate(${p.deg.toFixed(1)}deg)`;

const css = `
  .field-beads { --bead: light-dark(#fbfcfd, #e5e6e9); --side: color-mix(in oklab, var(--bead) 82%, light-dark(#6d7179, #7d8087)); --print: light-dark(#373a40, #121417); --cord: light-dark(#5f626a, #7d8087); display: grid; justify-items: center; gap: 4px; }
  .field-beads__strand { position: relative; display: block; width: 262px; height: 150px; border-radius: 12px; cursor: text; }
  .field-beads__strand:has(.field-beads__input:focus-visible) { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
  .field-beads__input { position: absolute; inset: 0; z-index: 3; width: 100%; height: 100%; margin: 0; padding: 0; border: 0; background: transparent; color: transparent; caret-color: transparent; font-size: 16px; outline: none; cursor: text; text-transform: uppercase; }
  .field-beads__input::selection { background: transparent; }
  .field-beads__cord { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; fill: none; stroke: var(--cord); stroke-width: 1.6; stroke-linecap: round; }
  .field-beads__cord circle { fill: var(--cord); stroke: none; }
  /* A bead: a letter cube threaded on the cord, turned to follow its curve. */
  .field-beads__bead { position: absolute; left: 0; top: 0; width: 19px; height: 21px; margin: -10.5px 0 0 -9.5px; transition: transform 0.55s cubic-bezier(0.25, 1.3, 0.5, 1); }
  .field-beads__bead > b { display: grid; place-items: center; height: 100%; border-radius: 5px; background: linear-gradient(160deg, var(--bead) 40%, var(--side)); box-shadow: inset -2px -2px 0 color-mix(in oklab, var(--side) 85%, light-dark(#fbfcfd, #121417)), 0 3px 4px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35)); color: var(--print); font: 800 12px/1 ui-rounded, 'SF Pro Rounded', 'Arial Rounded MT Bold', system-ui, sans-serif; }
  .field-beads__bead--space > b { width: 12px; height: 12px; margin: 4.5px auto 0; border-radius: 50%; background: radial-gradient(circle at 35% 30%, var(--bead), var(--side) 75%); }
  .field-beads__bead--new { animation: field-beads-thread 0.6s cubic-bezier(0.2, 0.7, 0.3, 1.1) both; }
  @keyframes field-beads-thread { from { transform: ${pose(END)}; } }
  .field-beads__bead--new > b { animation: field-beads-click 0.2s ease-out 0.5s; }
  @keyframes field-beads-click { 50% { transform: translateX(-1.5px) rotate(-6deg); } }
  .field-beads__gone { position: absolute; left: 0; top: 0; width: 19px; height: 21px; margin: -10.5px 0 0 -9.5px; animation: field-beads-off 0.8s cubic-bezier(0.5, 0, 0.8, 0.6) forwards; pointer-events: none; }
  .field-beads__gone > b { display: grid; place-items: center; height: 100%; border-radius: 5px; background: var(--bead); color: var(--print); font: 800 12px/1 ui-rounded, 'SF Pro Rounded', 'Arial Rounded MT Bold', system-ui, sans-serif; }
  @keyframes field-beads-off { 0% { transform: var(--from); } 45% { transform: ${pose(END)}; opacity: 1; } 100% { transform: translate(${(END.x + 8).toFixed(1)}px, ${(END.y + 90).toFixed(1)}px) rotate(120deg); opacity: 0; } }
  .field-beads__gap { position: absolute; left: 0; top: 0; width: 2px; height: 16px; margin: -8px 0 0 -1px; border-radius: 1px; background: light-dark(#42454c, #e5e6e9); opacity: 0; transition: transform 0.45s cubic-bezier(0.25, 1.3, 0.5, 1); }
  .field-beads__strand:has(.field-beads__input:focus) .field-beads__gap { animation: field-beads-blink 1s steps(1) infinite; }
  @keyframes field-beads-blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
  .field-beads__read { color: light-dark(#5f626a, #a7abb2); font: 500 11.5px system-ui, sans-serif; }
  .field-beads__read b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
  @media (prefers-reduced-motion: reduce) {
    .field-beads * { animation: none !important; transition: none !important; }
    .field-beads__gone { display: none; }
  }
`;

export function Preview() {
  const id = useId();
  const [name, setName] = useState('MAYA');
  const [gone, setGone] = useState<{ ch: string; from: string; n: number } | null>(null);
  const born = useRef(name.length);
  const n = name.length;
  return (
    <>
      <style href="kit-inputs-beads" precedence="kit">
        {css}
      </style>
      <div className="field-beads">
        <label className="field-beads__strand" htmlFor={id}>
          <svg className="field-beads__cord" viewBox="0 0 262 150" aria-hidden="true">
            <path d={`M${P0[0] - 6} ${P0[1] - 14}Q${P0[0] - 2} ${P0[1] - 2} ${P0[0]} ${P0[1]}Q${C[0]} ${C[1]} ${P2[0]} ${P2[1]}Q${P2[0] + 2} ${P2[1] - 2} ${P2[0] + 6} ${P2[1] - 14}`} />
            <circle cx={P0[0]} cy={P0[1]} r={3.5} />
            <circle cx={P2[0]} cy={P2[1]} r={3.5} />
          </svg>
          {[...name].map((ch, k) => (
            <span key={k} className={`field-beads__bead${ch === ' ' ? ' field-beads__bead--space' : ''}${k >= born.current ? ' field-beads__bead--new' : ''}`} style={{ transform: pose(place(n, k)) }} aria-hidden="true">
              <b>{ch === ' ' ? '' : ch}</b>
            </span>
          ))}
          {n < MAX && <i className="field-beads__gap" style={{ transform: pose(place(n + 1, n)) }} aria-hidden="true" />}
          {gone && (
            <span key={gone.n} className="field-beads__gone" style={{ '--from': gone.from } as CSSProperties} aria-hidden="true" onAnimationEnd={() => setGone(null)}>
              <b>{gone.ch}</b>
            </span>
          )}
          <input
            id={id}
            className="field-beads__input"
            maxLength={MAX}
            autoComplete="off"
            spellCheck={false}
            aria-label="Name for the bracelet"
            value={name}
            onChange={(e) => {
              const next = e.target.value.toUpperCase().replace(/[^A-Z0-9 ]/g, '').slice(0, MAX);
              // Only the bead just added slides in; one taken off the end slides away.
              born.current = Math.min(name.length, next.length);
              if (next.length < name.length && next === name.slice(0, next.length)) {
                setGone({ ch: name.at(-1)!, from: pose(place(name.length, name.length - 1)), n: Date.now() });
              }
              setName(next);
            }}
          />
        </label>
        <p className="field-beads__read" aria-hidden="true">
          <b>{n}</b> of {MAX} beads
        </p>
      </div>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/input-beads
Source on GitHub

About this component

A name bracelet: every letter is a bead that slides along the cord into place and clicks against the last, and deleting slides one off the end.

Dependencies
None
Difficulty
Advanced
Triggers
state change, focus, spring, keyframe
Techniques
css transition, keyframes, svg, spring
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A native input named "Name for the bracelet", transparent over the bracelet; focus outlines it and a caret blinks in the next gap. The beads and the count are aria-hidden. Under prefers-reduced-motion beads appear and leave without sliding.

More inputs

See all

No photo yet · JPG, PNG or WebP

Flatbed
  • motion
  • ui kit
Freight
Growth
Jersey