# Oscilloscope

> A phosphor trace on a CRT grid, from waveforms or live audio.

Category: Instruments. Docs: https://neonkit.heyaadi.com/components/oscilloscope

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/oscilloscope.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/oscilloscope`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { Oscilloscope } from "@/components/ui/oscilloscope"

<Oscilloscope wave="sine" frequency={2} amplitude={0.6} />
```

## Examples

### Bench scope

A phosphor trace on a lit graticule. Slow parts of the wave burn bright and steep edges draw faint, the way a real beam deposits light. Children render over the glass, here as readouts.

```tsx
"use client";

import * as React from "react";
import { Oscilloscope, type OscilloscopeWave } from "@/components/ui/oscilloscope";
import { Slider } from "@/components/ui/slider";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";

// Little front-panel glyphs, one per waveform.
const waves: { value: OscilloscopeWave; label: string; path: string }[] = [
  { value: "sine", label: "Sine", path: "M1 6c2.5-6 4.5-6 7 0s4.5 6 7 0" },
  { value: "square", label: "Square", path: "M1 9V3h4.7v6h4.6V3H15" },
  { value: "saw", label: "Sawtooth", path: "M1 9l7-6v6l7-6" },
  { value: "triangle", label: "Triangle", path: "M1 9l3.5-6L8 9l3.5-6L15 9" },
  { value: "noise", label: "Noise", path: "M1 6l1.5-3 1.5 5 1.5-6 1.5 7 1.5-4 1.5 2 1.5-5 1.5 6 1.5-2" },
];

export default function OscilloscopeDemo() {
  const [wave, setWave] = React.useState<OscilloscopeWave>("sine");
  const [cycles, setCycles] = React.useState(2);
  const [amplitude, setAmplitude] = React.useState(0.6);

  // At 1 ms per division the ten divisions span 10 ms.
  const hertz = cycles * 100;
  const peakToPeak = amplitude * 4;

  return (
    <div className="w-full max-w-[640px] rounded-[26px] border border-white/10 bg-[linear-gradient(180deg,#12141b,#0a0b10)] p-3 shadow-[inset_0_1px_0_rgb(255_255_255/0.06),0_40px_80px_-40px_rgb(0_0_0/0.9)] sm:p-4">
      <Oscilloscope wave={wave} frequency={cycles} amplitude={amplitude}>
        <span className="absolute left-5 top-5 flex items-center gap-2">
          <span className="rounded-[3px] bg-[rgb(var(--neon)/0.85)] px-1 py-0.5 text-[#030405] [text-shadow:none]">CH1</span>
          500 mV/div
        </span>
        <span className="absolute right-5 top-5">M 1.00 ms/div</span>
        <span className="absolute bottom-5 left-5 tabular-nums">
          {wave === "noise" ? "f ---" : `f ${hertz.toFixed(0)} Hz`}
        </span>
        <span className="absolute bottom-5 right-5 tabular-nums">Vpp {peakToPeak.toFixed(2)} V</span>
      </Oscilloscope>

      <div className="mt-4 grid gap-5 px-1 pb-1 sm:grid-cols-[auto_1fr_1fr] sm:items-end">
        <div className="grid gap-2">
          <span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-white/50">Waveform</span>
          <ToggleGroup
            aria-label="Waveform"
            value={wave}
            onValueChange={(value) => value && setWave(value as OscilloscopeWave)}
          >
            {waves.map((item) => (
              <ToggleGroupItem key={item.value} value={item.value} aria-label={item.label} title={item.label}>
                <svg viewBox="0 0 16 12" fill="none" aria-hidden>
                  <path d={item.path} stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
              </ToggleGroupItem>
            ))}
          </ToggleGroup>
        </div>
        <div className="grid gap-3">
          <span className="flex justify-between text-[10px] font-semibold uppercase tracking-[0.16em] text-white/50">
            Cycles <span className="font-mono font-normal normal-case tracking-normal tabular-nums text-white/70">{cycles.toFixed(1)}</span>
          </span>
          <Slider
            size="sm"
            aria-label="Cycles on screen"
            min={1}
            max={6}
            step={0.5}
            value={[cycles]}
            onValueChange={([value]) => setCycles(value)}
          />
        </div>
        <div className="grid gap-3">
          <span className="flex justify-between text-[10px] font-semibold uppercase tracking-[0.16em] text-white/50">
            Amplitude <span className="font-mono font-normal normal-case tracking-normal tabular-nums text-white/70">{Math.round(amplitude * 100)}%</span>
          </span>
          <Slider
            size="sm"
            aria-label="Amplitude"
            min={0.1}
            max={0.95}
            step={0.05}
            value={[amplitude]}
            onValueChange={([value]) => setAmplitude(value)}
            formatValue={(value) => `${Math.round(value * 100)}%`}
          />
        </div>
      </div>
    </div>
  );
}
```

