# Pearl Necklace Length Picker on a Velvet Bust with a Knotted Strand That Drapes and Swings Like a Real Chain

> Length selector on a canvas: a knotted pearl strand on a velvet bust drapes under simulated gravity and swings when pulled. Free React and HTML to copy.

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/pearl-necklace-length-picker-on-a-velvet-bust-with-a-knotted-strand-that-drapes-and-swings-like-a-real-chain).

- Page, with a live preview: https://shadcn.exchange/r/weinc/pearl-necklace-length-picker-on-a-velvet-bust-with-a-knotted-strand-that-drapes-and-swings-like-a-real-chain
- Screenshot: https://shadcn.exchange/shot/weinc/pearl-necklace-length-picker-on-a-velvet-bust-with-a-knotted-strand-that-drapes-and-swings-like-a-real-chain/light.png
- Registry item: https://we.inc/r/pearl-necklace-length-picker-on-a-velvet-bust-with-a-knotted-strand-that-drapes-and-swings-like-a-real-chain.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/pearl-necklace-length-picker-on-a-velvet-bust-with-a-knotted-strand-that-drapes-and-swings-like-a-real-chain.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Files

### `components/we-inc/pearl-necklace-length-picker-on-a-velvet-bust-with-a-knotted-strand-that-drapes-and-swings-like-a-real-chain.tsx`

