Strings

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

// Standard tuning, high string on top as in tab. Heavier strings are thicker,
// wound, and swing slower.
const STRINGS = [
  { value: 'e4', note: 'e', name: 'E4', hz: '329.63', gauge: 0.9, period: 0.034, wound: false },
  { value: 'b3', note: 'B', name: 'B3', hz: '246.94', gauge: 1.1, period: 0.04, wound: false },
  { value: 'g3', note: 'G', name: 'G3', hz: '196.00', gauge: 1.5, period: 0.046, wound: true },
  { value: 'd3', note: 'D', name: 'D3', hz: '146.83', gauge: 1.9, period: 0.054, wound: true },
  { value: 'a2', note: 'A', name: 'A2', hz: '110.00', gauge: 2.3, period: 0.064, wound: true },
  { value: 'e2', note: 'E', name: 'E2', hz: '82.41', gauge: 2.7, period: 0.076, wound: true },
];
const TOP = 13;
const STEP = 17;
const NUT = 60;
const LEN = 262 - NUT;
const FRETS = [36, 70, 102, 132, 160, 186];
const at = (n: number) => `.radio-strings:has(.radio-strings__row:nth-child(${n + 2}) input:checked)`;

const css = `
  .radio-strings { --board: light-dark(#50535b, #232529); --fret: light-dark(#5f626a, #393c41); --steel: light-dark(#ebedf0, #a7abb2); --bright: light-dark(#fbfcfd, #f1f2f3); display: grid; justify-items: center; gap: 10px; width: 262px; }
  .radio-strings__neck { position: relative; width: 262px; height: ${TOP * 2 + STEP * 5}px; }
  .radio-strings__wood { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
  .radio-strings__head { fill: color-mix(in oklab, var(--board) 88%, light-dark(#fbfcfd, #121417)); }
  .radio-strings__board { fill: var(--board); }
  .radio-strings__fret { fill: var(--fret); }
  .radio-strings__nut { fill: light-dark(#f3f4f7, #c6c8cd); }
  .radio-strings__dot { fill: color-mix(in oklab, var(--board) 60%, light-dark(#373a40, #f1f2f3)); }
  .radio-strings__row { position: absolute; left: 0; right: 0; height: ${STEP}px; cursor: pointer; }
  .radio-strings__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
  .radio-strings__note { position: absolute; left: 0; top: 0; width: 14px; color: light-dark(#6d7179, #7d8087); font: italic 700 13px/${STEP}px Georgia, 'Times New Roman', serif; text-align: center; transition: color 0.25s; }
  .radio-strings__row:hover .radio-strings__note { color: light-dark(#42454c, #e5e6e9); }
  .radio-strings__row:has(input:checked) .radio-strings__note { color: light-dark(#373a40, #f1f2f3); }
  .radio-strings__row:has(input:focus-visible) .radio-strings__note { outline: 1.5px solid light-dark(#50535b, #c6c8cd); border-radius: 3px; }
  /* The peg: a slotted post that turns a little when its string is picked. */
  .radio-strings__peg { position: absolute; left: 33px; top: 3.5px; width: 10px; height: 10px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, var(--bright), var(--steel) 70%); box-shadow: 0 1px 2px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)); transition: rotate 0.5s cubic-bezier(0.3, 1.6, 0.5, 1); }
  .radio-strings__peg::after { content: ''; position: absolute; left: 1px; right: 1px; top: 4.2px; height: 1.6px; background: color-mix(in oklab, var(--steel) 50%, light-dark(#fbfcfd, #121417)); }
  .radio-strings__row:has(input:checked) .radio-strings__peg { rotate: 140deg; }
  .radio-strings__line { position: absolute; left: 38px; right: 0; top: ${STEP / 2}px; height: var(--g); margin-top: calc(var(--g) / -2); background: var(--steel); box-shadow: 0 1px 1px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35)); transition: background-color 0.3s; }
  .radio-strings__line--wound { background: repeating-linear-gradient(90deg, var(--steel) 0 1px, color-mix(in oklab, var(--steel) 70%, light-dark(#fbfcfd, #121417)) 1px 1.6px); }
  .radio-strings__row:has(input:checked) .radio-strings__line { background: var(--bright); }
  /* The ring: a bowed copy flips up and down every period while its amplitude dies away. */
  .radio-strings__ring { position: absolute; left: ${NUT}px; top: 0; width: ${LEN}px; height: ${STEP}px; overflow: visible; opacity: 0; pointer-events: none; }
  .radio-strings__amp { transform-box: view-box; transform-origin: 0 ${STEP / 2}px; transform: scaleY(0.02); }
  .radio-strings__osc { transform-box: view-box; transform-origin: 0 ${STEP / 2}px; }
  .radio-strings__osc path:first-child { fill: none; stroke: var(--bright); stroke-width: calc(var(--g) + 0.5px); vector-effect: non-scaling-stroke; }
  .radio-strings__osc path + path { fill: var(--bright); opacity: 0.3; }
  .radio-strings__row:has(input:checked) .radio-strings__ring { animation: radio-strings-show 1.9s linear; }
  .radio-strings__row:has(input:checked) .radio-strings__amp { animation: radio-strings-decay 1.9s cubic-bezier(0.15, 0.6, 0.3, 1); }
  .radio-strings__row:has(input:checked) .radio-strings__osc { animation: radio-strings-osc var(--p) linear infinite alternate; }
  @keyframes radio-strings-show { 0%, 92% { opacity: 1; } 100% { opacity: 0; } }
  @keyframes radio-strings-decay { from { transform: scaleY(1); } to { transform: scaleY(0.02); } }
  @keyframes radio-strings-osc { from { transform: scaleY(1); } to { transform: scaleY(-1); } }
  /* The tuner: note, frequency and a needle that swings in and settles on zero. */
  .radio-strings__tuner { display: flex; align-items: center; gap: 12px; height: 30px; }
  .radio-strings__read { display: grid; }
  .radio-strings__read > span { grid-area: 1 / 1; display: flex; align-items: baseline; gap: 8px; opacity: 0; transform: translateY(4px); transition: opacity 0.2s, transform 0.3s; }
  .radio-strings__read b { color: light-dark(#373a40, #f1f2f3); font: italic 700 22px/1 Georgia, 'Times New Roman', serif; }
  .radio-strings__read small { color: light-dark(#5f626a, #a7abb2); font: 500 11px ui-monospace, 'SF Mono', Menlo, Consolas, monospace; }
  ${STRINGS.map((_, i) => `${at(i)} .radio-strings__read > :nth-child(${i + 1})`).join(', ')} { opacity: 1; transform: none; transition-delay: 0.1s; }
  .radio-strings__meter { width: 46px; height: 26px; overflow: visible; }
  .radio-strings__meter path { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 1.2; stroke-linecap: round; }
  .radio-strings__meter path + path { stroke: light-dark(#5f626a, #a7abb2); }
  .radio-strings__needle { transform-box: view-box; transform-origin: 23px 25px; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.6; stroke-linecap: round; }
  ${STRINGS.map((_, i) => `${at(i)} .radio-strings__needle { animation: radio-strings-tune${i} 1.6s cubic-bezier(0.3, 0, 0.3, 1); }\n  @keyframes radio-strings-tune${i} { 0% { rotate: ${i % 2 ? 48 : -48}deg; } 30% { rotate: ${i % 2 ? -18 : 18}deg; } 55% { rotate: ${i % 2 ? 7 : -7}deg; } 78% { rotate: ${i % 2 ? -2 : 2}deg; } 100% { rotate: 0deg; } }`).join('\n  ')}
  @media (prefers-reduced-motion: reduce) {
    .radio-strings * { animation: none !important; transition: none !important; }
  }
`;