### Waveforms

Square and saw edges overshoot and ring before they settle, like a function generator seen through the scope's bandwidth.

```tsx
import { Oscilloscope, type OscilloscopeWave } from "@/components/ui/oscilloscope";

const waves: { wave: OscilloscopeWave; label: string }[] = [
  { wave: "sine", label: "Sine" },
  { wave: "square", label: "Square" },
  { wave: "saw", label: "Saw" },
  { wave: "triangle", label: "Triangle" },
];

export default function OscilloscopeWaveforms() {
  return (
    <div className="grid w-full max-w-2xl grid-cols-1 gap-4 sm:grid-cols-2">
      {waves.map(({ wave, label }) => (
        <figure key={wave} className="grid gap-2">
          <Oscilloscope wave={wave} frequency={2} amplitude={0.62} className="aspect-[16/9] rounded-xl" />
          <figcaption className="text-[13px] text-white/55">{label}</figcaption>
        </figure>
      ))}
    </div>
  );
}
```

### Persistence

How much light each frame keeps. Higher values leave a longer afterglow as the trace drifts.

```tsx
import { Oscilloscope } from "@/components/ui/oscilloscope";

const settings = [
  { persistence: 0, label: "Digital, no afterglow" },
  { persistence: 0.85, label: "Default phosphor" },
  { persistence: 0.96, label: "Long glow" },
];

export default function OscilloscopePersistence() {
  return (
    <div className="grid w-full max-w-2xl grid-cols-1 gap-4 sm:grid-cols-3">
      {settings.map(({ persistence, label }) => (
        <figure key={persistence} className="grid gap-2">
          <Oscilloscope
            wave="sine"
            frequency={1.5}
            speed={0.3}
            persistence={persistence}
            grid={false}
            className="aspect-[2/1] rounded-xl sm:aspect-square"
          />
          <figcaption className="flex items-baseline justify-between gap-3 text-[13px] text-white/55">
            {label}
            <code className="font-mono text-xs tabular-nums text-white/70">{persistence}</code>
          </figcaption>
        </figure>
      ))}
    </div>
  );
}
```

### Live audio

Pass an AnalyserNode as source to draw real audio, triggered on a rising edge so periodic sound stands still. Both sources start their AudioContext inside the click.

