No photo yet · JPG, PNG or WebP
Beads
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldBeads(el) on the mounted element instead.
<div class="field-beads">
<label class="field-beads__strand" for="field-beads">
<svg class="field-beads__cord" viewBox="0 0 262 150" aria-hidden="true">
<path d="M8 30Q12 42 14 44Q131 124 248 44Q250 42 254 30"></path>
<circle cx="14" cy="44" r="3.5"></circle>
<circle cx="248" cy="44" r="3.5"></circle>
</svg>
<span class="field-beads__bead" style="transform:translate(98.8px, 81.0px) rotate(10.8deg)" aria-hidden="true">
<b>M</b>
</span>
<span class="field-beads__bead" style="transform:translate(120.3px, 83.7px) rotate(3.7deg)" aria-hidden="true">
<b>A</b>
</span>
<span class="field-beads__bead" style="transform:translate(142.7px, 83.6px) rotate(-3.7deg)" aria-hidden="true">
<b>Y</b>
</span>
<span class="field-beads__bead" style="transform:translate(164.2px, 80.8px) rotate(-10.8deg)" aria-hidden="true">
<b>A</b>
</span>
<i class="field-beads__gap" style="transform:translate(174.9px, 78.4px) rotate(-14.2deg)" aria-hidden="true"></i>
<input id="field-beads" class="field-beads__input" maxLength="10" autoComplete="off" spellCheck="false" aria-label="Name for the bracelet" value="MAYA"/>
</label>
<p class="field-beads__read" aria-hidden="true">
<b>4</b>
of 10 beads
</p>
</div>.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: translate(245.1px, 46.0px) rotate(-33.6deg); } }
.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: translate(245.1px, 46.0px) rotate(-33.6deg); opacity: 1; } 100% { transform: translate(253.1px, 136.0px) 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; }
}function initFieldBeads(root) {
var MAX = 10;
var P0 = [14, 44];
var C = [131, 124];
var P2 = [248, 44];
var PITCH = 22; // bead width plus the gap, along the cord
var strand = root.querySelector('.field-beads__strand');
var gap = root.querySelector('.field-beads__gap');
var input = root.querySelector('.field-beads__input');
var read = root.querySelector('.field-beads__read');
var beads = [].slice.call(root.querySelectorAll('.field-beads__bead'));
var name = input.value;
var gone = null;
// Sample the cord once: points with their distance along it, for placing beads by length.
var samples = [];
for (var i = 0; i <= 240; i++) {
var t = i / 240;
var u = 1 - t;
samples.push({ 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 });
if (i) samples[i].s = samples[i - 1].s + Math.hypot(samples[i].x - samples[i - 1].x, samples[i].y - samples[i - 1].y);
}
var total = samples[samples.length - 1].s;
function at(s) {
var k = samples.findIndex(function (p) {
return p.s >= s;
});
k = Math.max(1, Math.min(samples.length - 1, k));
var a = samples[k - 1];
var b = samples[k];
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.
function place(n, k) {
return at(total / 2 - (n * PITCH) / 2 + PITCH / 2 + k * PITCH);
}
function pose(p) {
return 'translate(' + p.x.toFixed(1) + 'px, ' + p.y.toFixed(1) + 'px) rotate(' + p.deg.toFixed(1) + 'deg)';
}
function render(born) {
var n = name.length;
while (beads.length > n) beads.pop().remove();
for (var k = 0; k < n; k++) {
if (!beads[k]) {
beads[k] = document.createElement('span');
beads[k].setAttribute('aria-hidden', 'true');
beads[k].appendChild(document.createElement('b'));
strand.insertBefore(beads[k], gap);
}
// Only a bead just added threads in from the end of the cord.
beads[k].className = 'field-beads__bead' + (name[k] === ' ' ? ' field-beads__bead--space' : '') + (k >= born ? ' field-beads__bead--new' : '');
beads[k].querySelector('b').textContent = name[k] === ' ' ? '' : name[k];
beads[k].style.transform = pose(place(n, k));
}
gap.hidden = n >= MAX;
gap.style.transform = pose(place(n + 1, n));
read.innerHTML = '<b>' + n + '</b> of ' + MAX + ' beads';
}
function onInput() {
var next = input.value.toUpperCase().replace(/[^A-Z0-9 ]/g, '').slice(0, MAX);
input.value = next;
// One taken off the end slides back up the cord and off.
if (next.length < name.length && next === name.slice(0, next.length)) {
if (gone) gone.remove();
gone = document.createElement('span');
gone.className = 'field-beads__gone';
gone.setAttribute('aria-hidden', 'true');
gone.style.setProperty('--from', pose(place(name.length, name.length - 1)));
gone.innerHTML = '<b></b>';
gone.firstChild.textContent = name[name.length - 1];
gone.addEventListener('animationend', function (e) {
e.currentTarget.remove();
});
strand.insertBefore(gone, input);
}
var born = Math.min(name.length, next.length);
name = next;
render(born);
}
input.addEventListener('input', onInput);
return function () {
input.removeEventListener('input', onInput);
};
}
document.querySelectorAll('.field-beads').forEach(initFieldBeads);Goal: Create a "Beads" input.
Visual behavior: 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.
Technique:
- A real text input lies transparent over the bracelet; the beads are drawn from its value, uppercased, letters, digits and spaces only.
- The cord is a quadratic curve, sampled once into points with their distance along it, so beads are placed by length and turned to the cord's angle at that point.
- The row stays centred on the lowest point of the cord: adding a bead moves the others along with a springy transform transition, while the new one threads in from the end of the cord and clicks into place.
- A deleted bead slides back up the cord and drops off the end. A space is a small round spacer bead.
Accessibility: 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.
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 "Beads" input.
Visual behavior: 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.
Technique:
- A real text input lies transparent over the bracelet; the beads are drawn from its value, uppercased, letters, digits and spaces only.
- The cord is a quadratic curve, sampled once into points with their distance along it, so beads are placed by length and turned to the cord's angle at that point.
- The row stays centred on the lowest point of the cord: adding a bead moves the others along with a springy transform transition, while the new one threads in from the end of the cord and clicks into place.
- A deleted bead slides back up the cord and drops off the end. A space is a small round spacer bead.
Accessibility: 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.
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 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.