# Meter

> LED VU meter with zones and peak hold.

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

## Installation

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

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

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

## Usage

```tsx
import { Meter } from "@/components/ui/meter"

<Meter label="L" value={0.72} />
```

## Examples

### Stereo bus

Two vertical meters fed from a simulated mix. Segments strike instantly and decay over a few frames, and the peak segment lingers before it falls.

```tsx
"use client";

import * as React from "react";
import { Meter } from "@/components/ui/meter";

// The meters span -54 to +6 dBFS, so the default red zone (from 90%) starts at 0 dB.
const scale = [6, 0, -6, -12, -24, -36, -48];
const toLevel = (db: number) => (db + 54) / 60;
const toDb = (level: number) => level * 60 - 54;

type Channel = { level: number; peak: number; peakAt: number };

// A stand-in for a real mix: a steady bed, a kick on every beat and a little
// grit, with the right channel a touch behind the left.
function useStereoSignal(ref: React.RefObject<HTMLElement | null>) {
  const [channels, setChannels] = React.useState<[Channel, Channel]>([
    { level: 0, peak: 0, peakAt: 0 },
    { level: 0, peak: 0, peakAt: 0 },
  ]);

  React.useEffect(() => {
    let raf = 0;
    let last = 0;
    const start = performance.now();
    const state: [Channel, Channel] = [
      { level: 0, peak: 0, peakAt: 0 },
      { level: 0, peak: 0, peakAt: 0 },
    ];
    let visible = true;
    const tick = (now: number) => {
      raf = visible ? requestAnimationFrame(tick) : 0;
      if (now - last < 30) return;
      last = now;
      const t = (now - start) / 1000;
      const beat = t / (60 / 124);
      const phrase = Math.sin(t * 0.45) * 0.05;
      state.forEach((channel, i) => {
        const hit = (beat + i * 0.04) % 1;
        // Every fourth bar the chorus pushes into the red.
        const lift = Math.floor(beat / 16) % 4 === 3 ? 0.08 : 0;
        const target = 0.6 + phrase + lift + Math.exp(-hit * 5.5) * 0.23 + (Math.random() - 0.5) * 0.07;
        // Ballistics: jump up to a transient, ease back down.
        channel.level = Math.min(1, target > channel.level ? target : channel.level + (target - channel.level) * 0.3);
        if (channel.level >= channel.peak || now - channel.peakAt > 2000) {
          channel.peak = channel.level;
          channel.peakAt = now;
        }
      });
      setChannels([{ ...state[0] }, { ...state[1] }]);
    };
    // The meters stop when the panel scrolls away and pick up again when it's back.
    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      if (visible && !raf) raf = requestAnimationFrame(tick);
    });
    if (ref.current) io.observe(ref.current);
    raf = requestAnimationFrame(tick);
    return () => {
      io.disconnect();
      cancelAnimationFrame(raf);
    };
  }, [ref]);

  return channels;
}

function Readout({ level }: { level: number }) {
  const db = toDb(level);
  return (
    <span
      className={
        db >= 0
          ? "font-mono text-[11px] tabular-nums text-[#ff4d5e] [text-shadow:0_0_10px_rgb(255_77_94/0.7)]"
          : "font-mono text-[11px] tabular-nums text-white/70"
      }
    >
      {level === 0 ? "\u2212inf" : `${db > 0 ? "+" : db < 0 ? "\u2212" : ""}${Math.abs(db).toFixed(1)}`}
    </span>
  );
}

function Screw({ className }: { className: string }) {
  return (
    <span
      aria-hidden
      className={`absolute size-[7px] rounded-full bg-[radial-gradient(circle_at_30%_30%,#3b404d,#101219)] shadow-[0_0_0_1px_rgb(255_255_255/0.05)] after:absolute after:left-1/2 after:top-1/2 after:h-px after:w-[5px] after:-translate-x-1/2 after:-translate-y-1/2 after:rotate-45 after:bg-black/70 ${className}`}
    />
  );
}

