Interactive
StringHarp
A walnut lever harp. Sweep across the strings and each one bends, shivers and rings at its own pitch, with a real plucked-string sound. Red strings mark the octaves. Arrow keys walk the strings, Enter plucks.
Download
- React
- Tailwind CSS v4
- motion
components/ui/string-harp.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
type StringHarpProps = {
/** Frequency of each string in Hz, longest string first. */
notes?: number[];
/** Play the strings. Browsers stay silent until the first press or key. */
sound?: boolean;
/** Called with the string index on every pluck. */
onPluck?: (index: number) => void;
/** Read out by screen readers. */
label?: string;
className?: string;
};
const W = 440;
const H = 340;
const FIRST = 98; // Longest string.
const LAST = 340; // Shortest string.
const D_PENTATONIC = [0, 2, 4, 7, 9, 12, 14, 16, 19, 21, 24, 26, 28].map((s) => 146.83 * 2 ** (s / 12));
const ease = (t: number) => t * t * (3 - 2 * t);
const neckY = (x: number) => {
const t = (x - 50) / 345;
return 36 + 80 * ease(Math.min(1, t)) - 18 * Math.sin(Math.PI * t);
};
const boxY = (x: number) => 288 - (x - 60) * 0.5;
const curve = (off: number) => (x: number, peak = 0.5) => (a: number, b: number) => {
const y = a + (b - a) * peak;
return `M${x} ${a}Q${x + off * 2} ${y} ${x} ${b}`;
};
const NECK = (() => {
const top: string[] = [];
const low: string[] = [];
for (let x = 44; x <= 398; x += 6) {
const y = neckY(x);
const thick = 7 + 5 * ((x - 44) / 354);
top.push(`${x} ${(y - thick).toFixed(1)}`);
low.unshift(`${x} ${(y + thick).toFixed(1)}`);
}
return `M${top.join("L")}L${low.join("L")}Z`;
})();
/* A short bright noise burst fed through a decaying loop: the Karplus-Strong pluck. */
function pluckBuffer(audio: AudioContext, hz: number) {
const rate = audio.sampleRate;
const size = Math.round(rate / hz);
const length = Math.floor(rate * 1.8);
const buffer = audio.createBuffer(1, length, rate);
const out = buffer.getChannelData(0);
const ring = new Float32Array(size);
let soft = 0;
for (let i = 0; i < size; i++) {
soft = soft * 0.5 + (Math.random() * 2 - 1) * 0.5;
ring[i] = soft;
}
for (let n = 0; n < length; n++) {
const i = n % size;
out[n] = ring[i];
ring[i] = (ring[i] + ring[(i + 1) % size]) * 0.4985;
}
return buffer;
}
type Engine = { pluck: (i: number, push: number) => void; move: (x: number, y: number) => void; leave: () => void; unlock: () => void };
/* StringHarp: a small walnut lever harp. Sweep across the strings and each one bends, shivers and rings at its own pitch; red strings mark the octaves like on a real harp. */
export function StringHarp({ notes = D_PENTATONIC, sound = true, onPluck, label = "Harp", className = "" }: StringHarpProps) {
const reduce = useReducedMotion();
const svg = useRef<SVGSVGElement>(null);
const lines = useRef<(SVGPathElement | null)[]>([]);
const hits = useRef<(SVGRectElement | null)[]>([]);
const engine = useRef<Engine | null>(null);
const callback = useRef(onPluck);
const [focus, setFocus] = useState(0);
const [armed, setArmed] = useState(false);
const n = notes.length;
const xAt = (i: number) => (n > 1 ? FIRST + (i * (LAST - FIRST)) / (n - 1) : (FIRST + LAST) / 2);
useEffect(() => {
callback.current = onPluck;
}, [onPluck]);
useEffect(() => {
const xs = notes.map((_, i) => (n > 1 ? FIRST + (i * (LAST - FIRST)) / (n - 1) : (FIRST + LAST) / 2));
const off = new Float32Array(n);
const vel = new Float32Array(n);
const buffers = new Map<number, AudioBuffer>();
let audio: AudioContext | undefined;
let unlocked = false;
let raf = 0;
let then = 0;
let lastX: number | null = null;
const draw = (i: number) => {
lines.current[i]?.setAttribute("d", curve(off[i])(xs[i])(neckY(xs[i]) + 2, boxY(xs[i]) - 3));
};
const tick = (now: number) => {
const dt = Math.min(0.033, (now - then) / 1000 || 0.016);
then = now;
let moving = false;
for (let i = 0; i < n; i++) {
if (off[i] === 0 && vel[i] === 0) continue;
const k = 1500 + 1700 * (i / n); // Shorter strings shiver faster.
const steps = 4;
for (let s = 0; s < steps; s++) {
vel[i] += (-k * off[i] - 5.5 * vel[i]) * (dt / steps);
off[i] += vel[i] * (dt / steps);
}
if (Math.abs(off[i]) < 0.04 && Math.abs(vel[i]) < 2) {
off[i] = 0;
vel[i] = 0;
} else moving = true;
draw(i);
}
raf = moving ? requestAnimationFrame(tick) : 0;
};
const ring = (i: number, push: number) => {
if (!sound || !unlocked || !audio) return;
const hz = notes[i];
let buffer = buffers.get(hz);
if (!buffer) {
buffer = pluckBuffer(audio, hz);
buffers.set(hz, buffer);
}
const source = audio.createBufferSource();
const gain = audio.createGain();
source.buffer = buffer;
gain.gain.value = 0.28 + Math.min(0.42, Math.abs(push) * 0.035);
source.connect(gain).connect(audio.destination);
source.start();
};
const pluck = (i: number, push: number) => {
ring(i, push);
callback.current?.(i);
if (reduce) {
lines.current[i]?.animate([{ opacity: 0.35 }, { opacity: 1 }], { duration: 320 });
return;
}
off[i] = push;
vel[i] = 0;
if (!raf) {
then = performance.now();
raf = requestAnimationFrame(tick);
}
};
engine.current = {
pluck,
move(x, y) {
if (lastX !== null && lastX !== x) {
const dir = Math.sign(x - lastX);
const push = dir * (4 + Math.min(9, Math.abs(x - lastX) * 0.3));
const order = xs.map((_, i) => i).filter((i) => (lastX! - xs[i]) * (x - xs[i]) <= 0 && y > neckY(xs[i]) && y < boxY(xs[i]));
if (dir < 0) order.reverse();
order.forEach((i) => pluck(i, push));
}
lastX = x;
},
leave() {
lastX = null;
},
unlock() {
if (!sound) return;
audio ??= new AudioContext();
void audio.resume();
unlocked = true;
setArmed(true);
},
};
return () => {
cancelAnimationFrame(raf);
engine.current = null;
void audio?.close();
};
}, [notes, n, sound, reduce]);
const local = (e: React.PointerEvent) => {
const box = svg.current!.getBoundingClientRect();
return { x: ((e.clientX - box.left) / box.width) * W, y: ((e.clientY - box.top) / box.height) * H };
};
const onDown = (e: React.PointerEvent) => {
const { x, y } = local(e);
engine.current?.unlock();
let near = -1;
for (let i = 0; i < n; i++) if (Math.abs(xAt(i) - x) < 9 && y > neckY(xAt(i)) && y < boxY(xAt(i))) near = i;
if (near >= 0) engine.current?.pluck(near, 8 * (x < xAt(near) ? -1 : 1));
engine.current?.leave();
engine.current?.move(x, y);
};
const onKey = (i: number) => (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
engine.current?.unlock();
engine.current?.pluck(i, 9);
}
const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (step) {
e.preventDefault();
const next = (i + step + n) % n;
setFocus(next);
hits.current[next]?.focus();
}
};
return (
<svg
ref={svg}
viewBox={`0 0 ${W} ${H}`}
role="group"
aria-label={label}
className={`block h-auto touch-none select-none ${className}`}
onPointerDown={onDown}
onMouseDown={(e) => e.preventDefault()}
onPointerMove={(e) => {
const { x, y } = local(e);
engine.current?.move(x, y);
}}
onPointerLeave={() => engine.current?.leave()}
>
<defs>
<linearGradient id="harp-wood" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#8a4f27" />
<stop offset="0.5" stopColor="#5e2f15" />
<stop offset="1" stopColor="#38190a" />
</linearGradient>
<linearGradient id="harp-face" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stopColor="#c58d51" />
<stop offset="1" stopColor="#e3b878" />
</linearGradient>
<linearGradient id="harp-brass" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stopColor="#8d6a25" />
<stop offset="0.45" stopColor="#f3d98c" />
<stop offset="1" stopColor="#8d6a25" />
</linearGradient>
</defs>
{/* Sound box, then the spruce face the strings end on. */}
<path d={`M40 ${boxY(40) + 8}L404 ${boxY(404) - 4}L402 ${boxY(402) + 16}L120 ${H - 8}L44 ${H - 8}Z`} fill="url(#harp-wood)" />
<path d={`M40 ${boxY(40)}L404 ${boxY(404) - 10}L404 ${boxY(404) + 6}L40 ${boxY(40) + 22}Z`} fill="url(#harp-face)" />
<path d={`M40 ${boxY(40) + 8.5}L404 ${boxY(404) - 1.5}`} stroke="#6f3f1c" strokeOpacity="0.55" strokeWidth="5" />
{[150, 225, 300].map((x) => (
<ellipse key={x} cx={x} cy={boxY(x) + 22 + (300 - x) * 0.04} rx="9" ry="5.5" transform={`rotate(-26 ${x} ${boxY(x) + 22})`} fill="#1c0c04" stroke="#a9743c" strokeWidth="1.2" />
))}
{/* Strings sit under the neck so the pins cover their tops. */}
{notes.map((_, i) => {
const x = xAt(i);
const top = neckY(x);
const bottom = boxY(x);
const octave = i % 5 === 0;
return (
<g key={i}>
<path
ref={(el) => {
lines.current[i] = el;
}}
d={curve(0)(x)(top + 2, bottom - 3)}
fill="none"
stroke={octave ? "#c5352c" : "#ecd9a8"}
strokeWidth={2.1 - (i / n) * 0.8}
strokeLinecap="round"
/>
<circle cx={x} cy={bottom - 2} r="2.4" fill="url(#harp-brass)" />
</g>
);
})}
{/* Neck, pins, pillar. */}
<path d={NECK} fill="url(#harp-wood)" stroke="#2a1206" strokeWidth="0.8" />
{notes.map((_, i) => (
<circle key={i} cx={xAt(i)} cy={neckY(xAt(i))} r="3.1" fill="url(#harp-brass)" stroke="#3d2109" strokeWidth="0.6" />
))}
<rect x="42" y="30" width="19" height={H - 38} rx="5" fill="url(#harp-wood)" stroke="#2a1206" strokeWidth="0.8" />
<rect x="47.5" y="52" width="2.2" height={H - 84} rx="1" fill="#a8703a" fillOpacity="0.55" />
<rect x="53.5" y="52" width="2.2" height={H - 84} rx="1" fill="#1d0b03" fillOpacity="0.55" />
<rect x="38" y="22" width="27" height="15" rx="4" fill="url(#harp-brass)" />
<rect x="34" y={H - 16} width="96" height="12" rx="4" fill="url(#harp-brass)" />
<circle cx="400" cy={neckY(398) + 4} r="11" fill="url(#harp-brass)" stroke="#3d2109" strokeWidth="0.8" />
{sound && !armed && (
<text x={W - 10} y={H - 12} textAnchor="end" fontSize="13" fill="currentColor" fillOpacity="0.6" fontFamily="ui-sans-serif, system-ui, sans-serif">
Press for sound
</text>
)}
{/* One focus stop per string; arrow keys walk along them. */}
{notes.map((_, i) => {
const x = xAt(i);
const top = neckY(x);
return (
<rect
key={i}
ref={(el) => {
hits.current[i] = el;
}}
x={x - 7}
y={top}
width="14"
height={boxY(x) - top}
fill="transparent"
role="button"
aria-label={`String ${i + 1} of ${n}`}
tabIndex={i === focus ? 0 : -1}
onFocus={() => setFocus(i)}
onKeyDown={onKey(i)}
/>
);
})}
</svg>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/string-harp.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/string-harp-demo.tsx
import { StringHarp } from "@/components/ui/string-harp"; export default function StringHarpExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[radial-gradient(circle_at_50%_35%,#3b2a4a,#120c1a_78%)] p-2 text-[#e9dcc3]"> <StringHarp className="w-[min(124cqh,92cqw,520px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| notes | number[] | D pentatonic, 13 notes | Frequency of each string in Hz, longest string first. |
| sound | boolean | true | Browsers stay silent until the first press or key. |
| onPluck | (index: number) => void | - | Called with the string index on every pluck. |
| label | string | "Harp" | Read out by screen readers. |
| className | string | "w-96" | Set the width here. Everything scales with it. |