```tsx
import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";

// The lengths on offer. `pearls` is how many show in front of the neck; the longest entry sets the size of the strand.
const lengths = [
  { label: "Choker", size: "40 cm", pearls: 12, price: "£640" },
  { label: "Princess", size: "45 cm", pearls: 16, price: "£820" },
  { label: "Matinee", size: "58 cm", pearls: 22, price: "£1,080" },
  { label: "Opera", size: "76 cm", pearls: 28, price: "£1,460" },
];
const strand = { name: "Akoya strand", detail: "Hand knotted on silk" };
const START = 2; // which length is on the bust at load
const VW = 520, VH = 470; // drawing space; the canvas scales this to its width

export default function PearlNecklaceLengthPicker() {
  const canvas = useRef<HTMLCanvasElement>(null);
  const hit = useRef<HTMLDivElement>(null);
  const radios = useRef<(HTMLButtonElement | null)[]>([]);
  const [pick, setPick] = useState(START);
  const pickRef = useRef(START);
  const api = useRef<{ go: (i: number, fromUser: boolean) => void } | null>(null);

  useEffect(() => {
    const cv = canvas.current, pad = hit.current;
    if (!cv || !pad) return;
    const ctx = cv.getContext("2d");
    if (!ctx) return;
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    let scale = 1;

    const CX = VW / 2, BY = 408, NW = 58, AY = -286; // bust centre, its base line, half the neck width, where the strand leaves the neck
    const NMAX = Math.max(...lengths.map((l) => l.pearls)) + 2, MID = (NMAX - 1) / 2;
    // graduated: the largest pearl hangs at the centre
    const rad = Array.from({ length: NMAX }, (_, i) => 7.2 + 4.2 * Math.pow(1 - Math.abs(i - MID) / (MID + 1), 1.4));
    const rest = Array.from({ length: NMAX - 1 }, (_, i) => rad[i] + rad[i + 1] + 0.9);
    const px = new Float32Array(NMAX), py = new Float32Array(NMAX), ox = new Float32Array(NMAX), oy = new Float32Array(NMAX);
    const s = { n: lengths[START].pearls, lx: -0.3, ly: -0.2, lxTo: -0.3, lyTo: -0.2, grab: -1, gx: 0, gy: 0, t: 0, wind: 0 };

    const grainCv = document.createElement("canvas"); grainCv.width = grainCv.height = 128;
    const gx = grainCv.getContext("2d");
    if (gx) {
      let seed = 3; const rnd = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; };
      for (let i = 0; i < 9000; i++) { const v = rnd(); gx.fillStyle = v > 0.55 ? `rgba(255,255,255,${(v - 0.55) * 0.3})` : `rgba(0,0,0,${(0.55 - v) * 0.4})`; gx.fillRect(rnd() * 128, rnd() * 128, 0.6 + rnd() * 0.7, 0.6 + rnd() * 0.7); }
    }
    const grain = ctx.createPattern(grainCv, "repeat");

    const bust = () => {
      ctx.beginPath(); ctx.moveTo(-NW, -344);
      ctx.bezierCurveTo(-NW, -288, -NW - 2, -264, -102, -242); ctx.bezierCurveTo(-154, -224, -190, -198, -194, -150);
      ctx.bezierCurveTo(-198, -100, -152, -30, -130, 4); ctx.bezierCurveTo(-70, 24, 70, 24, 130, 4);
      ctx.bezierCurveTo(152, -30, 198, -100, 194, -150); ctx.bezierCurveTo(190, -198, 154, -224, 102, -242);
      ctx.bezierCurveTo(NW + 2, -264, NW, -288, NW, -344); ctx.bezierCurveTo(NW, -362, -NW, -362, -NW, -344); ctx.closePath();
    };

    // which pearls are in front of the neck, and how far the next one has been fed round
    const span = () => { const h = s.n / 2, lo = Math.floor(MID - h) + 1, hi = Math.ceil(MID + h) - 1; return { lo, hi, fl: lo - (MID - h), fr: MID + h - hi }; };

    const step = (dt: number) => {
      const { lo, hi, fl, fr } = span();
      for (let i = 0; i < NMAX; i++) {
        if (i < lo) { px[i] = ox[i] = -NW; py[i] = oy[i] = AY; continue; }
        if (i > hi) { px[i] = ox[i] = NW; py[i] = oy[i] = AY; continue; }
        if (i === s.grab) continue;
        // the bust leans back, so the pearls ride on velvet: gravity is softened and motion dies away quickly
        const vx = (px[i] - ox[i]) * 0.986, vy = (py[i] - oy[i]) * 0.986;
        ox[i] = px[i]; oy[i] = py[i];
        px[i] += vx + s.wind * dt * dt; py[i] += vy + 1500 * dt * dt;
      }
      if (s.grab >= lo && s.grab <= hi) {
        // you cannot pull a pearl further than the strand on either side of it allows
        let tx = s.gx, ty = Math.max(AY - 6, s.gy), reachL = rest[Math.max(0, lo - 1)] * fl, reachR = rest[Math.min(NMAX - 2, hi)] * fr;
        for (let i = lo; i < s.grab; i++) reachL += rest[i];
        for (let i = s.grab; i < hi; i++) reachR += rest[i];
        for (let k = 0; k < 4; k++) {
          let d = Math.hypot(tx + NW, ty - AY); if (d > reachL) { tx = -NW + ((tx + NW) * reachL) / d; ty = AY + ((ty - AY) * reachL) / d; }
          d = Math.hypot(tx - NW, ty - AY); if (d > reachR) { tx = NW + ((tx - NW) * reachR) / d; ty = AY + ((ty - AY) * reachR) / d; }
        }
        ox[s.grab] = px[s.grab]; oy[s.grab] = py[s.grab]; px[s.grab] += (tx - px[s.grab]) * 0.5; py[s.grab] += (ty - py[s.grab]) * 0.5;
      }
      for (let it = 0; it < 18; it++) {
        for (let i = Math.max(0, lo - 1); i <= Math.min(NMAX - 2, hi); i++) {
          const j = i + 1, want = rest[i] * (i === lo - 1 ? fl : i === hi ? fr : 1);
          const dx = px[j] - px[i], dy = py[j] - py[i], d = Math.hypot(dx, dy) || 0.001, k = (d - want) / d;
          const fi = i < lo || i === s.grab ? 0 : 1, fj = j > hi || j === s.grab ? 0 : 1, sum = fi + fj;
          if (!sum) continue;
          px[i] += dx * k * (fi / sum); py[i] += dy * k * (fi / sum); px[j] -= dx * k * (fj / sum); py[j] -= dy * k * (fj / sum);
        }
        if (it % 3 === 0) {
          // pearls are solid: two that are not neighbours still cannot share a place
          for (let i = lo; i <= hi; i++) for (let j = i + 2; j <= hi; j++) {
            const dx = px[j] - px[i], dy = py[j] - py[i], min = rad[i] + rad[j];
            if (Math.abs(dx) >= min || Math.abs(dy) >= min) continue;
            const d = Math.hypot(dx, dy) || 0.001; if (d >= min) continue;
            const k = ((min - d) / d) * 0.5, fi = i === s.grab ? 0 : 1, fj = j === s.grab ? 0 : 1;
            px[i] -= dx * k * fi; py[i] -= dy * k * fi; px[j] += dx * k * fj; py[j] += dy * k * fj;
          }
        }
      }
    };
    const relax = () => {
      const { lo, hi } = span();
      for (let i = 0; i < NMAX; i++) { const u = Math.max(0, Math.min(1, (i - lo + 1) / (hi - lo + 2))); px[i] = ox[i] = -NW + 2 * NW * u; py[i] = oy[i] = AY + Math.sin(u * Math.PI) * s.n * 8; }
      for (let k = 0; k < 500; k++) step(1 / 120);
      for (let i = 0; i < NMAX; i++) { ox[i] = px[i]; oy[i] = py[i]; }
    };

    const pearl = (x: number, y: number, r: number) => {
      const hx = x + (-0.34 + s.lx * 0.3) * r, hy = y + (-0.4 + s.ly * 0.22) * r;
      // body: nacre is brightest under the lamp and falls to a warm grey before the edge
      let g = ctx.createRadialGradient(hx, hy, r * 0.05, x, y, r);
      g.addColorStop(0, "#ffffff"); g.addColorStop(0.2, "#fdf8f1"); g.addColorStop(0.5, "#efe5da"); g.addColorStop(0.78, "#c9b9ad"); g.addColorStop(0.93, "#93857f"); g.addColorStop(1, "#6f6463");
      ctx.beginPath(); ctx.arc(x, y, r, 0, 6.3); ctx.fillStyle = g; ctx.fill();
      ctx.save(); ctx.clip();
      // the dark velvet mirrored in the lower half, then light coming back up off it along the rim
      g = ctx.createLinearGradient(x, y - r * 0.1, x, y + r);
      g.addColorStop(0, "rgba(34,28,36,0)"); g.addColorStop(0.55, "rgba(34,28,36,0.3)"); g.addColorStop(0.8, "rgba(34,28,36,0.16)"); g.addColorStop(1, "rgba(255,238,230,0.5)");
      ctx.fillStyle = g; ctx.fillRect(x - r, y - r, r * 2, r * 2);
      // orient: the rose and green that float on a good pearl
      g = ctx.createRadialGradient(x - r * 0.5, y - r * 0.1, 0, x - r * 0.5, y - r * 0.1, r * 1.1);
      g.addColorStop(0, "rgba(255,170,190,0.2)"); g.addColorStop(1, "rgba(255,170,190,0)");
      ctx.fillStyle = g; ctx.fillRect(x - r, y - r, r * 2, r * 2);
      g = ctx.createRadialGradient(x + r * 0.55, y + r * 0.2, 0, x + r * 0.55, y + r * 0.2, r);
      g.addColorStop(0, "rgba(150,230,210,0.2)"); g.addColorStop(1, "rgba(150,230,210,0)");
      ctx.fillStyle = g; ctx.fillRect(x - r, y - r, r * 2, r * 2);
      ctx.restore();
      // the lamp itself, soft edged because nacre is not glass
      g = ctx.createRadialGradient(hx, hy, 0, hx, hy, r * 0.34);
      g.addColorStop(0, "rgba(255,255,255,1)"); g.addColorStop(0.45, "rgba(255,255,255,0.8)"); g.addColorStop(1, "rgba(255,255,255,0)");
      ctx.fillStyle = g; ctx.beginPath(); ctx.arc(hx, hy, r * 0.34, 0, 6.3); ctx.fill();
    };

    const draw = () => {
      ctx.setTransform(scale, 0, 0, scale, 0, 0);
      ctx.clearRect(0, 0, VW, VH);
      ctx.translate(CX, BY);
      // shadow of the bust on the table
      ctx.save(); ctx.translate(-s.lx * 14, 18); ctx.scale(1, 0.15);
      let g = ctx.createRadialGradient(0, 0, 40, 0, 0, 200);
      g.addColorStop(0, "rgba(58,30,26,0.62)"); g.addColorStop(0.55, "rgba(58,30,26,0.28)"); g.addColorStop(1, "rgba(58,30,26,0)");
      ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0, 0, 200, 0, 6.3); ctx.fill(); ctx.restore();

      // black velvet: dark where you face the pile, pale along every edge that turns away
      bust(); ctx.fillStyle = "#121013"; ctx.fill();
      ctx.save(); bust(); ctx.clip();
      const lampX = s.lx * 120, lampY = -150 + s.ly * 60;
      g = ctx.createRadialGradient(lampX, lampY, 10, lampX, lampY, 250);
      g.addColorStop(0, "rgba(96,90,108,0.5)"); g.addColorStop(0.5, "rgba(60,56,70,0.2)"); g.addColorStop(1, "rgba(40,36,48,0)");
      ctx.fillStyle = g; ctx.fillRect(-240, -370, 480, 410);
      ctx.save(); ctx.shadowColor = "rgba(176,168,196,0.9)"; ctx.shadowBlur = 30 * scale; ctx.lineWidth = 7; ctx.strokeStyle = "rgba(176,168,196,0.55)"; bust(); ctx.stroke();
      ctx.shadowColor = "rgba(226,220,236,0.8)"; ctx.shadowBlur = 7 * scale; ctx.lineWidth = 1.5; bust(); ctx.stroke(); ctx.restore();
      // the neck is a column: its own soft band of light, and shade where it meets the shoulders
      g = ctx.createLinearGradient(-NW, 0, NW, 0);
      const nh = 0.5 + s.lx * 0.3;
      g.addColorStop(0, "rgba(0,0,0,0)"); g.addColorStop(Math.max(0.05, nh - 0.3), "rgba(0,0,0,0.25)"); g.addColorStop(nh, "rgba(120,114,134,0.22)"); g.addColorStop(Math.min(0.95, nh + 0.3), "rgba(0,0,0,0.3)"); g.addColorStop(1, "rgba(0,0,0,0)");
      ctx.fillStyle = g; ctx.fillRect(-NW, -348, NW * 2, 96);
      g = ctx.createLinearGradient(0, -282, 0, -214);
      g.addColorStop(0, "rgba(0,0,0,0)"); g.addColorStop(0.5, "rgba(0,0,0,0.34)"); g.addColorStop(1, "rgba(0,0,0,0)");
      ctx.fillStyle = g; ctx.fillRect(-150, -282, 300, 68);
      g = ctx.createLinearGradient(0, -40, 0, 26);
      g.addColorStop(0, "rgba(0,0,0,0)"); g.addColorStop(1, "rgba(0,0,0,0.5)");
      ctx.fillStyle = g; ctx.fillRect(-230, -40, 460, 70);
      if (grain) { ctx.globalAlpha = 0.7; ctx.fillStyle = grain; ctx.fillRect(-240, -370, 480, 410); ctx.globalAlpha = 1; }
      ctx.restore();
      // the cut top of the neck
      ctx.beginPath(); ctx.ellipse(0, -344, NW, 14, 0, 0, 6.3);
      g = ctx.createLinearGradient(-NW, 0, NW, 0); g.addColorStop(0, "#2a262e"); g.addColorStop(nh, "#57515f"); g.addColorStop(1, "#211e25");
      ctx.fillStyle = g; ctx.fill(); ctx.lineWidth = 1; ctx.strokeStyle = "rgba(214,206,226,0.5)"; ctx.stroke();

      // the strand passes behind the neck, so pearls are only cut off where they meet its edge
      const { lo, hi } = span();
      ctx.save(); ctx.beginPath(); ctx.rect(-NW, -370, NW * 2, 120); ctx.rect(-VW, AY + 3, VW * 2, VH); ctx.clip();
      // light the pearls throw onto the velvet under them
      ctx.save(); ctx.globalCompositeOperation = "lighter";
      for (let i = lo; i <= hi; i++) { const r = rad[i] * 2; g = ctx.createRadialGradient(px[i], py[i] + 2, rad[i] * 0.7, px[i], py[i] + 2, r); g.addColorStop(0, "rgba(255,236,226,0.08)"); g.addColorStop(1, "rgba(255,236,226,0)"); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(px[i], py[i] + 2, r, 0, 6.3); ctx.fill(); }
      ctx.restore();
      // silk and its knots show in the gaps
      ctx.beginPath();
      for (let i = Math.max(0, lo - 1); i <= Math.min(NMAX - 1, hi + 1); i++) { if (i === Math.max(0, lo - 1)) ctx.moveTo(px[i], py[i]); else ctx.lineTo(px[i], py[i]); }
      ctx.lineWidth = 1.6; ctx.lineJoin = "round"; ctx.strokeStyle = "#e6dccb"; ctx.stroke();
      const order: number[] = [];
      for (let i = Math.max(0, lo - 1); i <= Math.min(NMAX - 1, hi + 1); i++) order.push(i);
      order.sort((a, b) => Math.abs(b - MID) - Math.abs(a - MID));
      for (const i of order) pearl(px[i], py[i], rad[i]);
      ctx.restore();
    };

    const fit = () => {
      const w = cv.clientWidth; if (!w) return;
      const dpr = Math.min(2, window.devicePixelRatio || 1);
      cv.width = Math.round(w * dpr); cv.height = Math.round(((w * VH) / VW) * dpr); scale = cv.width / VW; draw();
    };

    let raf = 0, last = 0, t0 = 0, seen = true, touched = -1e9, nextDemo = 3400, act = 0, pluck = -1, pluckUntil = 0;
    const select = (i: number) => { if (pickRef.current !== i) { pickRef.current = i; setPick(i); } };
    const frame = (now: number) => {
      raf = 0;
      if (!t0) { t0 = now; last = now; }
      const dt = Math.min(0.033, (now - last) / 1000); last = now;
      const t = now - t0; s.t = t / 1000;
      const target = lengths[pickRef.current].pearls;
      s.n += Math.max(-dt * 16, Math.min(dt * 16, target - s.n)); // pearls are fed round the neck at a steady rate
      if (now - touched > 6000) {
        s.lxTo = Math.sin(t / 1600) * 0.8; s.lyTo = Math.cos(t / 2300) * 0.5;
        if (t > nextDemo && s.grab < 0) {
          // the demo lifts the strand to one side and lets it swing, then tries another length
          if (act % 2 === 0) { const sp = span(); pluck = Math.round(MID + (act % 4 === 0 ? -1 : 1) * (sp.hi - sp.lo) * 0.2); pluckUntil = t + 520; nextDemo = t + 3000; }
          else { select((pickRef.current + 1) % lengths.length); nextDemo = t + 3200; }
          act++;
        }
        if (pluck >= 0) {
          if (t < pluckUntil) { const dir = pluck < MID ? -1 : 1; s.grab = pluck; s.gx = px[pluck] + dir * 5; s.gy = py[pluck] - 4; }
          else { s.grab = -1; pluck = -1; }
        }
      } else if (pluck >= 0) { s.grab = -1; pluck = -1; }
      s.wind = Math.sin(s.t * 1.1) * 70;
      s.lx += (s.lxTo - s.lx) * Math.min(1, dt * 5); s.ly += (s.lyTo - s.ly) * Math.min(1, dt * 5);
      step(dt / 2); step(dt / 2);
      draw();
      if (seen && !document.hidden) raf = requestAnimationFrame(frame);
    };
    const wake = () => { if (!raf && !reduce) { last = performance.now(); raf = requestAnimationFrame(frame); } };
    const still = () => { s.n = lengths[pickRef.current].pearls; s.grab = -1; s.lx = s.lxTo; s.ly = s.lyTo; relax(); draw(); };

    api.current = { go: (i, fromUser) => { if (fromUser) touched = performance.now(); select(i); if (reduce) still(); else wake(); } };

    const local = (e: PointerEvent) => { const b = cv.getBoundingClientRect(); return { x: ((e.clientX - b.left) / b.width) * VW - CX, y: ((e.clientY - b.top) / b.height) * VH - BY }; };
    const onDown = (e: PointerEvent) => {
      const p = local(e), { lo, hi } = span();
      let best = -1, bd = 34;
      for (let i = lo; i <= hi; i++) { const d = Math.hypot(px[i] - p.x, py[i] - p.y); if (d < bd) { bd = d; best = i; } }
      touched = performance.now(); pluck = -1;
      if (best < 0 || reduce) return;
      s.grab = best; s.gx = p.x; s.gy = p.y; pad.setPointerCapture(e.pointerId); e.preventDefault(); wake();
    };
    const onMove = (e: PointerEvent) => {
      const p = local(e);
      s.lxTo = Math.max(-1, Math.min(1, p.x / (VW / 2))); s.lyTo = Math.max(-1, Math.min(1, (p.y + 200) / 260));
      touched = Math.max(touched, performance.now() - 3000);
      if (s.grab >= 0 && pluck < 0) { touched = performance.now(); s.gx = p.x; s.gy = p.y; }
      if (reduce) { s.lx = s.lxTo; s.ly = s.lyTo; draw(); }
    };
    const onUp = () => { if (pluck < 0) s.grab = -1; touched = performance.now(); };
    pad.addEventListener("pointerdown", onDown);
    pad.addEventListener("pointermove", onMove);
    pad.addEventListener("pointerup", onUp);
    pad.addEventListener("pointercancel", onUp);
    const ro = new ResizeObserver(fit); ro.observe(cv);
    const io = new IntersectionObserver(([en]) => { seen = en.isIntersecting; if (seen) wake(); });
    io.observe(cv);
    const onVis = () => { if (!document.hidden) wake(); };
    document.addEventListener("visibilitychange", onVis);
    relax(); fit();
    if (!reduce) wake();
    return () => {
      cancelAnimationFrame(raf); api.current = null; ro.disconnect(); io.disconnect();
      pad.removeEventListener("pointerdown", onDown); pad.removeEventListener("pointermove", onMove);
      pad.removeEventListener("pointerup", onUp); pad.removeEventListener("pointercancel", onUp);
      document.removeEventListener("visibilitychange", onVis);
    };
  }, []);

  const choose = (i: number) => { pickRef.current = i; setPick(i); api.current?.go(i, true); };
  const onKey = (e: ReactKeyboardEvent, i: number) => {
    const next = e.key === "ArrowRight" || e.key === "ArrowDown" ? (i + 1) % lengths.length : e.key === "ArrowLeft" || e.key === "ArrowUp" ? (i + lengths.length - 1) % lengths.length : e.key === "Home" ? 0 : e.key === "End" ? lengths.length - 1 : -1;
    if (next < 0) return;
    e.preventDefault(); choose(next); radios.current[next]?.focus();
  };
  const now = lengths[pick];

  return (
    <section className="relative flex min-h-[560px] w-full flex-col items-center overflow-hidden bg-[#dcc5bd] bg-[radial-gradient(80%_64%_at_50%_40%,#f3e6e0_0%,#e2cdc5_56%,#cdb2a9_100%)] px-4 pb-6 pt-4 sm:min-h-[720px] sm:justify-center sm:pb-4">
      <div className="relative w-full max-w-[640px] sm:-mt-16">
        <canvas ref={canvas} aria-hidden="true" className="block w-full" style={{ aspectRatio: `${VW} / ${VH}` }} />
        <div ref={hit} aria-hidden="true" className="absolute inset-0 cursor-grab touch-pan-y active:cursor-grabbing" />
      </div>
      <div className="flex w-full max-w-[540px] flex-col gap-4 sm:absolute sm:bottom-8 sm:left-8 sm:right-8 sm:w-auto sm:max-w-none sm:flex-row sm:items-end sm:justify-between">
        <p className="pointer-events-none text-[#5a443e]">
          <span className="block font-serif text-[17px] italic leading-tight text-[#2a1c19]">{strand.name}</span>
          <span className="mt-1 block font-mono text-[10.5px] uppercase tracking-[0.18em]">{strand.detail}</span>
          <span aria-live="polite" className="mt-1 block font-mono text-[10.5px] uppercase tracking-[0.18em]">
            {now.label} <span className="mx-1 text-[#9c827a]">/</span> {now.size} <span className="mx-1 text-[#9c827a]">/</span> <span className="text-[#2a1c19]">{now.price}</span>
          </span>
        </p>
        <div role="radiogroup" aria-label="Necklace length" className="flex flex-wrap gap-1.5">
          {lengths.map((l, i) => (
            <button
              key={l.label}
              ref={(el) => { radios.current[i] = el; }}
              type="button"
              role="radio"
              aria-checked={pick === i}
              tabIndex={pick === i ? 0 : -1}
              onClick={() => choose(i)}
              onKeyDown={(e) => onKey(e, i)}
              className={`h-10 rounded-full border px-3.5 font-mono text-[10.5px] uppercase tracking-[0.14em] outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-[#2a1c19] focus-visible:ring-offset-2 focus-visible:ring-offset-[#dcc5bd] motion-reduce:transition-none ${pick === i ? "border-[#1b1416] bg-[#1b1416] text-[#fbf1ea]" : "border-[#8f746c] bg-transparent text-[#3d2b27] hover:border-[#1b1416]"}`}
            >
              {l.label}
            </button>
          ))}
        </div>
      </div>
    </section>
  );
}
```