export function Preview() {
  const name = useId();
  const h = TOP * 2 + STEP * 5;
  return (
    <>
      <style href="kit-radios-strings" precedence="kit">
        {css}
      </style>
      <div className="radio-strings">
        <div className="radio-strings__neck" role="radiogroup" aria-label="String to tune">
          <svg className="radio-strings__wood" viewBox={`0 0 262 ${h}`} aria-hidden="true">
            <path className="radio-strings__head" d={`M26 6Q26 0 32 0H${NUT}V${h}H32Q26 ${h} 26 ${h - 6}Z`} />
            <rect className="radio-strings__board" x={NUT} y={2} width={LEN} height={h - 4} />
            {FRETS.map((x) => (
              <rect key={x} className="radio-strings__fret" x={NUT + x} y={2} width={1.6} height={h - 4} />
            ))}
            {[NUT + 86, NUT + 146].map((x) => (
              <circle key={x} className="radio-strings__dot" cx={x} cy={h / 2} r={3.2} />
            ))}
            <rect className="radio-strings__nut" x={NUT - 4} y={0} width={4} height={h} />
          </svg>
          {STRINGS.map((s, i) => (
            <label key={s.value} className="radio-strings__row" style={{ top: TOP - STEP / 2 + i * STEP, '--g': `${s.gauge}px`, '--p': `${s.period}s` } as CSSProperties}>
              <input className="radio-strings__input" type="radio" name={name} value={s.value} aria-label={`${s.name}, ${s.hz} hertz`} defaultChecked={i === 4} />
              <span className="radio-strings__note" aria-hidden="true">
                {s.note}
              </span>
              <span className={`radio-strings__line${s.wound ? ' radio-strings__line--wound' : ''}`} aria-hidden="true" />
              <span className="radio-strings__peg" aria-hidden="true" />
              <svg className="radio-strings__ring" viewBox={`0 0 ${LEN} ${STEP}`} preserveAspectRatio="none" aria-hidden="true">
                <g className="radio-strings__amp">
                  <g className="radio-strings__osc">
                    <path d={`M0 ${STEP / 2}Q${LEN / 2} ${STEP / 2 - 9} ${LEN} ${STEP / 2}`} />
                    <path d={`M0 ${STEP / 2}Q${LEN / 2} ${STEP / 2 - 9} ${LEN} ${STEP / 2}Q${LEN / 2} ${STEP / 2 + 9} 0 ${STEP / 2}Z`} />
                  </g>
                </g>
              </svg>
            </label>
          ))}
        </div>
        <div className="radio-strings__tuner" aria-hidden="true">
          <svg className="radio-strings__meter" viewBox="0 0 46 26">
            <path d="M5 22A20 20 0 0 1 41 22" />
            <path d="M23 3V7M9 9L11 12M37 9L35 12" />
            <line className="radio-strings__needle" x1={23} y1={25} x2={23} y2={8} />
          </svg>
          <span className="radio-strings__read">
            {STRINGS.map((s) => (
              <span key={s.value}>
                <b>{s.note}</b>
                <small>
                  {s.name} · {s.hz} Hz
                </small>
              </span>
            ))}
          </span>
        </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-strings
Source on GitHub

About this component

A tuner string picker on a guitar neck: the string you pick is plucked and rings out with a real blur envelope, its peg turns, and the tuner needle swings in and settles on the note.

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

A role="radiogroup" labelled "String to tune" around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once. Each string is a <label> across the neck whose input is named with its note and frequency (such as "A2, 110.00 hertz"); the note letters are aria-hidden and focus outlines the letter. Under prefers-reduced-motion nothing vibrates and the needle rests on zero.

More radios

See all
Units
Egg
Elevation
Firmness