export default function MeterDemo() {
  const panelRef = React.useRef<HTMLDivElement>(null);
  const [left, right] = useStereoSignal(panelRef);

  return (
    <div ref={panelRef} className="relative rounded-2xl border border-white/10 bg-[linear-gradient(180deg,#11131a,#0a0b10)] px-8 pb-6 pt-5 shadow-[inset_0_1px_0_rgb(255_255_255/0.05)]">
      <Screw className="left-2.5 top-2.5" />
      <Screw className="right-2.5 top-2.5" />
      <Screw className="bottom-2.5 left-2.5" />
      <Screw className="bottom-2.5 right-2.5" />
      <div className="mb-4 flex items-baseline justify-between gap-10 text-xs">
        <span className="font-medium text-white/80">Master</span>
        <span className="text-white/50">Peak, dBFS</span>
      </div>
      <div className="grid grid-cols-[auto_2rem_auto] justify-center gap-x-3 gap-y-2">
        <div className="text-center">
          <Readout level={left.peak} />
        </div>
        <span />
        <div className="text-center">
          <Readout level={right.peak} />
        </div>
        <Meter aria-label="Left channel" orientation="vertical" size="lg" value={left.level} className="h-72" />
        <div aria-hidden className="relative">
          {scale.map((db) => (
            <span
              key={db}
              className="absolute inset-x-0 translate-y-1/2 text-center text-[10px] font-medium tabular-nums text-white/50"
              style={{ bottom: `calc(4px + ${toLevel(db)} * (100% - 8px))` }}
            >
              {db > 0 ? `+${db}` : db < 0 ? `\u2212${-db}` : db}
            </span>
          ))}
        </div>
        <Meter aria-label="Right channel" orientation="vertical" size="lg" value={right.level} className="h-72" />
        <span className="text-center text-[11px] font-semibold text-white/60">L</span>
        <span />
        <span className="text-center text-[11px] font-semibold text-white/60">R</span>
      </div>
    </div>
  );
}
```

### Microphone check

Drive it from a real input: the example reads microphone loudness with Web Audio after you click.

```tsx
"use client";

import * as React from "react";
import { Mic, MicOff } from "@/components/ui/icons";
import { Meter } from "@/components/ui/meter";

type Status = "idle" | "listening" | "blocked";

// Maps loudness to the meter: -60 dBFS reads empty, 0 dBFS fills it.
const toLevel = (rms: number) => Math.min(1, Math.max(0, (20 * Math.log10(rms + 1e-8) + 60) / 60));

export default function MeterMicCheck() {
  const [status, setStatus] = React.useState<Status>("idle");
  const [level, setLevel] = React.useState(0);
  const stopRef = React.useRef<(() => void) | null>(null);

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

  const start = async () => {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      // Created inside the click, so the browser lets it start.
      const context = new AudioContext();
      const analyser = context.createAnalyser();
      analyser.fftSize = 1024;
      context.createMediaStreamSource(stream).connect(analyser);
      const samples = new Float32Array(analyser.fftSize);
      let raf = 0;
      const read = () => {
        analyser.getFloatTimeDomainData(samples);
        let sum = 0;
        for (const sample of samples) sum += sample * sample;
        setLevel(toLevel(Math.sqrt(sum / samples.length)));
        raf = requestAnimationFrame(read);
      };
      read();
      stopRef.current = () => {
        cancelAnimationFrame(raf);
        stream.getTracks().forEach((track) => track.stop());
        void context.close();
      };
      setStatus("listening");
    } catch {
      setStatus("blocked");
    }
  };

  const stop = () => {
    stopRef.current?.();
    stopRef.current = null;
    setLevel(0);
    setStatus("idle");
  };

  return (
    <div className="w-full max-w-sm rounded-xl border border-white/10 bg-white/[0.03] p-4">
      <p className="text-sm font-medium text-white/90">Microphone</p>
      <p className="mt-1 text-xs leading-5 text-white/50">
        {status === "blocked"
          ? "Microphone access was blocked. Allow it in your browser's site settings."
          : "Speak at your normal volume. The bar should reach the amber zone."}
      </p>
      <Meter aria-label="Input level" value={level} segments={28} className="mt-4 w-full" />
      <button
        type="button"
        onClick={status === "listening" ? stop : start}
        className="mt-4 inline-flex h-8 cursor-pointer items-center gap-2 rounded-lg border border-white/10 bg-white/[0.04] px-3 text-xs text-white/80 transition-colors hover:bg-white/[0.08] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon-accent,34_211_238)/0.6)]"
      >
        {status === "listening" ? <MicOff className="size-3.5" /> : <Mic className="size-3.5" />}
        {status === "listening" ? "Stop test" : "Test microphone"}
      </button>
    </div>
  );
}
```

### Not just audio

Any 0 to 1 level works. A longer peakHold keeps the recent high visible between updates.

```tsx
"use client";

import * as React from "react";
import { Meter } from "@/components/ui/meter";

