Plucked Text
A headline sitting on a string you can pluck, strum and let ring, built with React and real string physics.
1 exampleFrom KokonutUI (MIT)
Installation
Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:
npx shadcn@latest add https://kryptonite-ui.vercel.app/r/plucked-text.jsonInstall manually
Copy this into
components/kokonut/plucked-text.tsx:plucked-text.tsx"use client"; /** * @author: @dorianbaffier * @description: Plucked Text * @version: 1.0.0 * @date: 2026-10-14 * @license: MIT * @website: https://kokonutui.com * @github: https://github.com/kokonut-labs/kokonutui */ import { useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; export interface PluckedTextProps { /** The line of text that sits on the string. Keep it to one line. */ text?: string; className?: string; /** How taut the string is. Higher rings faster and settles sooner. */ tension?: number; /** Strum the string when the cursor sweeps across it, not only on drag. */ strum?: boolean; } // The string is simulated as a sum of its standing waves (modes), each one a // damped oscillator solved exactly, so it can't blow up at any frame rate. const MAX_MODES = 24; // No mode may vibrate faster than this many times a second; above it, a // 60 Hz screen would alias it into jitter. const MAX_MODE_HZ = 22; // Wave speed along the string, in px per second, at tension 1. A real // string keeps its wave speed, so a longer one rings lower. const WAVE_SPEED = 1500; // Damping: every mode loses some energy, high ones much faster, so a pluck // starts sharp and rounds off into a slow sway, as a real string does. const DAMP_BASE = 0.9; const DAMP_HIGH = 0.012; // Sample spacing along the string for the drawn path, in px. const STEP = 6; // Displacements flatten out past this fraction of the font size, so a // hard pull bends the line without throwing letters off it. const MAX_SWING = 0.6; // How much of a sweep's vertical speed reaches the string. const STRUM_GAIN = 0.55; const MAX_STRUM_SPEED = 2400; // Below this energy the string is at rest and the loop stops. const REST_ENERGY = 0.02; // Letters tilt with the string, a little less than its full slope. const TILT = 0.85; interface Mode { q: number; v: number; omega: number; gamma: number; damped: number; } /** Advance one damped oscillator by `dt` seconds, exactly. */ function stepMode(mode: Mode, dt: number) { const { q, v, omega, gamma, damped } = mode; const decay = Math.exp(-gamma * dt); const c = Math.cos(damped * dt); const s = Math.sin(damped * dt); mode.q = decay * (q * c + ((v + gamma * q) / damped) * s); mode.v = decay * (v * c - ((omega * omega * q + gamma * v) / damped) * s); } /** * The string pulled into a V with its apex at `apex` and height `height`, * written as standing waves. Cutting the series short rounds the corner. */ function triangleModes( modes: Mode[], length: number, apex: number, height: number ) { const d = Math.min(Math.max(apex, 1), length - 1); modes.forEach((mode, index) => { const n = index + 1; mode.q = ((2 * height * length * length) / (n * n * Math.PI * Math.PI * d * (length - d))) * Math.sin((n * Math.PI * d) / length); mode.v = 0; }); } /** Rubber band: grows like `x` near zero and never past `max`. */ function soften(x: number, max: number) { return max * Math.tanh(x / max); } export default function PluckedText({ text = "Good vibrations", className, tension = 1, strum = true, }: PluckedTextProps) { const stageRef = useRef<HTMLDivElement>(null); const lettersRef = useRef<HTMLSpanElement>(null); const svgRef = useRef<SVGSVGElement>(null); const pathRef = useRef<SVGPathElement>(null); const ghostRef = useRef<SVGPathElement>(null); // biome-ignore lint/correctness/useExhaustiveDependencies: text renders the letter spans the effect reads useEffect(() => { const stage = stageRef.current; const lettersEl = lettersRef.current; const svg = svgRef.current; const path = pathRef.current; const ghost = ghostRef.current; if (!(stage && lettersEl && svg && path && ghost)) { return; } const reduceMotion = window.matchMedia( "(prefers-reduced-motion: reduce)" ).matches; const letters = Array.from(lettersEl.children).slice( 0, -1 ) as HTMLElement[]; let length = 0; let swing = 0; let modes: Mode[] = []; // Precomputed sin and cos of every mode at every sample and letter. let pathBasis: Float64Array[] = []; let letterBasis: { sin: Float64Array; cos: Float64Array }[] = []; let raf = 0; let last = 0; let visible = true; let lastPath = ""; // Pointer state: a grab holds the string in a V; otherwise the last // position is kept to notice the cursor sweeping across the line. let grab: { id: number; x: number; y: number } | null = null; let release: { from: Float64Array; start: number } | null = null; let prev: { x: number; y: number; t: number } | null = null; const measure = () => { // The string sits on the text's baseline, found with an empty // inline-block, whose bottom edge rests on it. const probe = lettersEl.lastElementChild as HTMLElement; stage.style.setProperty( "--pluck-baseline", `${lettersEl.offsetTop + probe.offsetTop + probe.offsetHeight}px` ); const svgBox = svg.getBoundingClientRect(); const lettersLeft = lettersEl.getBoundingClientRect().left - svgBox.left; length = svgBox.width; swing = Number.parseFloat(getComputedStyle(stage).fontSize) * MAX_SWING; const f0 = (WAVE_SPEED * tension) / (2 * length); const count = Math.max( 4, Math.min(MAX_MODES, Math.floor(MAX_MODE_HZ / f0)) ); const old = modes; modes = Array.from({ length: count }, (_, index) => { const n = index + 1; const omega = 2 * Math.PI * f0 * n; const gamma = (DAMP_BASE + DAMP_HIGH * n * n) * Math.sqrt(tension); return { q: old[index]?.q ?? 0, v: old[index]?.v ?? 0, omega, gamma, damped: Math.sqrt(Math.max(omega * omega - gamma * gamma, 1)), }; }); const samples = Math.ceil(length / STEP) + 1; pathBasis = Array.from({ length: samples }, (_, i) => { const x = Math.min(i * STEP, length); return Float64Array.from(modes, (_m, index) => Math.sin(((index + 1) * Math.PI * x) / length) ); }); letterBasis = letters.map((letter) => { // Offsets ignore the transform the loop applies. const x = lettersLeft + letter.offsetLeft + letter.offsetWidth / 2; const k = (index: number) => ((index + 1) * Math.PI) / length; return { sin: Float64Array.from(modes, (_m, index) => Math.sin(k(index) * x)), cos: Float64Array.from( modes, (_m, index) => k(index) * Math.cos(k(index) * x) ), }; }); }; /** Displacement of the string where `basis` was sampled. */ const sum = (basis: Float64Array) => { let y = 0; for (let i = 0; i < modes.length; i++) { y += modes[i].q * basis[i]; } return y; }; const draw = () => { let d = ""; for (let i = 0; i < pathBasis.length; i++) { const x = Math.min(i * STEP, length); const y = soften(sum(pathBasis[i]), swing); d += `${i === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(2)}`; } // The previous frame stays behind faintly: a vibrating string blurs. ghost.setAttribute("d", lastPath || d); path.setAttribute("d", d); lastPath = d; for (let i = 0; i < letters.length; i++) { const { sin, cos } = letterBasis[i]; const raw = sum(sin); const y = soften(raw, swing); // Slope of the softened curve, so tilt eases off with the swing. const t = Math.tanh(raw / swing); const slope = sum(cos) * (1 - t * t); const angle = Math.atan(slope) * TILT; letters[i].style.transform = `translate3d(0, ${y.toFixed(2)}px, 0) rotate(${angle.toFixed(4)}rad)`; } }; const energy = () => modes.reduce((e, m) => e + m.v * m.v + m.omega * m.omega * m.q * m.q, 0); const settle = () => { for (const mode of modes) { mode.q = 0; mode.v = 0; } }; /** Reduced motion: no ringing, the line just eases back flat. */ const easeBack = (now: number) => { if (!release) { return; } const t = Math.min((now - release.start) / 260, 1); const left = (1 - t) ** 3; const from = release.from; modes.forEach((mode, index) => { mode.q = from[index] * left; }); if (t >= 1) { release = null; settle(); } }; const advance = (now: number, dt: number) => { if (release) { easeBack(now); } else if (!grab) { for (const mode of modes) { stepMode(mode, dt); } } }; const frame = (now: number) => { raf = 0; if (!visible) { return; } const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60; last = now; advance(now, dt); draw(); if (grab || release || energy() > REST_ENERGY) { raf = requestAnimationFrame(frame); return; } settle(); draw(); ghost.setAttribute("d", path.getAttribute("d") ?? ""); }; const start = () => { if (!raf && visible) { last = 0; raf = requestAnimationFrame(frame); } }; /** Pointer position along the string and above or below it. */ const local = (event: PointerEvent) => { const r = svg.getBoundingClientRect(); return { x: event.clientX - r.left, y: event.clientY - r.top }; }; const lineAt = (x: number) => { let y = 0; for (let i = 0; i < modes.length; i++) { y += modes[i].q * Math.sin(((i + 1) * Math.PI * x) / length); } return soften(y, swing); }; const onDown = (event: PointerEvent) => { if (grab || event.button !== 0) { return; } const point = local(event); if (point.x < 0 || point.x > length) { return; } event.preventDefault(); stage.setPointerCapture(event.pointerId); release = null; grab = { id: event.pointerId, x: point.x, y: point.y }; stage.dataset.grabbing = ""; triangleModes(modes, length, point.x, lineAt(point.x)); start(); }; const onMove = (event: PointerEvent) => { const point = local(event); if (grab && event.pointerId === grab.id) { // Follow the hand along the string, and up or down with a pull // that stiffens the further it goes. grab.x = Math.min(Math.max(point.x, 0), length); grab.y = soften(point.y, swing * 1.6); triangleModes(modes, length, grab.x, grab.y); start(); return; } if (!(strum && event.pointerType === "mouse") || reduceMotion) { prev = null; return; } const now = event.timeStamp; if (prev && point.x >= 0 && point.x <= length) { // Did the cursor cross the line since the last move? Then it // strums it there, as hard as it was moving. const before = prev.y - lineAt(prev.x); const after = point.y - lineAt(point.x); if (before * after < 0) { const dt = Math.max(now - prev.t, 8) / 1000; const speed = Math.max( -MAX_STRUM_SPEED, Math.min(MAX_STRUM_SPEED, (point.y - prev.y) / dt) ); const where = prev.x + (point.x - prev.x) * (before / (before - after)); modes.forEach((mode, index) => { const n = index + 1; mode.v += ((STRUM_GAIN * speed * 80) / length) * Math.sin((n * Math.PI * where) / length) * Math.exp(-((n / 14) ** 2)); }); start(); } } prev = { x: point.x, y: point.y, t: now }; }; const onUp = (event: PointerEvent) => { if (!grab || event.pointerId !== grab.id) { return; } grab = null; delete stage.dataset.grabbing; if (reduceMotion) { release = { from: Float64Array.from(modes, (mode) => mode.q), start: performance.now(), }; } start(); }; const onLeave = () => { prev = null; }; measure(); draw(); const resizeObserver = new ResizeObserver(() => { measure(); draw(); }); resizeObserver.observe(stage); // Letter widths change when a web font arrives. document.fonts?.ready.then(() => { measure(); draw(); }); // One soft strum the first time the line scrolls into view, so it // shows it can move. let greeted = reduceMotion; const intersection = new IntersectionObserver( ([entry]) => { visible = entry.isIntersecting && document.visibilityState === "visible"; if (!visible) { return; } if (!greeted && entry.intersectionRatio > 0.6) { greeted = true; triangleModes(modes, length, length * 0.3, -swing * 0.5); } start(); }, { threshold: [0, 0.6] } ); intersection.observe(stage); const onVisibility = () => { visible = document.visibilityState === "visible"; if (visible) { start(); } }; document.addEventListener("visibilitychange", onVisibility); stage.addEventListener("pointerdown", onDown); stage.addEventListener("pointermove", onMove); stage.addEventListener("pointerup", onUp); stage.addEventListener("pointercancel", onUp); stage.addEventListener("pointerleave", onLeave); return () => { cancelAnimationFrame(raf); resizeObserver.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", onVisibility); stage.removeEventListener("pointerdown", onDown); stage.removeEventListener("pointermove", onMove); stage.removeEventListener("pointerup", onUp); stage.removeEventListener("pointercancel", onUp); stage.removeEventListener("pointerleave", onLeave); }; }, [text, tension, strum]); return ( <div className={cn("@container flex w-full justify-center", className)}> <div className="group/pluck relative cursor-grab touch-none select-none px-[0.5em] py-[0.7em] font-semibold text-[clamp(2rem,11cqi,6rem)] text-zinc-900 leading-none tracking-tight data-[grabbing]:cursor-grabbing dark:text-zinc-100" ref={stageRef} > <span className="sr-only">{text}</span> <span aria-hidden="true" className="relative block whitespace-pre" ref={lettersRef} > {Array.from(text).map((char, index) => ( <span className="inline-block origin-[50%_100%] will-change-transform" // biome-ignore lint/suspicious/noArrayIndexKey: letters repeat; their position is their identity. key={index} > {char} </span> ))} <span className="inline-block h-0 w-0" /> </span> {/* The string runs along the baseline, pinned at both ends. */} <svg aria-hidden="true" className="pointer-events-none absolute left-[0.25em] h-px w-[calc(100%-0.5em)] overflow-visible text-zinc-400 dark:text-zinc-600" ref={svgRef} style={{ top: "var(--pluck-baseline, 1.56em)" }} > <path className="opacity-30" fill="none" ref={ghostRef} stroke="currentColor" strokeLinecap="round" strokeWidth={1.5} /> <path fill="none" ref={pathRef} stroke="currentColor" strokeLinecap="round" strokeWidth={1.5} /> </svg> <span aria-hidden="true" className="pointer-events-none absolute left-[0.25em] size-[0.12em] -translate-x-1/2 -translate-y-1/2 rounded-full bg-zinc-400 dark:bg-zinc-600" style={{ top: "var(--pluck-baseline, 1.56em)" }} /> <span aria-hidden="true" className="pointer-events-none absolute right-[0.25em] size-[0.12em] translate-x-1/2 -translate-y-1/2 rounded-full bg-zinc-400 dark:bg-zinc-600" style={{ top: "var(--pluck-baseline, 1.56em)" }} /> </div> </div> ); }
Feedback
Questions, bugs or ideas for Plucked Text? Post them here, or reply to someone else. Sign in with GitHub to comment.