import * as React from "react";
import { MicrophoneIcon, StopIcon } from "@heroicons-animated/react";
import { Button } from "@/components/ui/button";
import { Waveform } from "@/components/pandacoderz-ui/waveform";
export default function WaveformDemo() {
const [active, setActive] = React.useState(true);
return (
<div className="flex w-full max-w-sm flex-col items-center gap-6">
<Waveform active={active} bars={32} height={48} className="w-full" />
<Waveform active={active} bars={12} height={20} className="text-muted-foreground" />
<Button variant={active ? "secondary" : "default"} size="sm" className="rounded-full" onClick={() => setActive((a) => !a)}>
{active ? <StopIcon size={14} className="flex" /> : <MicrophoneIcon size={14} className="flex" />}
{active ? "Stop" : "Start listening"}
</Button>
</div>
);
}Installation
npx shadcn@latest add https://ui.spencerwueste.com/r/waveform.jsonpnpm dlx shadcn@latest add https://ui.spencerwueste.com/r/waveform.jsonyarn dlx shadcn@latest add https://ui.spencerwueste.com/r/waveform.jsonbunx --bun shadcn@latest add https://ui.spencerwueste.com/r/waveform.jsonCopy the source into your project:
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
type WaveformProps = React.ComponentProps<"div"> & {
/** Number of bars. @default 24 */
bars?: number;
/** Animate bars. When false they settle to a flat line. */
active?: boolean;
/** Optional 0–1 levels, one per bar. Overrides the animation. */
levels?: number[];
/** Bar color. Defaults to currentColor. */
color?: string;
/** Height in px. @default 40 */
height?: number;
/** Base animation duration in seconds. @default 0.9 */
duration?: number;
};
/**
* Audio-style bar visualizer. Animated with CSS so it costs nothing while
* idle; pass `levels` from an AnalyserNode to render real audio.
*/
function Waveform({
bars = 24,
active = false,
levels,
color,
height = 40,
duration = 0.9,
className,
style,
...props
}: WaveformProps) {
const id = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
const keyframes = `pc-wave-${id}`;
const seeds = React.useMemo(
() => Array.from({ length: bars }, (_, i) => ((i * 7919) % 97) / 97),
[bars],
);
return (
<div
data-slot="waveform"
data-active={active || undefined}
role="img"
aria-label={active ? "Audio level" : "Audio idle"}
className={cn("flex items-center justify-center gap-[3px] text-brand", className)}
style={{ height, color, ...style }}
{...props}
>
<style>{`@keyframes ${keyframes}{0%,100%{transform:scaleY(var(--min))}50%{transform:scaleY(1)}}`}</style>
{seeds.map((seed, i) => {
const level = levels?.[i];
const min = 0.12 + seed * 0.25;
const peak = 0.45 + seed * 0.55;
return (
<span
key={i}
className="block w-[3px] shrink-0 rounded-full bg-current transition-transform duration-150 will-change-transform"
style={{
height: `${peak * 100}%`,
["--min" as string]: min / peak,
transform:
level !== undefined
? `scaleY(${Math.max(0.08, Math.min(1, level)) / peak})`
: active
? undefined
: "scaleY(0.12)",
animation:
active && level === undefined
? `${keyframes} ${duration + seed * 0.6}s ease-in-out ${-seed * duration}s infinite`
: undefined,
}}
/>
);
})}
</div>
);
}
export { Waveform };Usage
import { Waveform } from "@/components/pandacoderz-ui/waveform";<Waveform active={isListening} bars={32} height={48} />Pass levels to render real audio. Feed it from an AnalyserNode on each animation frame and the bars follow the microphone instead of the built-in animation.
const levels = React.useMemo(() => Array.from(frequencyData, (v) => v / 255), [frequencyData]);
<Waveform levels={levels} bars={levels.length} />API Reference
| Prop | Type | Description |
|---|---|---|
bars |
number |
Number of bars. Default 24. |
active |
boolean |
Animate. When false, bars flatten. |
levels |
number[] |
0–1 per bar; overrides the animation. |
height |
number |
Height in px. Default 40. |
duration |
number |
Base animation duration in seconds. Default 0.9. |
color |
string |
Bar color. Defaults to currentColor. |