nordaun
blockaudiosource: ok · hit
Audio
An audio playback with extendable control modules already included such as volume and speed.
npx shadcn add "https://ui.nordaun.com/r/audio.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
buttondropdown-menuslider
Files1 · source cached 10/14/2026, 1:49:30 PM
src/components/ui/audio.tsx
"use client";
import { MenuTrigger } from "@base-ui/react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import {
Check,
Gauge,
Pause,
Play,
Repeat,
Repeat1,
Volume,
Volume1,
Volume2,
VolumeX,
} from "lucide-react";
declare global {
interface Window {
webkitAudioContext: typeof AudioContext;
}
}
type AudioContext = {
audioRef: React.RefObject<HTMLAudioElement | null>;
containerRef: React.RefObject<HTMLDivElement | null>;
analyserRef: React.RefObject<AnalyserNode | null>;
graphRef: React.RefObject<globalThis.AudioContext | null>;
paused: boolean;
setPaused: React.Dispatch<React.SetStateAction<boolean>>;
progress: number;
setProgress: React.Dispatch<React.SetStateAction<number>>;
volume: number;
setVolume: React.Dispatch<React.SetStateAction<number>>;
muted: boolean;
setMuted: React.Dispatch<React.SetStateAction<boolean>>;
loop: boolean;
setLoop: React.Dispatch<React.SetStateAction<boolean>>;
playbackRate: number;
setPlaybackRate: React.Dispatch<React.SetStateAction<number>>;
duration: number;
setDuration: React.Dispatch<React.SetStateAction<number>>;
currentTime: number;
setCurrentTime: React.Dispatch<React.SetStateAction<number>>;
};
const DefaultAudioContext: AudioContext = {
audioRef: { current: null },
containerRef: { current: null },
analyserRef: { current: null },
graphRef: { current: null },
paused: true,
setPaused: () => {},
progress: 0,
setProgress: () => {},
volume: 1,
setVolume: () => {},
muted: false,
setMuted: () => {},
loop: false,
setLoop: () => {},
playbackRate: 1,
setPlaybackRate: () => {},
duration: 0,
setDuration: () => {},
currentTime: 0,
setCurrentTime: () => {},
};
if (typeof window !== "undefined") {
const _error = console.error.bind(console);
console.error = (...args) => {
if (typeof args[0] === "string" && args[0].includes("script")) return;
_error(...args);
};
}
const WaveformCache = new WeakMap<
HTMLAudioElement,
{ ctx: globalThis.AudioContext; analyser: AnalyserNode }
>();
const Context = React.createContext<AudioContext>(DefaultAudioContext);
type AudioProviderProps = {
defaultVolume?: number;
defaultMuted?: boolean;
defaultLoop?: boolean;
defaultRate?: number;
children: React.ReactNode;
};
function AudioProvider({
defaultVolume = 0.5,
defaultMuted = false,
defaultLoop = false,
defaultRate = 1,
children,
}: AudioProviderProps) {
const audioRef = React.useRef<HTMLAudioElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const analyserRef = React.useRef<AnalyserNode>(null);
const graphRef = React.useRef<globalThis.AudioContext>(null);
const [paused, setPaused] = React.useState<boolean>(true);
const [progress, setProgress] = React.useState<number>(0);
const [volume, setVolume] = React.useState<number>(defaultVolume);
const [muted, setMuted] = React.useState<boolean>(defaultMuted);
const [loop, setLoop] = React.useState<boolean>(defaultLoop);
const [playbackRate, setPlaybackRate] = React.useState<number>(defaultRate);
const [duration, setDuration] = React.useState<number>(0);
const [currentTime, setCurrentTime] = React.useState<number>(0);
React.useEffect(() => {
if (!audioRef.current) return;
audioRef.current.volume = volume;
audioRef.current.muted = muted;
audioRef.current.playbackRate = playbackRate;
audioRef.current.loop = loop;
setDuration(audioRef.current.duration);
if (WaveformCache.has(audioRef.current)) {
const cached = WaveformCache.get(audioRef.current)!;
analyserRef.current = cached.analyser;
graphRef.current = cached.ctx;
return;
}
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const source = ctx.createMediaElementSource(audioRef.current);
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
analyser.connect(ctx.destination);
analyserRef.current = analyser;
graphRef.current = ctx;
WaveformCache.set(audioRef.current, { ctx, analyser });
}, [volume, muted, playbackRate, loop]);
const values = {
audioRef,
containerRef,
analyserRef,
graphRef,
paused,
setPaused,
progress,
setProgress,
volume,
setVolume,
muted,
setMuted,
loop,
setLoop,
playbackRate,
setPlaybackRate,
duration,
setDuration,
currentTime,
setCurrentTime,
};
return <Context.Provider value={values}>{children}</Context.Provider>;
}
function AudioPlayer({
src,
className,
children,
...props
}: React.ComponentProps<"audio">) {
const {
audioRef,
containerRef,
setPaused,
setCurrentTime,
setProgress,
setDuration,
} = React.useContext(Context);
const handleTimeUpdate = () => {
if (!audioRef.current) return;
setCurrentTime(audioRef.current.currentTime);
setProgress(
(audioRef.current.currentTime / audioRef.current.duration) * 100,
);
};
const handleLoadedMetadata = () => {
setDuration(audioRef.current?.duration || 0);
};
const handleEnded = () => {
setPaused(true);
};
return (
<div
ref={containerRef}
className={cn(
"relative flex items-center gap-3 bg-secondary rounded-xl px-3 py-2 min-h-14 dark:bg-secondary/50",
className,
)}
>
<audio
ref={audioRef}
src={src}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={handleEnded}
{...props}
/>
<div className="flex items-center justify-between w-full gap-2">
{children}
</div>
</div>
);
}
function AudioControlSeek({ className }: { className?: string }) {
const { audioRef, setProgress, setCurrentTime } = React.useContext(Context);
const wrapperRef = React.useRef<HTMLDivElement>(null);
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!audioRef.current || !wrapperRef.current) return;
const rect = wrapperRef.current.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
const newTime = ratio * audioRef.current.duration;
audioRef.current.currentTime = newTime;
setCurrentTime(newTime);
setProgress(ratio * 100);
};
return (
<div
ref={wrapperRef}
className={cn("flex-1 flex items-center", className)}
onClick={handleClick}
>
<Waveform className="w-full" />
</div>
);
}
function AudioControlGroup({
className,
children,
...props
}: React.ComponentProps<"div">) {
return (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
}
function AudioControlPlay({
className,
...props
}: React.ComponentProps<"button">) {
const { audioRef, graphRef, paused, setPaused } = React.useContext(Context);
const handlePlay = async () => {
if (!audioRef.current) return;
if (paused) {
await graphRef.current?.resume();
audioRef.current.play();
} else {
audioRef.current.pause();
}
setPaused(!paused);
};
return (
<Button
variant="ghost"
size="icon"
onClick={handlePlay}
className={cn(
"text-card-foreground rounded-full aspect-square h-8",
className,
)}
{...props}
>
{paused ? (
<Play className="fill-current stroke-0" />
) : (
<Pause className="fill-current stroke-0" />
)}
</Button>
);
}
function AudioControlTimer({ className }: React.ComponentProps<"button">) {
const { currentTime, duration } = React.useContext(Context);
const [reverse, setReverse] = React.useState<boolean>(false);
const displayTime = reverse
? `-${Math.floor((duration - currentTime) / 60)}` +
":" +
`${Math.floor((duration - currentTime) % 60)
.toString()
.padStart(2, "0")}`
: `${Math.floor(currentTime / 60)}` +
":" +
`${Math.floor(currentTime % 60)
.toString()
.padStart(2, "0")}`;
const handleToggle = () => {
setReverse(!reverse);
};
return (
<div
className={cn(
"text-card-foreground text-sm cursor-pointer mx-1.5",
className,
)}
onClick={handleToggle}
>
{displayTime}
<span className="text-card-foreground/50 mx-1">/</span>
{Math.floor(duration / 60)}:
{Math.floor(duration % 60)
.toString()
.padStart(2, "0")}
</div>
);
}
function AudioControlPlaybackRate({
rates,
className,
...props
}: MenuTrigger.Props & { rates: number[] }) {
const { playbackRate, setPlaybackRate } = React.useContext(Context);
const handlePlaybackRate = (rate: number) => {
setPlaybackRate(rate);
};
return (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"inline-flex items-center justify-center text-card-foreground rounded-full aspect-square h-8 hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground cursor-pointer",
className,
)}
{...props}
>
<Gauge className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-card text-card-foreground border-border"
>
{rates.map((rate) => (
<DropdownMenuItem
key={rate}
onClick={() => handlePlaybackRate(rate)}
className="flex items-center justify-between focus:bg-accent focus:text-card-foreground"
>
{rate}x{playbackRate === rate && <Check className="size-4 ml-2" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function AudioControlLoop({
className,
...props
}: React.ComponentProps<"button">) {
const { audioRef, loop, setLoop } = React.useContext(Context);
const handleLoop = () => {
if (!audioRef.current) return;
setLoop(!loop);
};
return (
<Button
variant="ghost"
size="icon"
onClick={handleLoop}
className={cn(
"text-card-foreground rounded-full aspect-square h-8",
className,
)}
{...props}
>
{loop ? <Repeat1 /> : <Repeat />}
</Button>
);
}
function AudioControlVolume({ className, ...props }: MenuTrigger.Props) {
const { muted, volume, setMuted, setVolume } = React.useContext(Context);
const value = React.useMemo(
() => [muted ? 0 : (Number(volume) || 0) * 100],
[muted, volume],
);
const handleMute = () => {
setMuted(!muted);
};
const handleVolume = (newVolume: number | readonly number[]) => {
const nextValue = Array.isArray(newVolume) ? newVolume[0] : newVolume;
setVolume(nextValue / 100);
if (nextValue > 0) setMuted(false);
};
return (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"inline-flex items-center justify-center text-card-foreground rounded-full aspect-square h-8 hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground cursor-pointer",
className,
)}
{...props}
>
<VolumeIcon className="size-4" muted={muted} volume={volume} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
<div className="flex flex-row items-center gap-1">
<Button
variant="ghost"
size="icon"
className="text-accent-foreground/80 hover:bg-accent/20 rounded-full"
onClick={handleMute}
>
<VolumeIcon muted={muted} volume={volume} />
</Button>
<Slider
value={value}
className="w-20 group-hover/volume:block transition-all mr-1.5"
onValueChange={(v) => handleVolume(v)}
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
function Waveform({
className,
playedClassName = "bg-primary",
unplayedClassName = "bg-primary/20",
}: {
className?: string;
playedClassName?: string;
unplayedClassName?: string;
}) {
const BAR_W = 3;
const BAR_GAP = 2;
const TRANSITION_SPEED = 0.08;
const { analyserRef, paused, progress } = React.useContext(Context);
const wrapperRef = React.useRef<HTMLDivElement>(null);
const barsRef = React.useRef<(HTMLDivElement | null)[]>([]);
const playedStateRef = React.useRef<boolean[]>([]);
const rafRef = React.useRef<number>(0);
const blendRef = React.useRef<number>(paused ? 1 : 0);
const [barCount, setBarCount] = React.useState(0);
React.useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const ro = new ResizeObserver(() => {
setBarCount(Math.floor(wrapper.clientWidth / (BAR_W + BAR_GAP)));
});
ro.observe(wrapper);
return () => ro.disconnect();
}, []);
const staticHeights = React.useMemo(
() =>
Array.from({ length: barCount }, (_, i) => {
const t = i / barCount;
return (
0.15 +
0.35 * Math.abs(Math.sin(t * Math.PI * 6)) +
0.2 * Math.abs(Math.sin(t * Math.PI * 13.7 + 1))
);
}),
[barCount],
);
React.useEffect(() => {
if (barCount === 0) return;
const target = paused ? 1 : 0;
const animate = () => {
rafRef.current = requestAnimationFrame(animate);
blendRef.current += (target - blendRef.current) * TRANSITION_SPEED;
if (Math.abs(blendRef.current - target) < 0.001)
blendRef.current = target;
const blend = blendRef.current;
const analyser = analyserRef.current;
let freqData: Uint8Array<ArrayBuffer> | null = null;
if (analyser) {
freqData = new Uint8Array(
analyser.frequencyBinCount,
) as Uint8Array<ArrayBuffer>;
analyser.getByteFrequencyData(freqData);
}
const step = freqData ? Math.floor(freqData.length / barCount) : 1;
for (let i = 0; i < barCount; i++) {
const bar = barsRef.current[i];
if (!bar) continue;
const t = i / barCount;
const liveValue = freqData ? freqData[i * step] / 255 : 0;
const value = liveValue * (1 - blend) + staticHeights[i] * blend;
bar.style.height = `${Math.max(10, value * 100)}%`;
const played = t * 100 < progress;
if (played !== playedStateRef.current[i]) {
playedStateRef.current[i] = played;
bar.className = cn(
"shrink-0 rounded-full",
played ? playedClassName : unplayedClassName,
);
bar.style.width = `${BAR_W}px`;
}
}
};
animate();
return () => cancelAnimationFrame(rafRef.current);
}, [
analyserRef,
paused,
progress,
barCount,
staticHeights,
playedClassName,
unplayedClassName,
]);
return (
<div
ref={wrapperRef}
className={cn(
"flex-1 h-8 flex items-center overflow-hidden cursor-pointer",
className,
)}
style={{ gap: `${BAR_GAP}px` }}
>
{Array.from({ length: barCount }, (_, index) => (
<div
key={index}
ref={(element) => {
barsRef.current[index] = element;
}}
className={cn("shrink-0 rounded-full", unplayedClassName)}
style={{ width: BAR_W, height: "20%px" }}
/>
))}
</div>
);
}
function VolumeIcon({
className,
muted,
volume,
}: {
className?: string;
muted: boolean;
volume: number;
}) {
if (muted || volume === 0) return <VolumeX className={className} />;
if (volume > 0.66) return <Volume2 className={className} />;
if (volume > 0.33) return <Volume1 className={className} />;
return <Volume className={className} />;
}
export {
AudioControlGroup,
AudioControlLoop,
AudioControlPlay,
AudioControlPlaybackRate,
AudioControlSeek,
AudioControlTimer,
AudioControlVolume,
AudioPlayer,
AudioProvider,
};