Media

Equalizer

A multi-band graphic equalizer — rotated native faders, a Catmull-Rom response curve through the handles, presets that glide, and a typed gain map out.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Lock } from "lucide-react"
import { cn } from "@/lib/utils"

/** How long one status sentence stays on screen before it clears itself. */
const STATUS_MS = 4000
/** Thumb diameter in px. The handle centre travels inset by half of it at both ends. */
const THUMB_PX = 14
/** Breathing room above and below the curve, so a full-scale bend is not clipped. */
const CURVE_PAD_Y = 8
/** Keys the browser itself turns into a value change on a native range input. */
const VALUE_KEYS = new Set([

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/equalizer.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "Equalizer" component: one vertical fader
per frequency band, a smooth response curve drawn through the handles, and
presets that glide the handles to their targets. lucide-react for one icon; no
other dependency, and no Web Audio at all — the component emits numbers, wiring
them to BiquadFilterNodes is the consumer's job.

Contract
- Export a forwardRef div extending HTMLAttributes (minus defaultValue and
  onChange), plus the types EqualizerBand, EqualizerPreset and
  EqualizerValue = Record<string, number> — gain in dB, keyed by band id.
- bands: EqualizerBand[] = seven defaults (60, 150, 400, 1000, 2400, 6000,
  12000 Hz). A band is { id, frequency, label?, locked?, lockedReason? }; the
  label defaults to a formatted frequency (60 -> "60 Hz", 2400 -> "2.4 kHz").
- presets: EqualizerPreset[] = { id, label, gains: EqualizerValue }. A preset
  omitting a band sends it to resetGain, so gains: {} is a portable "Flat" that
  survives any band layout. Pass [] to drop the preset row.
- value / defaultValue / onValueChange(map) / onValueCommit(map): controlled or
  uncontrolled, same shape both ways.
- min (-12), max (12), step (1), resetGain (0) — all dB.
- trackHeight (152), curveHeight (96), showCurve (true), showValues (true),
  presetDurationMs (420), disabled (false), label ("Equalizer"),
  formatGain (default "+3 dB" / "-4 dB"), onRefuse(message).

Behavior
- Every fader is a real <input type="range"> rotated -90deg inside a wrapper of
  trackHeight: absolutely positioned, left/top 50%, translate(-50%,-50%) then
  rotate, so max ends up at the top while ArrowUp still means louder. The whole
  keyboard model is the platform's — no re-implementation, no drag-only trap.
- Value pipeline, one path for pointer and keys alike: clamp into [min, max],
  then snap onto the grid min + round((v - min) / step) * step, rounded to the
  decimal count of step itself so a 0.5 step leaves no floating-point tails, and
  clamped again to the last reachable stop min + floor((max - min) / step) * step
  — with min -12, max 12 and step 5 that is 8, exactly where the browser stops
  the thumb, and rounding past it would hand the input a value it sanitises away
  silently. Every emitted map is a complete map of the current bands.
- Keyboard: Arrow/Page/Home/End are the browser's. Unmodified Backspace, Delete
  and 0 reset the focused band to resetGain — the keyboard twin of the
  double-click, because a gesture must never be the only path to a feature. A
  key held with Ctrl/Cmd/Alt is handed straight back to the browser, or Cmd+0
  would reset a band instead of the page zoom.
- Double-click a handle resets that band and commits immediately.
- Presets: pressing one computes a full target map, then runs a
  requestAnimationFrame tween (ease-out cubic, presetDurationMs) whose start
  instant is the first rAF timestamp, never a clock read during render. Any
  pointerdown or value key on a fader cancels the tween mid-flight and hands the
  value back to the user; the frozen frame is snapped onto the grid and committed
  on the spot, so what the control holds is what the handles show rather than the
  fraction of a dB the glide happened to be passing through. Under
  prefers-reduced-motion (read through
  useSyncExternalStore, never at render time) the targets are applied in one
  step — the feature works, only the decoration is gone.
- The tween needs one trick: a range input rounds any off-step value it is
  handed, so a 6 dB glide on a 1 dB grid would be a six-frame staircase. While a
  tween runs, the faders switch to step="any" and the readouts show the
  intermediate value to 0.1 dB; the grid is restored — and the on-grid value
  committed — on the last frame.
- Change vs commit: onValueChange fires on every drag frame, key press and tween
  frame; onValueCommit fires once per settled interaction — pointerup, pointer
  cancel, keyup, blur, a reset, an interrupted glide, or the tween's final frame.
  Outside a tween frame every emitted map is built on a grid-snapped base, so no
  fractional gain from a cancelled glide can survive in it. The committed map is
  read from a ref written by the same emit that raised the pending flag, because
  the value prop can still be a render behind at pointerup. Wire expensive work
  (rebuilding an audio graph, persisting a profile) to commit.
- Refusals, each one a sentence rather than a dead control:
  - A locked band keeps its gain through presets, refuses drags and keys, and
    answers with its own lockedReason.
  - A preset that names bands but none of the ones on screen (a profile saved
    for an older layout) refuses instead of flattening everything.
  - disabled guards every handler and never uses the native attribute — the
    browser blurs a control the instant it is disabled, and these can go inert
    while the user is on them.
- Guarded change events restore the DOM node in the same tick: the node has
  already moved, the value this render carries has not, so the handler writes
  input.value back rather than trusting a later render to correct it.
- Value-map hygiene: keys the current bands do not cover (a gain stored by an
  older band set) ride along untouched instead of being deleted by the first
  drag; bands the map never mentioned start at resetGain; a duplicated band id
  is dropped rather than keying two children the same; max <= min falls back to
  min + 1 so no mapping divides by zero.
- One status sentence serves the visible line and a polite aria-live region, and
  clears itself after 4s so the next identical refusal is announced again
  instead of being swallowed as a no-change.
- Cleanup: cancelAnimationFrame plus the status timeout on unmount, and the
  same cancel when the band layout changes — a tween that outlived its layout
  would write gains for bands that no longer exist. The curve panel's
  ResizeObserver is disconnected on unmount.

Rendering & styling
- The curve: measure the panel with a ResizeObserver (observe() fires once,
  which is the initial measurement) and draw in CSS pixels, so no stretched
  viewBox distorts the stroke. Handle i sits at x = (i + 0.5) * width / bands,
  the centre of its share of the panel and so of the fader column under it (the
  fader grid's gap moves that by at most a pixel or two, which the eye reads as
  aligned); y = pad + (1 - (gain - min) /
  (max - min)) * (height - 2 * pad). Run a uniform Catmull-Rom through the
  points, converted to cubic Béziers: c1 = p1 + (p2 - p0) / 6,
  c2 = p2 - (p3 - p1) / 6, endpoints duplicated. The spline passes exactly
  through every handle — an approximating curve would draw a shape the faders
  contradict. Clamp each control point between its two anchors on x and inside
  the panel on y, so a ±12 dB step bends hard instead of doubling back or
  leaving the frame. The outer handles are extended flat to both edges; the
  filled area is the curve closed along the bottom.
- Semantic tokens only: stroke-primary curve, fill-primary area at 0.12 opacity,
  stroke-border dashed 0 dB line, bg-muted/30 panel, bg-card demo surfaces,
  text-muted-foreground labels, text-destructive refusals. The track gradient is
  painted from CSS variables set in style (--eq-fill = var(--primary) or
  var(--muted-foreground) when locked, --eq-from/--eq-to = the two percentages),
  so only the span between 0 dB and the handle is coloured and a boost reads
  differently from a cut. Thumb: appearance-none, size-3.5, border-2, -mt-1 on
  WebKit to centre it on the 6px track.
- ARIA: role="group" with the label on the root; each fader is a native range
  (role=slider for free) named by a real <label htmlFor>, carrying
  aria-orientation="vertical", aria-valuetext through formatGain, and — when
  locked — aria-describedby pointing at an sr-only sentence. Preset buttons are
  aria-pressed toggles whose pressed state is derived by comparing the current
  gains with the preset's targets, so it stays true after a manual edit. The SVG
  is aria-hidden: it only restates what the faders already say.
- Focus ring lives on the fader's wrapper via has-[:focus-visible], not on the
  rotated input, so it stays axis-aligned with the column. touch-none on the
  input keeps a vertical drag from scrolling the page.
- Merge the consumer className with cn() and spread the rest onto the root.

Customization levers
- Bands: any count and any frequency set; label overrides the derived text, so
  "Low / Mid / High" tone controls are the same component. Everything (grid
  columns, curve x positions, preset targets) follows the array.
- Range and grid: min/max/step/resetGain are independent — ±6 dB at 0.5 dB, or
  a 0..1 send level with resetGain 0 and the reference line at the bottom.
- Density: trackHeight, curveHeight, showCurve and showValues shrink it to a
  bare fader strip for a mixer row; drop presets with presets={[]}.
- Motion: presetDurationMs 0 makes presets instant for everyone; swap the
  ease-out cubic for a spring if the rest of the product uses one.
- Colour: --eq-fill is one line — point it at var(--chart-2) to match a chart
  family, or at var(--destructive) above a threshold for a "too hot" fader; the
  curve's stroke-primary/fill-primary pair is the other half.
- Semantics: formatGain drives the readout and aria-valuetext together (dB, %,
  a word scale). locked + lockedReason turn any band into a pinned one, and
  onRefuse forwards every refusal sentence to a toast or a log.

Concepts

  • Gain map as the whole output — the component owns no audio and no filter: it produces { bandId: dB } and nothing else, which is what lets the same control drive a Web Audio chain, a server-side profile or a saved preset without knowing about any of them.
  • Rotated native range — each fader is a real <input type="range"> turned a quarter turn, so arrows, Page keys, Home/End, pointer capture and the slider role arrive from the platform. Nothing about the interaction is re-implemented, and there is no drag-only path into any value.
  • Catmull-Rom through the handles — the preview is an interpolating spline, not a smoothing one: it passes exactly through every handle, so the picture can never claim a curve the faders disagree with. Control points are clamped between their anchors and inside the panel, which is what keeps a ±12 dB neighbour step from drawing outside the frame.
  • Preset tween that can be interrupted — a preset is a target map plus an ease-out rAF glide whose start comes from the first frame's timestamp, not a clock read. Touching a fader cancels it and the user keeps whatever the glide had reached; prefers-reduced-motion skips the glide entirely and the presets still work.
  • Step grid relaxed for the glide — a range input rounds every off-step value it is handed, so an animation across a 1 dB grid would arrive as a staircase. The faders switch to step="any" for the duration of the tween and are put back on the grid by its final frame, which is also the frame that commits. A glide that is interrupted instead of finished snaps its frozen frame the same way, so the value a fader holds is never a fraction of a dB away from the value it shows.
  • Refusal instead of a dead control — locked bands, presets from an older band layout and the disabled state all answer with a sentence in a polite live region rather than going quiet; the guarded input is restored in the same tick, and aria-disabled plus handler guards keep every control focusable so nothing goes inert under the user's hands.

On This Page