AudioVisualizer
BetaBeta — the props contract is close to final, but small breaking changes can still land.Audio visualization with waveform, line envelope, and frequency bar alternatives. Choose the implementation that fits your audio data.
Alternatives
Composition
Levels-driven visualizer
Waveform and line envelope alternatives read amplitude history. Frequency bars read spectrum bands and add alignment, mirroring, idle, and loading options. Each alternative has its own install and import.
- <AudioVisualizer />
Installation
First install and activate one skin. Core deliberately contains no visual token defaults.
Install the Waveform alternative. To adopt another implementation, install its registry item and update the import in your project. Check its data requirements and options in Usage.
npx shadcn@latest add https://control-ui.dev/r/audio-visualizer.jsonUsage
"use client";import { useEffect, useState } from "react";import { AudioVisualizer } from "@/components/control-ui/audio-visualizer";const windowSize = 48;const sampleInterval = 80;function useElementLevels(audio: HTMLAudioElement | null) { const [levels, setLevels] = useState<number[]>(() => Array.from({ length: windowSize }, () => 0)); useEffect(() => { if (!audio) return; const context = new AudioContext(); const analyser = context.createAnalyser(); analyser.fftSize = 1024; const sourceNode = context.createMediaElementSource(audio); sourceNode.connect(analyser); analyser.connect(context.destination); const samples = new Uint8Array(analyser.fftSize); const timer = setInterval(() => { analyser.getByteTimeDomainData(samples); let sum = 0; for (const sample of samples) { const centered = (sample - 128) / 128; sum += centered * centered; } const rootMeanSquare = Math.sqrt(sum / samples.length); setLevels((previous) => [...previous.slice(1), Math.min(1, rootMeanSquare * 4)]); }, sampleInterval); return () => { clearInterval(timer); sourceNode.disconnect(); void context.close(); }; }, [audio]); return levels;}export function Example({ audio }: { audio: HTMLAudioElement | null }) { const levels = useElementLevels(audio); return <AudioVisualizer levels={levels} />;}Dependencies
src/registry/lib/audio-visualizer-levels.tsUtilRaw code
Installed source for the Waveform alternative
"use client";import type { ComponentProps, CSSProperties } from "react";import { useLayoutEffect, useRef } from "react";import type { AudioVisualizerKnobStyle } from "@/components/control-ui/knob-contracts/audio-visualizer-knobs";import { audioVisualizerHistory } from "@/components/control-ui/lib/audio-visualizer-levels";import { cn } from "@/components/control-ui/lib/cn";export type AudioVisualizerProps = Omit<ComponentProps<"div">, "children" | "style"> & { levels: ArrayLike<number>; active?: boolean; points?: number; style?: CSSProperties & AudioVisualizerKnobStyle;};type AudioVisualizerLevelStyle = CSSProperties & Record<"--_audio-visualizer-level-opacity", string>;const MIN_VISIBLE_LEVEL = 0.04;type HistorySample = { levels: number[]; time: number; active: boolean };function historyAdvanced(previous: HistorySample | null, next: HistorySample) { if (!previous?.active || !next.active || previous.levels.length !== next.levels.length || next.levels.length < 2) return false; const moved = previous.levels.slice(1).every((level, index) => level === next.levels[index]); const changed = previous.levels.some((level, index) => level !== next.levels[index]); return moved && changed;}export function AudioVisualizer({ levels, points, active = true, className, style, ...props }: AudioVisualizerProps) { const visible = audioVisualizerHistory(levels, points, active); const labelled = Boolean(props["aria-label"] || props["aria-labelledby"]); const trackRef = useRef<HTMLSpanElement | null>(null); const firstBarRef = useRef<HTMLSpanElement | null>(null); const previousSample = useRef<HistorySample | null>(null); useLayoutEffect(() => { const next = { levels: visible.map(({ level }) => level), time: performance.now(), active }; const previous = previousSample.current; previousSample.current = next; const track = trackRef.current; const firstBar = firstBarRef.current; if (!track || !firstBar || !historyAdvanced(previous, next) || document.hidden) return; const duration = next.time - (previous?.time ?? next.time); if (duration < 16 || duration > 250 || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; const direction = getComputedStyle(track).direction === "rtl" ? -1 : 1; const pitch = (track.clientWidth - firstBar.clientWidth) / (visible.length - 1); const animation = track.animate([{ transform: `translateX(${pitch * direction}px)` }, { transform: "translateX(0)" }], { duration, easing: "linear", }); return () => animation.cancel(); }, [visible, active]); return ( <div data-control-ui="audio-visualizer" data-control-family="audio-visualizer" data-slot="root" data-variant="bars" data-active={active ? "true" : undefined} role={labelled ? "img" : undefined} aria-hidden={labelled ? undefined : true} className={cn("shrink-0 overflow-hidden mask-x-from-90%", className)} style={style} {...props} > <span ref={trackRef} data-control-ui="audio-visualizer" data-control-family="audio-visualizer" data-slot="track" data-active={active ? "true" : undefined} className="flex size-full items-stretch justify-between" > {visible.map(({ key, level }) => { const perceptualLevel = Math.sqrt(level); const visibleLevel = MIN_VISIBLE_LEVEL + perceptualLevel * (1 - MIN_VISIBLE_LEVEL); const levelStyle: AudioVisualizerLevelStyle = { "--_audio-visualizer-level-opacity": `${0.48 + perceptualLevel * 0.52}`, clipPath: `inset(${(1 - visibleLevel) * 50}% 0 round var(--cui-audio-visualizer-bar-radius))`, }; return ( <span key={key} ref={key === "bar-0" ? firstBarRef : undefined} data-control-ui="audio-visualizer" data-control-family="audio-visualizer" data-slot="bar-track" className="flex items-center" > <span data-control-ui="audio-visualizer" data-control-family="audio-visualizer" data-slot="bar" className="block h-full w-full" style={levelStyle} /> </span> ); })} </span> </div> );}Knobs
Typed custom properties the recipe paints with. Set one on the root — style, a utility class, or a skin — and every slot inherits it.
--cui-audio-visualizer-* · 8 knobsHow the cascade resolves--cui-audio-visualizer-bar-size<length-percentage>calc(var(--spacing) * 1)--cui-audio-visualizer-bar-gap<length-percentage>1px--cui-audio-visualizer-radius<length-percentage>var(--radius-control)--cui-audio-visualizer-bar-radius<length-percentage>var(--radius-control)--cui-audio-visualizer-bar-background<color>oklch(from var(--primary) l c h / 0.8)--cui-audio-visualizer-line-fill<color>oklch(from var(--primary) l c h / 0.18)--cui-audio-visualizer-line-stroke<color>oklch(from var(--primary) l c h / 0.8)--cui-audio-visualizer-baseline-stroke<color>oklch(from var(--border) l c h / 0.55)