const resources = [
  { name: "CPU", base: 0.46, swing: 0.3 },
  { name: "Memory", base: 0.71, swing: 0.06 },
  { name: "Disk I/O", base: 0.22, swing: 0.4 },
];

// A host's load wanders once a second; the peak marker shows the recent high.
export default function MeterServerLoad() {
  const [tick, setTick] = React.useState(0);

  React.useEffect(() => {
    const timer = setInterval(() => setTick((value) => value + 1), 1000);
    return () => clearInterval(timer);
  }, []);

  return (
    <div className="w-full max-w-sm rounded-xl border border-white/10 bg-white/[0.03] p-4">
      <div className="mb-4 flex items-center justify-between">
        <p className="text-sm font-medium text-white/90">edge-fra-02</p>
        <p className="text-xs text-white/50">Updated every second</p>
      </div>
      <div className="grid gap-3">
        {resources.map((resource, i) => {
          // Deterministic wobble, so the first render matches the server.
          const wobble = Math.sin(tick * (0.9 + i * 0.7) + i * 2) * Math.sin(tick * 0.37 + i);
          const value = Math.min(1, Math.max(0, resource.base + wobble * resource.swing));
          return (
            <div key={resource.name} className="grid grid-cols-[4.5rem_1fr_2.75rem] items-center gap-3">
              <span id={`load-${i}`} className="text-xs text-white/60">
                {resource.name}
              </span>
              <Meter aria-labelledby={`load-${i}`} size="sm" segments={20} value={value} peakHold={2500} className="w-full" />
              <span className="text-right font-mono text-xs tabular-nums text-white/75">{Math.round(value * 100)}%</span>
            </div>
          );
        })}
      </div>
    </div>
  );
}
```

### Zones and colors

Zones without a color use the meter's neon color, so color restyles the safe range. Pass your own bands for anything else.

```tsx
import { Meter } from "@/components/ui/meter";

export default function MeterZones() {
  return (
    <div className="grid w-full max-w-sm gap-5">
      <div className="grid gap-2">
        <p className="text-xs text-white/50">Default: neon, then amber and red near the top</p>
        <Meter label="Mix" value={0.78} peak={0.92} className="w-full" />
      </div>
      <div className="grid gap-2">
        <p className="text-xs text-white/50">Classic broadcast green</p>
        <Meter label="Mix" color="green" value={0.66} peak={0.8} className="w-full" />
      </div>
      <div className="grid gap-2">
        <p className="text-xs text-white/50">One color all the way</p>
        <Meter label="Bus" color="violet" zones={[{ from: 0 }]} value={0.58} peak={0.71} className="w-full" />
      </div>
      <div className="grid gap-2">
        <p className="text-xs text-white/50">Your own bands</p>
        <Meter
          label="GPU"
          segments={20}
          value={0.83}
          peak={0.9}
          zones={[
            { from: 0, color: "blue" },
            { from: 0.5, color: "lime" },
            { from: 0.75, color: "orange" },
            { from: 0.9, color: "red" },
          ]}
          className="w-full"
        />
      </div>
    </div>
  );
}
```

## API reference

### Meter

Renders a div with role="meter" and accepts its attributes; the ref points at it.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` | — | Level from 0 to 1. Out-of-range values are clamped. |
| `peak` | `boolean \| number` | `true` | true holds the highest recent segment, then lets it fall. A number (0 to 1) places the peak marker yourself. false hides it. |
| `peakHold` | `number` | `900` | Milliseconds the automatic peak lingers before falling, one segment every 40ms. |
| `segments` | `number` | `24` | Number of LED segments. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Vertical meters fill from the bottom. |
| `zones` | `MeterZone[]` | `neon → amber 72% → red 90%` | Color bands from low to high. Each segment takes the last zone that starts at or below it. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Segment thickness and the default length (144, 208 or 272px). Override the length with a w-* or h-* class. |
| `label` | `ReactNode` | — | Short caption at the start of the meter. Also names it for screen readers. |
| `color` | `NeonColor` | `inherited` | Color of zones without their own. Falls back to --neon-accent, then cyan. |

### MeterZone

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `from` | `number` | — | Where the zone starts, 0 to 1. |
| `color` | `NeonColor` | — | Palette name or hex. Leave it out to use the meter's neon color. |

## Accessibility

- role="meter" reports the level as aria-valuenow from 0 to 100 with a percentage in aria-valuetext. Pass aria-valuetext to read something else, such as decibels.
- Without a label, pass aria-label or aria-labelledby.
- Values can change every frame: segments are plain spans, and the falling peak runs on its own clock and writes to the DOM, so it never re-renders the meter.
- With reduced motion, segments switch without the decay and the peak drops straight to the level once its hold ends.

