New: Activity Terrain +12 more

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.json
Install manually
  1. 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.