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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/equalizer.jsonPrompt
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-motionskips 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-disabledplus handler guards keep every control focusable so nothing goes inert under the user's hands.
Scratch Card
A canvas foil erased under the pointer, with a coverage threshold that opens the card itself and a Reveal button that does the same from the keyboard.
Lyrics Sync
Karaoke-style synced lyrics — an injected playhead lights each line, word by word where word timings exist, keeps it on the anchor until you scroll away, and turns every line into a seek target.