## Source

`components/ui/meter.tsx`

```tsx
"use client";

import * as React from "react";

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

// LEDs strike instantly and decay over a few frames, like real ones behind a diffuser.
// Each lit segment only draws its core; the halo is one drop-shadow over the whole row, so a meter with
// forty segments moving 20 times a second paints one blur instead of forty. Segments tinted by a zone
// (the red over the line) keep a small halo of their own color.
const css = `
.nk-meter-row {
  filter: drop-shadow(0 0 5px rgb(var(--neon) / calc(0.5 * var(--neon-glow, 1))));
}
.nk-meter-seg {
  background-color: rgb(var(--nk-meter-seg) / 0.14);
  box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04), inset 0 0 0 1px rgb(var(--nk-meter-seg) / 0.08);
  transition: background-color 200ms ease-out;
}
.nk-meter-seg[data-lit], .nk-meter-seg[data-peak] {
  background-color: color-mix(in srgb, rgb(var(--nk-meter-seg)) 82%, white);
  box-shadow: inset 0 0 3px 1px rgb(255 255 255 / calc(0.45 * var(--neon-glow, 1)));
  transition-duration: 0ms;
}
.nk-meter-seg[data-tinted][data-lit], .nk-meter-seg[data-tinted][data-peak] {
  box-shadow: inset 0 0 3px 1px rgb(255 255 255 / calc(0.45 * var(--neon-glow, 1))), 0 0 6px rgb(var(--nk-meter-seg) / calc(0.8 * var(--neon-glow, 1)));
}
@media (prefers-reduced-motion: reduce) { .nk-meter-seg { transition: none; } }
`;

// Length is a class, so className can override it (w-full, h-64…).
const sizes = {
  sm: { thickness: 6, gap: 2, horizontal: "w-36", vertical: "h-36", label: "text-[10px]" },
  md: { thickness: 10, gap: 2, horizontal: "w-52", vertical: "h-52", label: "text-[11px]" },
  lg: { thickness: 16, gap: 3, horizontal: "w-[272px]", vertical: "h-[272px]", label: "text-xs" },
} as const;

export interface MeterZone {
  /** Where the zone starts, 0 to 1. */
  from: number;
  /** Palette name or hex. Leave it out to use the meter's neon color. */
  color?: NeonColor;
}

const defaultZones: MeterZone[] = [{ from: 0 }, { from: 0.72, color: "amber" }, { from: 0.9, color: "red" }];

function clamp01(value: number) {
  return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;
}

export interface MeterProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Level, 0 to 1. */
  value: number;
  /** Hold the highest recent segment, then let it fall. Pass a number (0 to 1) to place the peak yourself. */
  peak?: boolean | number;
  /** Milliseconds the automatic peak lingers before it falls. */
  peakHold?: number;
  /** Number of LED segments. */
  segments?: number;
  orientation?: "horizontal" | "vertical";
  /** Color bands from low to high. Defaults to neon, then amber from 72%, then red from 90%. */
  zones?: MeterZone[];
  size?: keyof typeof sizes;
  /** Short caption at the start of the meter, such as "L" or "Mic". Also names it. */
  label?: React.ReactNode;
  /** The color of zones without their own. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
}

const Meter = React.forwardRef<HTMLDivElement, MeterProps>(
  (
    {
      className,
      style,
      color,
      value,
      peak = true,
      peakHold = 900,
      segments = 24,
      orientation = "horizontal",
      zones = defaultZones,
      size = "md",
      label,
      ...props
    },
    ref,
  ) => {
    const labelId = React.useId();
    const segmentRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
    const count = Math.max(1, Math.round(segments));
    const level = clamp01(value);
    const lit = Math.round(level * count);
    const auto = peak === true;
    const s = sizes[size];
    const vertical = orientation === "vertical";

    // The zone colors only change with their props, so the segments render once per set.
    const zoneKey = zones.map((zone) => `${zone.from}:${zone.color ?? ""}`).join("|");
    const tints = React.useMemo(() => {
      const sorted = zoneKey
        .split("|")
        .map((entry) => {
          const [from, zoneColor] = entry.split(":");
          return { from: Number(from), color: zoneColor };
        })
        .sort((a, b) => a.from - b.from);
      return Array.from({ length: count }, (_, i) => {
        const start = i / count + 1e-6;
        let tint = "var(--neon)";
        for (const zone of sorted) if (zone.from <= start) tint = zone.color ? toNeonRgb(zone.color) : "var(--neon)";
        return tint;
      });
    }, [zoneKey, count]);

    // Automatic peak hold runs on its own clock and writes straight to the DOM,
    // so a falling peak never re-renders the meter.
    const peakState = React.useRef({ index: -1, since: 0, raf: 0, shown: -1 });
    const levelRef = React.useRef(lit - 1);

    React.useEffect(() => {
      levelRef.current = lit - 1;
      const state = peakState.current;
      if (!auto) return;
      const show = (index: number) => {
        if (index === state.shown) return;
        segmentRefs.current[state.shown]?.removeAttribute("data-peak");
        if (index >= 0) segmentRefs.current[index]?.setAttribute("data-peak", "");
        state.shown = index;
      };
      const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
      const tick = (now: number) => {
        state.raf = 0;
        const floor = levelRef.current;
        if (state.index <= floor) {
          state.index = floor;
          show(floor);
          return;
        }
        if (now - state.since > peakHold) {
          if (reduce) state.index = floor;
          else {
            // Fall one segment every 40ms once the hold expires.
            const steps = Math.floor((now - state.since - peakHold) / 40);
            state.index = Math.max(floor, state.index - Math.max(1, steps));
            state.since = now - peakHold;
          }
        }
        show(state.index);
        if (state.index > floor) state.raf = requestAnimationFrame(tick);
      };
      if (lit - 1 >= state.index) {
        state.index = lit - 1;
        state.since = performance.now();
        show(state.index);
      } else if (!state.raf) state.raf = requestAnimationFrame(tick);
    }, [lit, auto, peakHold]);

    React.useEffect(() => {
      const state = peakState.current;
      return () => {
        cancelAnimationFrame(state.raf);
        state.raf = 0;
      };
    }, []);

    // Leaving auto mode clears the attribute the clock wrote.
    React.useEffect(() => {
      if (auto) return;
      const state = peakState.current;
      cancelAnimationFrame(state.raf);
      state.raf = 0;
      segmentRefs.current[state.shown]?.removeAttribute("data-peak");
      state.shown = -1;
      state.index = -1;
    }, [auto]);

    const fixedPeak = typeof peak === "number" ? Math.round(clamp01(peak) * count) - 1 : -1;
    const percent = Math.round(level * 100);

    return (
      <div
        ref={ref}
        role="meter"
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={percent}
        aria-valuetext={`${percent}%`}
        aria-labelledby={label ? labelId : undefined}
        data-orientation={orientation}
        style={neonVars(color, { "--nk-meter-level": level, ...style } as React.CSSProperties)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "inline-flex shrink-0 items-center gap-2",
          vertical ? cn("flex-col-reverse", s.vertical) : s.horizontal,
          className,
        )}
        {...props}
      >
        <style>{css}</style>
        {label != null && (
          <span
            id={labelId}
            className={cn(
              "shrink-0 select-none font-semibold uppercase leading-none tracking-[0.12em] text-white/60",
              s.label,
            )}
          >
            {label}
          </span>
        )}
        <span
          aria-hidden
          className={cn(
            "flex min-h-0 min-w-0 flex-1 rounded-[5px] border border-white/10 bg-[#07080c] p-[3px]",
            // The housing picks up light from the lit segments, more as the level rises.
            "shadow-[inset_0_1px_3px_rgb(0_0_0/0.7),0_1px_0_rgb(255_255_255/0.03),0_0_28px_-4px_rgb(var(--neon)/CALC(calc(var(--nk-meter-level)*0.4)*var(--neon-glow,1)))]",
          )}
          style={{ [vertical ? "width" : "height"]: s.thickness + 8 }}
        >
          <span className={cn("nk-meter-row flex min-h-0 min-w-0 flex-1", vertical && "flex-col-reverse")} style={{ gap: s.gap }}>
            {tints.map((tint, i) => (
              <span
                key={i}
                ref={(node) => {
                  segmentRefs.current[i] = node;
                }}
                data-lit={i < lit ? "" : undefined}
                data-peak={i === fixedPeak && i >= lit ? "" : undefined}
                data-tinted={tint === "var(--neon)" ? undefined : ""}
                style={{ "--nk-meter-seg": tint } as React.CSSProperties}
                className="nk-meter-seg min-h-0 min-w-0 flex-1 rounded-[1.5px]"
              />
            ))}
          </span>
        </span>
      </div>
    );
  },
);
Meter.displayName = "Meter";

export { Meter };
```