```tsx
"use client";

import * as React from "react";
import { Mic, Volume2 } from "@/components/ui/icons";
import { Oscilloscope } from "@/components/ui/oscilloscope";

type Source = "tone" | "mic";

export default function OscilloscopeLiveAudio() {
  const [analyser, setAnalyser] = React.useState<AnalyserNode | null>(null);
  const [active, setActive] = React.useState<Source | null>(null);
  const [error, setError] = React.useState<string | null>(null);
  const stopRef = React.useRef<(() => void) | null>(null);

  React.useEffect(() => () => stopRef.current?.(), []);

  const stop = () => {
    stopRef.current?.();
    stopRef.current = null;
    setAnalyser(null);
    setActive(null);
  };

  // Each source builds its own graph inside the click, so the AudioContext may start.
  const start = async (source: Source) => {
    stop();
    setError(null);
    const context = new AudioContext();
    const node = context.createAnalyser();
    node.fftSize = 2048;
    try {
      if (source === "tone") {
        // A 220 Hz sawtooth with a slow filter sweep, quiet enough to leave on.
        const osc = context.createOscillator();
        osc.type = "sawtooth";
        osc.frequency.value = 220;
        const filter = context.createBiquadFilter();
        filter.frequency.value = 900;
        const lfo = context.createOscillator();
        lfo.frequency.value = 0.25;
        const depth = context.createGain();
        depth.gain.value = 600;
        lfo.connect(depth).connect(filter.frequency);
        const volume = context.createGain();
        volume.gain.value = 0.08;
        osc.connect(filter).connect(node);
        node.connect(volume).connect(context.destination);
        osc.start();
        lfo.start();
      } else {
        const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
        context.createMediaStreamSource(stream).connect(node);
        stopRef.current = () => stream.getTracks().forEach((track) => track.stop());
      }
    } catch {
      void context.close();
      setError("Microphone access was blocked.");
      return;
    }
    const release = stopRef.current;
    stopRef.current = () => {
      release?.();
      void context.close();
    };
    setAnalyser(node);
    setActive(source);
  };

  const button =
    "inline-flex h-8 cursor-pointer items-center gap-2 rounded-lg border px-3 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon-accent,34_211_238)/0.6)]";
  const idle = "border-white/10 bg-white/[0.04] text-white/80 hover:bg-white/[0.08]";
  const on =
    "border-[rgb(var(--neon-accent,34_211_238)/0.5)] bg-[rgb(var(--neon-accent,34_211_238)/0.12)] text-[rgb(var(--neon-accent,34_211_238))]";

  return (
    <div className="grid w-full max-w-xl gap-4">
      <Oscilloscope source={analyser} amplitude={active === "mic" ? 2.5 : active ? 1.6 : 0} aria-label="Live audio waveform">
        <span className="absolute left-4 top-4">{active === "mic" ? "MIC" : active === "tone" ? "OSC 220 Hz" : "NO SIGNAL"}</span>
      </Oscilloscope>
      <div className="flex flex-wrap items-center gap-2">
        <button
          type="button"
          aria-pressed={active === "tone"}
          onClick={() => (active === "tone" ? stop() : start("tone"))}
          className={`${button} ${active === "tone" ? on : idle}`}
        >
          <Volume2 className="size-3.5" /> Tone
        </button>
        <button
          type="button"
          aria-pressed={active === "mic"}
          onClick={() => (active === "mic" ? stop() : start("mic"))}
          className={`${button} ${active === "mic" ? on : idle}`}
        >
          <Mic className="size-3.5" /> Microphone
        </button>
        {error && <span className="text-xs text-[#ff4d5e]">{error}</span>}
      </div>
    </div>
  );
}
```

## API reference

### Oscilloscope

Renders a div with the screen's canvases and accepts div attributes; the ref points at the div. It defaults to the full width at 16:10; size it with className.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `wave` | `"sine" \| "square" \| "saw" \| "triangle" \| "noise"` | `"sine"` | Generated waveform. Ignored while source is set. |
| `frequency` | `number` | `2` | Cycles across the screen. |
| `amplitude` | `number` | `0.6` | Peak height as a fraction of half the screen. With source, the input gain. |
| `speed` | `number` | `0.25` | Drift in cycles per second. 0 holds the trace still, like a triggered scope. |
| `persistence` | `number` | `0.85` | Share of each frame's light that survives into the next, 0 to 0.98. |
| `grid` | `boolean` | `true` | The graticule: 10 by 8 divisions with a ticked center cross. |
| `source` | `AnalyserNode \| null` | — | Live audio. Draws its time-domain data instead of the generated wave. |
| `children` | `ReactNode` | — | Overlay content, positioned absolutely over the glass. Text picks up a mono, glowing readout style. |
| `color` | `NeonColor` | `inherited` | Phosphor color. Falls back to --neon-accent, then cyan. |

## Accessibility

