Components
AudioVisualizer
BetaBeta — the props contract is close to final, but small breaking changes can still land.Levels-driven realtime audio visualizer offered in two usage versions - bars and line - sharing one export and one props contract.
Version
Composition
Levels-driven visualizer
Both the bars and line versions use the same component and levels prop.
- <AudioVisualizer />
Installation
First install and activate one skin. Core deliberately contains no visual token defaults.
Usage versions are sibling registry items sharing one export and one props contract — install the Bars item with the command above; swapping versions later is an import-path change, no call site moves.
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} />;}Raw code
Owned source and private support files (Bars version)
import type { ComponentProps, CSSProperties } from "react";import type { AudioVisualizerKnobStyle } from "@/components/control-ui/knob-contracts/audio-visualizer-knobs";import { cn } from "@/components/control-ui/lib/cn";export type AudioVisualizerProps = Omit<ComponentProps<"div">, "children" | "style"> & { levels: readonly number[]; active?: boolean; points?: number; style?: CSSProperties & AudioVisualizerKnobStyle;};// Bars reading. audio-visualizer-line.tsx exports same `AudioVisualizer` on same props, so swapping is import-path change.// Runtime-agnostic — feed it any rolling 0..1 window.type AudioVisualizerLevelStyle = CSSProperties & Record<"--_audio-visualizer-level-opacity", string>;const DEFAULT_POINTS = 28;const MAX_POINTS = 128;const MIN_VISIBLE_LEVEL = 0.12;// local so either visualizer installs as complete, independent choicefunction resolveAudioVisualizerLevels(levels: readonly number[], points?: number) { const requestedPoints = Number.isFinite(points) ? Math.floor(points ?? DEFAULT_POINTS) : DEFAULT_POINTS; const pointCount = Math.min(MAX_POINTS, Math.max(1, requestedPoints)); const visible = levels.slice(-pointCount).map((level) => (Number.isFinite(level) ? Math.min(1, Math.max(0, level)) : 0)); const padded = visible.length === pointCount ? visible : [...new Array<number>(pointCount - visible.length).fill(0), ...visible]; return padded.map((level, position) => ({ key: `bar-${position}`, level }));}export function AudioVisualizer({ levels, points, active = true, className, style, ...props }: AudioVisualizerProps) { const visible = resolveAudioVisualizerLevels(levels, points); return ( <div data-control-ui="audio-visualizer" data-control-family="audio-visualizer" data-slot="root" data-variant="bars" data-active={active ? "true" : undefined} aria-hidden="true" className={cn("shrink-0 overflow-hidden", className)} style={style} {...props} > <span 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-end mask-l-from-90%" > {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} data-control-ui="audio-visualizer" data-control-family="audio-visualizer" data-slot="bar-track" className="flex shrink-0 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-* · 5 knobsHow the cascade resolves--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)