- It's hidden from screen readers by default. Pass aria-label (for example "Sine wave, 200 Hz") to expose it as an image.
- The canvases pause while off screen or in a hidden tab, size themselves with a ResizeObserver and render at the device pixel ratio, capped at 2.
- With reduced motion, generated waves draw one still frame. A live source keeps updating a few times a second without the afterglow.
- The phosphor color is read from --neon, and the canvas follows changes to an ancestor's --neon-accent without a re-render.
- The component never creates an AudioContext. Build the AnalyserNode in response to a click so the browser lets it start.

## Source

`components/ui/oscilloscope.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

export type OscilloscopeWave = "sine" | "square" | "saw" | "triangle" | "noise";

type Rgb = [number, number, number];

const TAU = Math.PI * 2;
const FALLBACK: Rgb = [34, 211, 238];
const COLUMNS = 10;
const ROWS = 8;
const LEVELS = 6;

// Edges ring like a real function generator's output seen through the
// scope's bandwidth: an underdamped step response, in fractions of a cycle.
const ZETA = 0.65;
const OMEGA = 150;
const DAMPED = OMEGA * Math.sqrt(1 - ZETA * ZETA);
const RATIO = ZETA / Math.sqrt(1 - ZETA * ZETA);

function settle(t: number) {
  if (t <= 0) return 0;
  return 1 - Math.exp(-ZETA * OMEGA * t) * (Math.cos(DAMPED * t) + RATIO * Math.sin(DAMPED * t));
}

function sample(wave: OscilloscopeWave, phase: number) {
  const u = phase - Math.floor(phase);
  if (wave === "square") return u < 0.5 ? -1 + 2 * settle(u) : 1 - 2 * settle(u - 0.5);
  if (wave === "saw") return 2 * u - 1 + 2 * (1 - settle(u));
  // A whisker under full scale rounds the triangle's corners.
  if (wave === "triangle") return (2 / Math.PI) * Math.asin(0.995 * Math.sin(TAU * u));
  return Math.sin(TAU * u);
}

// Each pass is [line width in px, steady-state alpha, core?]. Additive
// blending stacks them into a white-hot core inside a colored bloom.
const passes: [number, number, boolean][] = [
  [9, 0.08, false],
  [3.5, 0.32, false],
  [1.5, 1, true],
];
const BOOST = 2.6;

function readRgb(node: Element | null): Rgb {
  if (!node || typeof getComputedStyle === "undefined") return FALLBACK;
  const parts = getComputedStyle(node).color.match(/[\d.]+/g);
  return parts && parts.length >= 3 ? [+parts[0], +parts[1], +parts[2]] : FALLBACK;
}

/** The theme's glow multiplier. A finite check, not `|| 1`, so 0 stays 0. */
function readGlow(node: Element | null) {
  if (!node || typeof getComputedStyle === "undefined") return 1;
  const value = parseFloat(getComputedStyle(node).getPropertyValue("--neon-glow"));
  return Number.isFinite(value) ? Math.max(0, value) : 1;
}

function rgba([r, g, b]: Rgb, alpha: number) {
  return `rgba(${r | 0},${g | 0},${b | 0},${Math.min(1, Math.max(0, alpha)).toFixed(4)})`;
}

function crisp(value: number, dpr: number) {
  return (Math.round(value * dpr) + 0.5) / dpr;
}

export interface OscilloscopeProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Phosphor color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  /** Generated waveform. Ignored while `source` is set. */
  wave?: OscilloscopeWave;
  /** Cycles across the screen. */
  frequency?: number;
  /** Peak height as a fraction of half the screen. With `source`, the input gain. */
  amplitude?: number;
  /** Drift in cycles per second. 0 holds the trace still, like a triggered scope. */
  speed?: number;
  /** Share of each frame's light that survives into the next, 0 to 0.98. */
  persistence?: number;
  /** The graticule: a 10 by 8 division grid with a ticked center cross. */
  grid?: boolean;
  /** Live audio. Draws its time-domain data, triggered on a rising edge. */
  source?: AnalyserNode | null;
}

/**
 * A CRT trace. Children render over the screen, for readouts in the corners.
 */
const Oscilloscope = React.forwardRef<HTMLDivElement, OscilloscopeProps>(
  (
    {
      className,
      style,
      color,
      wave = "sine",
      frequency = 2,
      amplitude = 0.6,
      speed = 0.25,
      persistence = 0.85,
      grid = true,
      source = null,
      children,
      ...props
    },
    ref,
  ) => {
    const hostRef = React.useRef<HTMLDivElement>(null);
    const traceRef = React.useRef<HTMLCanvasElement>(null);
    const gridRef = React.useRef<HTMLCanvasElement>(null);
    const bloomRef = React.useRef<HTMLCanvasElement>(null);
    const probeRef = React.useRef<HTMLSpanElement>(null);
    const engine = React.useRef<{ redraw: () => void; recolor: () => void; sync: () => void } | null>(
      null,
    );
    const settings = React.useRef({ wave, frequency, amplitude, speed, persistence, source });

    React.useImperativeHandle(ref, () => hostRef.current as HTMLDivElement);

    React.useEffect(() => {
      settings.current = { wave, frequency, amplitude, speed, persistence, source };
      // A live source keeps the loop running even under reduced motion.
      engine.current?.sync();
      engine.current?.redraw();
    }, [wave, frequency, amplitude, speed, persistence, source]);

    React.useEffect(() => {
      engine.current?.recolor();
    }, [color]);

    React.useEffect(() => {
      const host = hostRef.current;
      const canvas = traceRef.current;
      const gridCanvas = gridRef.current;
      const probe = probeRef.current;
      if (!host || !canvas || !gridCanvas) return;
      const ctx = canvas.getContext("2d", { alpha: false });
      const gridCtx = gridCanvas.getContext("2d");
      const bloomCanvas = bloomRef.current;
      const bloomCtx = bloomCanvas?.getContext("2d", { alpha: false }) ?? null;
      if (!ctx || !gridCtx) return;

      const motion = window.matchMedia?.("(prefers-reduced-motion: reduce)");
      let width = 0;
      let height = 0;
      let dpr = 1;
      let rgb = readRgb(probe);
      let glow = readGlow(probe);
      let raf = 0;
      let last = 0;
      let phase = 0;
      let throttle = 0;
      let onScreen = true;
      let xs = new Float32Array(0);
      let ys = new Float32Array(0);
      let levels = new Uint8Array(0);
      let samples: Float32Array<ArrayBuffer> | null = null;

      const area = () => {
        const inset = Math.max(10, Math.round(Math.min(width, height) * 0.05));
        return { x: inset, y: inset, w: width - inset * 2, h: height - inset * 2 };
      };

      const drawGrid = () => {
        gridCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
        gridCtx.clearRect(0, 0, width, height);
        const { x, y, w, h } = area();
        if (w <= 0 || h <= 0) return;
        const cx = x + w / 2;
        const cy = y + h / 2;
        gridCtx.lineWidth = 1 / dpr;
        gridCtx.shadowColor = rgba(rgb, 0.6 * glow);
        gridCtx.shadowBlur = glow > 0 ? 3 : 0;
        gridCtx.beginPath();
        for (let i = 0; i <= COLUMNS; i++) {
          const lx = crisp(x + (w * i) / COLUMNS, dpr);
          gridCtx.moveTo(lx, y);
          gridCtx.lineTo(lx, y + h);
        }
        for (let i = 0; i <= ROWS; i++) {
          const ly = crisp(y + (h * i) / ROWS, dpr);
          gridCtx.moveTo(x, ly);
          gridCtx.lineTo(x + w, ly);
        }
        gridCtx.strokeStyle = rgba(rgb, 0.16);
        gridCtx.stroke();

        // The center cross carries fifth-division ticks.
        gridCtx.beginPath();
        const tick = Math.max(3, Math.min(w, h) * 0.012);
        for (let i = 1; i < COLUMNS * 5; i++) {
          const tx = crisp(x + (w * i) / (COLUMNS * 5), dpr);
          gridCtx.moveTo(tx, crisp(cy, dpr) - tick);
          gridCtx.lineTo(tx, crisp(cy, dpr) + tick);
        }
        for (let i = 1; i < ROWS * 5; i++) {
          const ty = crisp(y + (h * i) / (ROWS * 5), dpr);
          gridCtx.moveTo(crisp(cx, dpr) - tick, ty);
          gridCtx.lineTo(crisp(cx, dpr) + tick, ty);
        }
        gridCtx.strokeStyle = rgba(rgb, 0.34);
        gridCtx.stroke();
      };

      // Fills xs/ys with one sweep of the beam and returns the point count.
      const trace = (at: number) => {
        const { wave, frequency, amplitude, source } = settings.current;
        const { x, y, w, h } = area();
        const count = Math.min(xs.length, Math.max(2, Math.ceil(w / 1.5) + 1));
        const mid = y + h / 2;
        const half = h / 2;
        // The whole beam shivers a fraction of a pixel per sweep, which
        // thickens the trace the way a real spot's focus does.
        const shiver = (Math.random() - 0.5) * 0.7;

        if (source) {
          if (!samples || samples.length !== source.fftSize) samples = new Float32Array(source.fftSize);
          source.getFloatTimeDomainData(samples);
          const span = samples.length >> 1;
          // Trigger on the first rising zero crossing, so periodic signals stand still.
          let start = 0;
          for (let i = 1; i < span; i++) {
            if (samples[i - 1] < 0 && samples[i] >= 0) {
              start = i;
              break;
            }
          }
          for (let i = 0; i < count; i++) {
            const value = samples[start + Math.floor((i / (count - 1)) * (span - 1))];
            xs[i] = x + (w * i) / (count - 1);
            ys[i] = Math.min(y + h, Math.max(y, mid - value * amplitude * half + shiver));
          }
          return count;
        }

        let noise = 0;
        for (let i = 0; i < count; i++) {
          const t = i / (count - 1);
          let value: number;
          if (wave === "noise") {
            noise = noise * 0.55 + (Math.random() * 2 - 1) * 0.62;
            value = noise;
          } else {
            value = sample(wave, t * frequency + at);
          }
          xs[i] = x + w * t;
          ys[i] = Math.min(y + h, Math.max(y, mid - value * amplitude * half + shiver));
        }
        return count;
      };

      const sweep = (count: number, gain: number) => {
        const step = xs[1] - xs[0];
        // A fast-moving beam deposits less light: steep edges draw faint.
        for (let i = 0; i < count - 1; i++) {
          const length = Math.hypot(xs[i + 1] - xs[i], ys[i + 1] - ys[i]);
          const brightness = Math.pow(Math.min(1, step / Math.max(length, 1e-4)), 0.75);
          levels[i] = Math.round(brightness * (LEVELS - 1));
        }
        const core: Rgb = [rgb[0] * 0.3 + 178.5, rgb[1] * 0.3 + 178.5, rgb[2] * 0.3 + 178.5];
        for (const [lineWidth, alpha, isCore] of passes) {
          // The wide passes are the bloom; the core is the trace itself and stays.
          const lit = isCore ? 1 : glow;
          if (lit === 0) continue;
          ctx.lineWidth = lineWidth;
          for (let level = 0; level < LEVELS; level++) {
            const intensity = 0.12 + (0.88 * level) / (LEVELS - 1);
            ctx.strokeStyle = rgba(isCore ? core : rgb, alpha * intensity * gain * lit);
            ctx.beginPath();
            let open = false;
            for (let i = 0; i < count - 1; i++) {
              if (levels[i] !== level) {
                open = false;
                continue;
              }
              if (!open) ctx.moveTo(xs[i], ys[i]);
              ctx.lineTo(xs[i + 1], ys[i + 1]);
              open = true;
            }
            ctx.stroke();
          }
        }
      };

      const draw = (dt: number, fade: number) => {
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.globalCompositeOperation = "source-over";
        ctx.fillStyle = `rgba(0,0,0,${fade})`;
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        // 8-bit fades stall at faint values; color-burn against near-white
        // subtracts about one step, so old light actually reaches black.
        if (fade < 1) {
          ctx.globalCompositeOperation = "color-burn";
          ctx.fillStyle = "rgb(254,254,254)";
          ctx.fillRect(0, 0, canvas.width, canvas.height);
        }

        const { wave, frequency, speed, source } = settings.current;
        const { w } = area();
        const shift = speed * dt;
        // A drifting trace is swept several times per frame, as a real beam
        // would be, so motion smears into light instead of strobing.
        const travel = Math.abs(shift) * (w / Math.max(frequency, 0.01));
        // A single still frame gets two sweeps, standing in for the light a
        // running trace accumulates.
        const sweeps =
          fade >= 1 ? 2 : source || wave === "noise" ? 1 : Math.min(8, Math.max(1, Math.ceil(travel / 0.6)));
        // Light builds up over repeated sweeps; at the default persistence a still
        // trace settles near BOOST times a pass's alpha. The beam deposits at least
        // that much per frame however long the glow, so the head stays bright.
        const gain = fade < 1 ? Math.min(1, (Math.max(fade, 0.15) * BOOST) / sweeps) : 1;

        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.globalCompositeOperation = "lighter";
        // Butt caps: runs of different brightness meet end to end. Round caps
        // would overlap there and print bright dots along the trace.
        ctx.lineCap = "butt";
        ctx.lineJoin = "round";
        for (let i = 1; i <= sweeps; i++) {
          const count = trace(phase + (shift * i) / sweeps);
          if (count >= 2) sweep(count, gain);
        }
        phase = (phase + shift) % 1;

        // Bloom: a quarter-size copy, blurred by CSS and screened back on top.
        if (bloomCtx && bloomCanvas) {
          bloomCtx.globalCompositeOperation = "copy";
          bloomCtx.drawImage(canvas, 0, 0, bloomCanvas.width, bloomCanvas.height);
        }
      };

      const still = () => !!motion?.matches;

      const frame = (now: number) => {
        raf = requestAnimationFrame(frame);
        const dt = Math.min(0.05, last ? (now - last) / 1000 : 1 / 60);
        last = now;
        if (still()) {
          // Reduced motion: a live source still updates, a few times a second, without trails.
          throttle += dt;
          if (throttle < 0.25) return;
          throttle = 0;
          draw(0, 1);
          return;
        }
        const keep = Math.min(0.98, Math.max(0, settings.current.persistence));
        draw(dt, 1 - Math.pow(keep, dt * 60));
      };

      const stop = () => {
        cancelAnimationFrame(raf);
        raf = 0;
        last = 0;
      };

      const sync = () => {
        const live = onScreen && !document.hidden && (!still() || !!settings.current.source);
        if (live && !raf && width > 0) raf = requestAnimationFrame(frame);
        else if (!live && raf) stop();
      };

      const redraw = () => {
        if (raf || width === 0) return;
        draw(0, 1);
      };

      const resize = () => {
        const rect = host.getBoundingClientRect();
        width = rect.width;
        height = rect.height;
        dpr = Math.min(2, window.devicePixelRatio || 1);
        for (const node of [canvas, gridCanvas]) {
          node.width = Math.max(1, Math.round(width * dpr));
          node.height = Math.max(1, Math.round(height * dpr));
        }
        if (bloomCanvas && bloomCtx) {
          bloomCanvas.width = Math.max(1, Math.round(width / 4));
          bloomCanvas.height = Math.max(1, Math.round(height / 4));
          bloomCtx.imageSmoothingQuality = "high";
        }
        const points = Math.ceil(width / 1.5) + 2;
        xs = new Float32Array(points);
        ys = new Float32Array(points);
        levels = new Uint8Array(points);
        drawGrid();
        redraw();
        sync();
      };

      const recolor = () => {
        rgb = readRgb(probe);
        glow = readGlow(probe);
        drawGrid();
        redraw();
      };

      engine.current = { redraw, recolor, sync };
      resize();

      // A move to a screen with another pixel ratio needs a fresh backing store.
      let dprQuery: MediaQueryList | undefined;
      const onDpr = () => {
        resize();
        watchDpr();
      };
      const watchDpr = () => {
        dprQuery?.removeEventListener?.("change", onDpr);
        dprQuery = window.matchMedia?.(`(resolution: ${window.devicePixelRatio || 1}dppx)`);
        dprQuery?.addEventListener?.("change", onDpr);
      };
      watchDpr();

      const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(resize) : null;
      resizeObserver?.observe(host);
      const intersection =
        typeof IntersectionObserver !== "undefined"
          ? new IntersectionObserver(([entry]) => {
              onScreen = entry.isIntersecting;
              sync();
            })
          : null;
      intersection?.observe(host);
      const onMotion = () => {
        sync();
        redraw();
      };
      // An ancestor's --neon-accent can change without a prop change; the
      // probe's color transition tells us when.
      const onProbe = (event: TransitionEvent) => {
        if (event.target === probe) recolor();
      };
      document.addEventListener("visibilitychange", sync);
      motion?.addEventListener?.("change", onMotion);
      probe?.addEventListener("transitionend", onProbe);

      return () => {
        stop();
        engine.current = null;
        resizeObserver?.disconnect();
        intersection?.disconnect();
        document.removeEventListener("visibilitychange", sync);
        motion?.removeEventListener?.("change", onMotion);
        dprQuery?.removeEventListener?.("change", onDpr);
        probe?.removeEventListener("transitionend", onProbe);
      };
    }, []);

    return (
      <div
        ref={hostRef}
        aria-hidden={props["aria-label"] || props["aria-labelledby"] ? undefined : true}
        role={props["aria-label"] || props["aria-labelledby"] ? "img" : undefined}
        style={neonVars(color, style)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "relative isolate aspect-[16/10] w-full overflow-hidden rounded-2xl border border-white/10 bg-[#030405]",
          "shadow-[inset_0_1px_0_rgb(255_255_255/0.06),0_24px_60px_-28px_rgb(var(--neon)/CALC(0.35*var(--neon-glow,1)))]",
          className,
        )}
        {...props}
      >
        <span
          ref={probeRef}
          aria-hidden
          // Color and --neon-glow both transition here, so a change on any ancestor repaints the canvases.
          className="pointer-events-none absolute size-0 text-[rgb(var(--neon))] [letter-spacing:CALC(var(--neon-glow,1)*1px)] [transition:color_1ms_linear,letter-spacing_1ms_linear]"
        />
        {/* Phosphor never reads as pure black: a faint tint and a hotter center. */}
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_70%_65%_at_50%_50%,rgb(var(--neon)/CALC(0.075*var(--neon-glow,1))),rgb(var(--neon)/CALC(0.02*var(--neon-glow,1)))_60%,transparent_100%)]"
        />
        <canvas
          ref={gridRef}
          aria-hidden
          className={cn("pointer-events-none absolute inset-0 size-full", !grid && "hidden")}
        />
        <canvas
          ref={traceRef}
          aria-hidden
          className="pointer-events-none absolute inset-0 size-full mix-blend-screen"
        />
        <canvas
          ref={bloomRef}
          aria-hidden
          className="pointer-events-none absolute inset-0 size-full opacity-90 mix-blend-screen [filter:blur(5px)_opacity(var(--neon-glow,1))]"
        />
        {/* Curved glass: darker corners, an inner rim and a soft reflection. */}
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0 rounded-[inherit] bg-[radial-gradient(ellipse_120%_110%_at_50%_50%,transparent_58%,rgb(0_0_0/0.62)_100%)] shadow-[inset_0_0_0_1px_rgb(255_255_255/0.04),inset_0_0_42px_rgb(0_0_0/0.75)]"
        />
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0 rounded-[inherit] bg-[radial-gradient(ellipse_55%_38%_at_22%_8%,rgb(255_255_255/0.07),transparent_70%)]"
        />
        {children != null && (
          <div className="pointer-events-none absolute inset-0 font-mono text-[11px] leading-none tracking-[0.04em] text-[rgb(var(--neon)/0.85)] [text-shadow:0_0_8px_rgb(var(--neon)/CALC(0.65*var(--neon-glow,1)))]">
            {children}
          </div>
        )}
      </div>
    );
  },
);
Oscilloscope.displayName = "Oscilloscope";

export { Oscilloscope };
```
