Soundwave
A voice drawn while an app listens: capsules, bars, a line, a field of dots, spokes round a person, or rings of dots.
'use client';
import { Button } from '@/components/panelui/button';
import { Soundwave, useMicrophone, type MicrophoneControls } from '@/components/panelui/soundwave';
function MicIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5 11a7 7 0 0 0 14 0M12 18v3" />
</svg>
);
}
function MicButton({ mic }: { mic: MicrophoneControls }) {
return (
<div className="flex flex-col items-center gap-2">
<Button
variant={mic.active ? 'default' : 'outline'}
size="sm"
shape="pill"
aria-pressed={mic.active}
disabled={mic.status === 'requesting'}
onClick={mic.toggle}
>
<MicIcon />
{mic.active ? 'Stop listening' : 'Try it with your voice'}
</Button>
{mic.status === 'denied' || mic.status === 'unsupported' ? (
<p className="text-xs text-muted-foreground">No microphone, or permission was refused.</p>
) : null}
</div>
);
}
export default function SoundwaveOverview() {
const mic = useMicrophone();
return (
<div className="flex w-full max-w-md flex-col items-center gap-6">
{/* With no stream yet, the wave runs its own motion. */}
<Soundwave variant="dots" color="info" height={72} source={mic.stream} />
<MicButton mic={mic} />
</div>
);
}Use it to show that an app is hearing someone, or that someone is speaking: over a microphone button, in a recording row, beside each person on a call, or behind a whole voice screen.
It draws a level and does not record one. Pass it a microphone, a call or an audio element as source and it measures the sound itself, or pass a level from 0 to 1 that you measure. useMicrophone asks for the microphone and gives you the stream.
With no level it runs its own motion for the current state, which is enough to build and review a voice screen before any audio exists.
For a spinner while an answer is being written, use a loader. For a recording's length and position alone, a progress bar is clearer.
Installation
npx shadcn@latest add https://panelui.dev/r/web/soundwave.jsonpnpm dlx shadcn@latest add https://panelui.dev/r/web/soundwave.jsonyarn dlx shadcn@latest add https://panelui.dev/r/web/soundwave.jsonbunx shadcn@latest add https://panelui.dev/r/web/soundwave.jsonThe source is copied into components/panelui/, where you can edit it.
npm install panelui-webpnpm add panelui-webyarn add panelui-webbun add panelui-webImport from panelui-web/soundwave instead of @/components/panelui/soundwave. Tailwind needs one line to read the package's classes, which is on the installation page.
Install the packages it uses:
npm install tailwind-variantspnpm add tailwind-variantsyarn add tailwind-variantsbun add tailwind-variantsCopy the source into components/panelui/soundwave.tsx:
Usage
import { Soundwave, useMicrophone } from "@/components/panelui/soundwave"const mic = useMicrophone()
<Soundwave variant="dots" source={mic.stream} />
<Button onClick={mic.toggle}>{mic.active ? 'Stop' : 'Speak'}</Button>
<Soundwave variant="radial" state="speaking">
<Soundwave.Center>
<Avatar name="Khalid Abdi" size="xl" />
</Soundwave.Center>
</Soundwave>Composition
<Soundwave>
<Soundwave.Center />
</Soundwave>Soundwave.Center— What sits in the middle of aradialorringwave, such as an avatar or a figure. It is sized to the hole and centred in it. The other looks have no middle and do not use it.
Examples
Capsules
A few large capsules, for the space above a microphone button. Each capsule moves at its own pace and the middle ones lead, so the group follows one level without moving as a block.
'use client';
import { Button } from '@/components/panelui/button';
import { Soundwave, useMicrophone, type MicrophoneControls } from '@/components/panelui/soundwave';
function MicIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5 11a7 7 0 0 0 14 0M12 18v3" />
</svg>
);
}
function MicButton({ mic }: { mic: MicrophoneControls }) {
return (
<div className="flex flex-col items-center gap-2">
<Button
variant={mic.active ? 'default' : 'outline'}
size="sm"
shape="pill"
aria-pressed={mic.active}
disabled={mic.status === 'requesting'}
onClick={mic.toggle}
>
<MicIcon />
{mic.active ? 'Stop listening' : 'Try it with your voice'}
</Button>
{mic.status === 'denied' || mic.status === 'unsupported' ? (
<p className="text-xs text-muted-foreground">No microphone, or permission was refused.</p>
) : null}
</div>
);
}
export default function SoundwavePills() {
const mic = useMicrophone();
return (
<div className="flex flex-col items-center gap-6">
<Soundwave variant="pills" state="listening" height={112} barWidth={30} barGap={12} source={mic.stream} />
<MicButton mic={mic} />
</div>
);
}Metering bars
static gives every bar a share of the current level, for a live meter. scrolling keeps a history that slides left with the newest sample at the right edge, for a recording in progress. centered={false} grows the bars up from the bottom.
'use client';
import { Button } from '@/components/panelui/button';
import { Soundwave, useMicrophone, type MicrophoneControls } from '@/components/panelui/soundwave';
function MicIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5 11a7 7 0 0 0 14 0M12 18v3" />
</svg>
);
}
function MicButton({ mic }: { mic: MicrophoneControls }) {
return (
<div className="flex flex-col items-center gap-2">
<Button
variant={mic.active ? 'default' : 'outline'}
size="sm"
shape="pill"
aria-pressed={mic.active}
disabled={mic.status === 'requesting'}
onClick={mic.toggle}
>
<MicIcon />
{mic.active ? 'Stop listening' : 'Try it with your voice'}
</Button>
{mic.status === 'denied' || mic.status === 'unsupported' ? (
<p className="text-xs text-muted-foreground">No microphone, or permission was refused.</p>
) : null}
</div>
);
}
export default function SoundwaveBars() {
const mic = useMicrophone();
return (
<div className="flex w-full max-w-md flex-col items-center gap-8">
{/* Every bar takes a share of the level now. */}
<Soundwave variant="bars" mode="static" height={56} source={mic.stream} />
{/* A history that slides left, newest at the right edge. */}
<Soundwave
variant="bars"
mode="scrolling"
centered={false}
bars={48}
height={40}
fadeEdges
source={mic.stream}
/>
<MicButton mic={mic} />
</div>
);
}Travelling wave
One line, tapered to nothing at both ends so it meets the edges flat. Use it while an assistant is speaking.
'use client';
import { Button } from '@/components/panelui/button';
import { Soundwave, useMicrophone, type MicrophoneControls } from '@/components/panelui/soundwave';
function MicIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5 11a7 7 0 0 0 14 0M12 18v3" />
</svg>
);
}
function MicButton({ mic }: { mic: MicrophoneControls }) {
return (
<div className="flex flex-col items-center gap-2">
<Button
variant={mic.active ? 'default' : 'outline'}
size="sm"
shape="pill"
aria-pressed={mic.active}
disabled={mic.status === 'requesting'}
onClick={mic.toggle}
>
<MicIcon />
{mic.active ? 'Stop listening' : 'Try it with your voice'}
</Button>
{mic.status === 'denied' || mic.status === 'unsupported' ? (
<p className="text-xs text-muted-foreground">No microphone, or permission was refused.</p>
) : null}
</div>
);
}
export default function SoundwaveLine() {
const mic = useMicrophone();
return (
<div className="flex w-full max-w-md flex-col items-center gap-6">
<Soundwave
variant="line"
state={mic.active ? 'listening' : 'speaking'}
height={96}
color="primary"
source={mic.stream}
/>
<MicButton mic={mic} />
</div>
);
}A field of dots
dots lights a grid of dots into a wave, column by column. Unlit dots stay faintly visible, so the field keeps its shape when the voice is quiet. It takes static, scrolling and levels, as bars does.
'use client';
import { Button } from '@/components/panelui/button';
import { Soundwave, useMicrophone, type MicrophoneControls } from '@/components/panelui/soundwave';
function MicIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5 11a7 7 0 0 0 14 0M12 18v3" />
</svg>
);
}
function MicButton({ mic }: { mic: MicrophoneControls }) {
return (
<div className="flex flex-col items-center gap-2">
<Button
variant={mic.active ? 'default' : 'outline'}
size="sm"
shape="pill"
aria-pressed={mic.active}
disabled={mic.status === 'requesting'}
onClick={mic.toggle}
>
<MicIcon />
{mic.active ? 'Stop listening' : 'Try it with your voice'}
</Button>
{mic.status === 'denied' || mic.status === 'unsupported' ? (
<p className="text-xs text-muted-foreground">No microphone, or permission was refused.</p>
) : null}
</div>
);
}
export default function SoundwaveDots() {
const mic = useMicrophone();
return (
<div className="flex w-full max-w-md flex-col items-center gap-8">
{/* Lit out from the middle, each column its own share of the level. */}
<Soundwave variant="dots" color="success" height={64} source={mic.stream} />
{/* A history, newest on the right, growing up from the bottom row. */}
<Soundwave
variant="dots"
mode="scrolling"
centered={false}
color="chart-4"
barWidth={3}
barGap={2}
height={40}
source={mic.stream}
/>
<MicButton mic={mic} />
</div>
);
}Spokes round a person
radial draws spokes round a ring, with Soundwave.Center in the middle for an avatar. height is the diameter. Each spoke is solid near the ring and fainter at its tip, and a glow behind it grows with the level.
'use client';
import { Avatar } from '@/components/panelui/avatar';
import { Button } from '@/components/panelui/button';
import { Soundwave, useMicrophone, type MicrophoneControls } from '@/components/panelui/soundwave';
function MicIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5 11a7 7 0 0 0 14 0M12 18v3" />
</svg>
);
}
function MicButton({ mic }: { mic: MicrophoneControls }) {
return (
<div className="flex flex-col items-center gap-2">
<Button
variant={mic.active ? 'default' : 'outline'}
size="sm"
shape="pill"
aria-pressed={mic.active}
disabled={mic.status === 'requesting'}
onClick={mic.toggle}
>
<MicIcon />
{mic.active ? 'Stop listening' : 'Try it with your voice'}
</Button>
{mic.status === 'denied' || mic.status === 'unsupported' ? (
<p className="text-xs text-muted-foreground">No microphone, or permission was refused.</p>
) : null}
</div>
);
}
export default function SoundwaveRadial() {
const mic = useMicrophone();
return (
<div className="flex flex-col items-center gap-5">
<Soundwave
variant="radial"
state="speaking"
color="chart-2"
source={mic.stream}
label="Khalid Abdi, speaking"
>
<Soundwave.Center>
<Avatar name="Khalid Abdi" src="https://avatars.githubusercontent.com/u/127331761?v=4" size="xl" />
</Soundwave.Center>
</Soundwave>
<MicButton mic={mic} />
</div>
);
}A conversation
One radial wave per person, in the same colour as their avatar. The person speaking gets state="speaking" and the others idle, which leaves them short spokes rather than nothing, so everyone on the call stays visible. In an app, pass each person's own stream as source; here the microphone speaks for Khalid.
'use client';
import { useEffect, useState, useSyncExternalStore } from 'react';
import { Avatar } from '@/components/panelui/avatar';
import { Button } from '@/components/panelui/button';
import { Soundwave, useMicrophone, type MicrophoneControls } from '@/components/panelui/soundwave';
function MicIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5 11a7 7 0 0 0 14 0M12 18v3" />
</svg>
);
}
function MicButton({ mic }: { mic: MicrophoneControls }) {
return (
<div className="flex flex-col items-center gap-2">
<Button
variant={mic.active ? 'default' : 'outline'}
size="sm"
shape="pill"
aria-pressed={mic.active}
disabled={mic.status === 'requesting'}
onClick={mic.toggle}
>
<MicIcon />
{mic.active ? 'Leave the call' : 'Speak as Khalid'}
</Button>
{mic.status === 'denied' || mic.status === 'unsupported' ? (
<p className="text-xs text-muted-foreground">No microphone, or permission was refused.</p>
) : null}
</div>
);
}
const PEOPLE = [
{ name: 'Khalid Abdi', src: 'https://avatars.githubusercontent.com/u/127331761?v=4', color: 'chart-2' },
{ name: 'Rhea Lindqvist', color: 'chart-4' },
] as const;
/** How long each turn lasts, in seconds: people do not take turns on a beat. */
const TURNS = [4.2, 3.1, 5.4, 2.6, 3.8, 4.6];
const WIDE = '(min-width: 480px)';
const subscribe = (onChange: () => void) => {
const query = window.matchMedia(WIDE);
query.addEventListener('change', onChange);
return () => query.removeEventListener('change', onChange);
};
export default function SoundwaveConversation() {
const [turn, setTurn] = useState(0);
const mic = useMicrophone();
// Two side by side still fit on a phone, at a smaller diameter.
const wide = useSyncExternalStore(subscribe, () => window.matchMedia(WIDE).matches, () => true);
// Turns pass on their own until you join; then your own voice drives Khalid's wave.
useEffect(() => {
if (mic.active) return;
const timer = window.setTimeout(() => setTurn((value) => value + 1), TURNS[turn % TURNS.length]! * 1000);
return () => window.clearTimeout(timer);
}, [turn, mic.active]);
return (
<div className="flex flex-col items-center gap-6">
<div className="flex items-start justify-center gap-4 sm:gap-10">
{PEOPLE.map((person, index) => {
const live = mic.active && index === 0;
const speaking = mic.active ? live : turn % 2 === index;
return (
<div key={person.name} className="flex flex-col items-center gap-3">
<Soundwave
variant="radial"
state={speaking ? 'speaking' : 'idle'}
source={live ? mic.stream : null}
color={person.color}
height={wide ? 168 : 128}
label={`${person.name}, ${speaking ? 'speaking' : 'listening'}`}
>
<Soundwave.Center>
<Avatar
name={person.name}
src={'src' in person ? person.src : undefined}
color={person.color}
size={wide ? 'xl' : 'lg'}
/>
</Soundwave.Center>
</Soundwave>
<p className="flex flex-col items-center text-sm font-medium">
{person.name}
<span className="font-normal text-muted-foreground">
{live ? 'You' : speaking ? 'Speaking' : 'Listening'}
</span>
</p>
</div>
);
})}
</div>
<MicButton mic={mic} />
</div>
);
}Rings of dots
ring lights rings of dots outward from the middle, one reach for each angle. Given levels it draws them still: here one value for each hour of a day, read clockwise from midnight at the top. With a source or a level it moves like the other looks, which the microphone button shows.
'use client';
import { Button } from '@/components/panelui/button';
import { Soundwave, useMicrophone, type MicrophoneControls } from '@/components/panelui/soundwave';
import { Stat } from '@/components/panelui/stat';
function MicIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5 11a7 7 0 0 0 14 0M12 18v3" />
</svg>
);
}
function MicButton({ mic }: { mic: MicrophoneControls }) {
return (
<div className="flex flex-col items-center gap-2">
<Button
variant={mic.active ? 'default' : 'outline'}
size="sm"
shape="pill"
aria-pressed={mic.active}
disabled={mic.status === 'requesting'}
onClick={mic.toggle}
>
<MicIcon />
{mic.active ? 'Stop listening' : 'Try it with your voice'}
</Button>
{mic.status === 'denied' || mic.status === 'unsupported' ? (
<p className="text-xs text-muted-foreground">No microphone, or permission was refused.</p>
) : null}
</div>
);
}
/** Minutes of voice input in each hour of one day, from midnight, scaled 0–1 against the busiest hour. */
const DAY = [
0.04, 0.02, 0.02, 0.03, 0.05, 0.12, 0.3, 0.55, 0.82, 0.9, 0.74, 0.62, 0.48, 0.58, 0.7, 0.66, 0.6, 1, 0.86,
0.64, 0.42, 0.3, 0.16, 0.08,
];
export default function SoundwaveRing() {
const mic = useMicrophone();
return (
<div className="flex flex-col items-center gap-5">
{/* The day's shape, held still, until the microphone takes over. */}
<Soundwave
variant="ring"
levels={mic.active ? undefined : DAY}
source={mic.stream}
color="chart-1"
label={mic.active ? 'Your voice, live' : 'Voice input by hour, busiest at 5 pm'}
>
<Soundwave.Center>
<Stat>
<Stat.Content className="items-center">
{mic.active ? (
<Stat.Value size="sm">Live</Stat.Value>
) : (
<Stat.Value size="sm">
3<Stat.Unit>h</Stat.Unit> 40<Stat.Unit>m</Stat.Unit>
</Stat.Value>
)}
<Stat.Label>{mic.active ? 'your voice' : 'spoken today'}</Stat.Label>
</Stat.Content>
</Stat>
</Soundwave.Center>
</Soundwave>
<MicButton mic={mic} />
</div>
);
}States
With no level, state picks the motion: idle barely moves, listening and speaking run several waves at unrelated speeds, and thinking is slower and shallower. state also sets what a screen reader announces, whether or not a level is passed.
'use client';
import { useState } from 'react';
import { Button } from '@/components/panelui/button';
import { Soundwave, useMicrophone, type MicrophoneControls, type SoundwaveState } from '@/components/panelui/soundwave';
import { Tabs } from '@/components/panelui/tabs';
function MicIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5 11a7 7 0 0 0 14 0M12 18v3" />
</svg>
);
}
function MicButton({ mic }: { mic: MicrophoneControls }) {
return (
<div className="flex flex-col items-center gap-2">
<Button
variant={mic.active ? 'default' : 'outline'}
size="sm"
shape="pill"
aria-pressed={mic.active}
disabled={mic.status === 'requesting'}
onClick={mic.toggle}
>
<MicIcon />
{mic.active ? 'Stop listening' : 'Try it with your voice'}
</Button>
{mic.status === 'denied' || mic.status === 'unsupported' ? (
<p className="text-xs text-muted-foreground">No microphone, or permission was refused.</p>
) : null}
</div>
);
}
const STATES: SoundwaveState[] = ['idle', 'listening', 'thinking', 'speaking'];
export default function SoundwaveStates() {
const [state, setState] = useState<SoundwaveState>('listening');
const mic = useMicrophone();
return (
<div className="flex w-full max-w-md flex-col items-center gap-6">
{/* A live source replaces the motion; the state still sets what is announced. */}
<Soundwave variant="pills" state={state} height={96} source={mic.stream} />
<Tabs value={state} onValueChange={(value) => setState(value as SoundwaveState)}>
<Tabs.List aria-label="State">
{STATES.map((value) => (
<Tabs.Trigger key={value} value={value} className="capitalize">
{value}
</Tabs.Trigger>
))}
</Tabs.List>
</Tabs>
<MicButton mic={mic} />
</div>
);
}Your own audio
source takes the sound itself: the stream from useMicrophone, a MediaStream from a call, or an <audio> or <video> element. The wave measures it on every frame and nothing re-renders. Keep the element in state rather than a ref, so the wave hears about it once it has mounted.
'use client';
import { useState } from 'react';
import { Button } from '@/components/panelui/button';
import { Soundwave, useMicrophone } from '@/components/panelui/soundwave';
export default function SoundwaveAudio() {
// An element in state rather than a ref, so the wave hears about it once it mounts.
const [player, setPlayer] = useState<HTMLAudioElement | null>(null);
const [playing, setPlaying] = useState(false);
const mic = useMicrophone();
// Whichever is making sound now: the microphone, the file, or neither.
const source = mic.active ? mic.stream : playing ? player : null;
return (
<div className="flex w-full max-w-md flex-col items-center gap-6">
<Soundwave variant="dots" color="chart-2" height={64} source={source} state={source ? 'speaking' : 'idle'} />
<div className="flex flex-wrap justify-center gap-2">
<Button
size="sm"
shape="pill"
variant={playing ? 'default' : 'outline'}
onClick={() => {
mic.stop();
if (playing) player?.pause();
else void player?.play();
}}
>
{playing ? 'Pause the clip' : 'Play a clip'}
</Button>
<Button
size="sm"
shape="pill"
variant={mic.active ? 'default' : 'outline'}
disabled={mic.status === 'requesting'}
onClick={() => {
player?.pause();
mic.toggle();
}}
>
{mic.active ? 'Stop listening' : 'Use the microphone'}
</Button>
</div>
{mic.status === 'denied' || mic.status === 'unsupported' ? (
<p className="text-xs text-muted-foreground">No microphone, or permission was refused.</p>
) : null}
<audio
ref={setPlayer}
src="/audio/soundwave-sample.mp3"
preload="auto"
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => setPlaying(false)}
/>
</div>
);
}A voice note
levels is the shape of a finished recording: sample mic.level.current while recording and keep the numbers beside the file, rather than decoding the audio to draw it. With levels the wave is still, and progress is the playhead: bars behind it keep full ink and the rest are dimmed. Press the microphone to record a note of your own, then play it back.
'use client';
import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/panelui/button';
import { Soundwave, useMicrophone } from '@/components/panelui/soundwave';
/** The stored shape of a 14-second note: forty numbers kept beside the file. */
const SAMPLE = [
0.12, 0.3, 0.52, 0.7, 0.46, 0.28, 0.62, 0.86, 0.94, 0.72, 0.4, 0.22, 0.34, 0.58, 0.8, 0.66, 0.38, 0.18,
0.1, 0.26, 0.48, 0.74, 0.9, 1, 0.82, 0.56, 0.32, 0.44, 0.68, 0.6, 0.36, 0.2, 0.42, 0.64, 0.5, 0.3, 0.16,
0.24, 0.14, 0.08,
];
const BARS = 40;
const LONGEST = 30;
interface Note {
levels: number[];
seconds: number;
/** The recording itself, once there is one. The sample note has no audio. */
url?: string;
}
function Icon({ name }: { name: 'play' | 'pause' | 'mic' | 'stop' }) {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor">
{name === 'play' ? <path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5Z" /> : null}
{name === 'pause' ? (
<>
<rect x="6" y="5" width="4" height="14" rx="1.25" />
<rect x="14" y="5" width="4" height="14" rx="1.25" />
</>
) : null}
{name === 'stop' ? <rect x="6" y="6" width="12" height="12" rx="2.5" /> : null}
{name === 'mic' ? (
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5 11a7 7 0 0 0 14 0M12 18v3" />
</g>
) : null}
</svg>
);
}
/** A recording's loudness, squeezed to `count` numbers: the peak of each slice. */
function shape(samples: number[], count: number) {
if (!samples.length) return SAMPLE;
return Array.from({ length: count }, (_, i) => {
const from = Math.floor((i / count) * samples.length);
const to = Math.max(from + 1, Math.floor(((i + 1) / count) * samples.length));
return Math.max(...samples.slice(from, to));
});
}
const clock = (seconds: number) => `0:${String(Math.floor(seconds)).padStart(2, '0')}`;
export default function SoundwaveVoiceNote() {
const mic = useMicrophone();
const [note, setNote] = useState<Note>({ levels: SAMPLE, seconds: 14 });
const [recording, setRecording] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [playing, setPlaying] = useState(false);
const [position, setPosition] = useState(0);
const audio = useRef<HTMLAudioElement>(null);
const recorder = useRef<MediaRecorder | null>(null);
// While recording: sample the level a few times a second, and keep the audio.
useEffect(() => {
if (!recording || !mic.stream) return;
const samples: number[] = [];
const chunks: Blob[] = [];
const started = performance.now();
const media = typeof MediaRecorder === 'undefined' ? null : new MediaRecorder(mic.stream);
recorder.current = media;
media?.addEventListener('dataavailable', (event) => chunks.push(event.data));
media?.start();
const timer = window.setInterval(() => {
samples.push(mic.level.current);
const seconds = (performance.now() - started) / 1000;
setElapsed(seconds);
if (seconds >= LONGEST) setRecording(false);
}, 100);
return () => {
window.clearInterval(timer);
const seconds = (performance.now() - started) / 1000;
const finish = () => {
const url = chunks.length ? URL.createObjectURL(new Blob(chunks, { type: media?.mimeType })) : undefined;
setNote((previous) => {
if (previous.url) URL.revokeObjectURL(previous.url);
return { levels: shape(samples, BARS), seconds, url };
});
mic.stop();
};
if (media && media.state !== 'inactive') {
media.addEventListener('stop', finish, { once: true });
media.stop();
} else finish();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recording, mic.stream]);
// Stands in for a real file's `currentTime` when the note has no audio.
useEffect(() => {
if (!playing || note.url) return;
const started = performance.now() - position * note.seconds * 1000;
const timer = window.setInterval(() => {
const at = (performance.now() - started) / 1000 / note.seconds;
if (at >= 1) {
setPlaying(false);
setPosition(0);
} else setPosition(at);
}, 50);
return () => window.clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playing, note]);
async function record() {
audio.current?.pause();
setPlaying(false);
setPosition(0);
setElapsed(0);
await mic.start();
setRecording(true);
}
function play() {
const element = audio.current;
if (note.url && element) {
if (playing) element.pause();
else void element.play();
return;
}
setPlaying((value) => !value);
}
const time = recording ? elapsed : playing || position ? position * note.seconds : note.seconds;
return (
<div className="flex w-full max-w-sm items-center gap-3">
{recording ? (
<>
<Button size="icon" shape="pill" variant="destructive" aria-label="Stop recording" onClick={() => setRecording(false)}>
<Icon name="stop" />
</Button>
<Soundwave
variant="bars"
mode="scrolling"
source={mic.stream}
bars={BARS}
barWidth={2.5}
height={32}
label="Recording"
className="flex-1"
/>
</>
) : (
<>
<Button size="icon" shape="pill" aria-label={playing ? 'Pause' : 'Play voice note'} onClick={play}>
<Icon name={playing ? 'pause' : 'play'} />
</Button>
<Soundwave
variant="bars"
levels={note.levels}
progress={position}
bars={BARS}
barWidth={2.5}
height={32}
label={`Voice note, ${Math.round(note.seconds)} seconds`}
className="flex-1"
/>
</>
)}
<span className="w-8 text-xs text-muted-foreground tabular-nums">{clock(time)}</span>
{recording ? null : (
<Button
size="icon"
shape="pill"
variant="outline"
aria-label="Record a new note"
disabled={mic.status === 'requesting'}
onClick={record}
>
<Icon name="mic" />
</Button>
)}
{note.url ? (
<audio
ref={audio}
src={note.url}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => {
setPlaying(false);
setPosition(0);
}}
onTimeUpdate={(event) => {
const element = event.currentTarget;
// A recorded file often reports no duration; the timer knows it.
const length = Number.isFinite(element.duration) ? element.duration : note.seconds;
setPosition(Math.min(1, element.currentTime / length));
}}
/>
) : null}
</div>
);
}Colours
color takes a role from the theme. Left unset, the wave is drawn in the text colour of whatever contains it. trackColor sets the unlit dots of dots and ring, and the unplayed part of a recording. gradient spreads several roles across a strip.
'use client';
import { Button } from '@/components/panelui/button';
import { Soundwave, useMicrophone, type MicrophoneControls } from '@/components/panelui/soundwave';
function MicIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5 11a7 7 0 0 0 14 0M12 18v3" />
</svg>
);
}
function MicButton({ mic }: { mic: MicrophoneControls }) {
return (
<div className="flex flex-col items-center gap-2">
<Button
variant={mic.active ? 'default' : 'outline'}
size="sm"
shape="pill"
aria-pressed={mic.active}
disabled={mic.status === 'requesting'}
onClick={mic.toggle}
>
<MicIcon />
{mic.active ? 'Stop listening' : 'Try it with your voice'}
</Button>
{mic.status === 'denied' || mic.status === 'unsupported' ? (
<p className="text-xs text-muted-foreground">No microphone, or permission was refused.</p>
) : null}
</div>
);
}
const NOTE = [0.2, 0.45, 0.7, 0.5, 0.3, 0.65, 0.9, 0.75, 0.4, 0.25, 0.55, 0.8, 1, 0.7, 0.45, 0.3, 0.5, 0.35, 0.2, 0.15];
export default function SoundwaveColours() {
const mic = useMicrophone();
return (
<div className="flex w-full max-w-md flex-col items-center gap-6">
{/* Unset: the text colour of whatever it sits in. */}
<Soundwave variant="bars" height={40} source={mic.stream} />
<Soundwave variant="bars" height={40} color="info" source={mic.stream} />
<Soundwave variant="dots" height={40} color="warning" source={mic.stream} />
{/* A recording two-thirds played, with its own colour for the rest. */}
<Soundwave variant="bars" height={32} bars={48} levels={NOTE} progress={0.66} color="success" trackColor="chart-3" />
{/* Across the wave, left to right. */}
<Soundwave variant="line" height={56} gradient={['chart-2', 'info', 'chart-4']} source={mic.stream} />
<MicButton mic={mic} />
</div>
);
}Ambient glow
ambient fills its nearest positioned parent with a glow rising from the bottom edge and a rim of light round the rest, both growing with the level. It takes no pointer events, so put it behind the content. It defaults to info, since a glow in the text colour reads as a shadow.
'use client';
import { Card } from '@/components/panelui/card';
import { Button } from '@/components/panelui/button';
import { Soundwave, useMicrophone, type MicrophoneControls } from '@/components/panelui/soundwave';
function MicIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5 11a7 7 0 0 0 14 0M12 18v3" />
</svg>
);
}
function MicButton({ mic }: { mic: MicrophoneControls }) {
return (
<div className="flex flex-col items-center gap-2">
<Button
variant={mic.active ? 'default' : 'outline'}
size="sm"
shape="pill"
aria-pressed={mic.active}
disabled={mic.status === 'requesting'}
onClick={mic.toggle}
>
<MicIcon />
{mic.active ? 'Stop listening' : 'Try it with your voice'}
</Button>
{mic.status === 'denied' || mic.status === 'unsupported' ? (
<p className="text-xs text-muted-foreground">No microphone, or permission was refused.</p>
) : null}
</div>
);
}
export default function SoundwaveAmbient() {
const mic = useMicrophone();
return (
<Card className="relative h-72 w-full max-w-sm justify-center overflow-hidden">
<Soundwave variant="ambient" state="listening" source={mic.stream} />
<Card.Header className="relative text-center">
<Card.Title>Go ahead, I'm listening</Card.Title>
<Card.Description>Ask about your week's schedule</Card.Description>
</Card.Header>
<Card.Content className="relative">
<MicButton mic={mic} />
</Card.Content>
</Card>
);
}API Reference
Soundwave
| Prop | Type | Default | Description |
|---|---|---|---|
variant | SoundwaveVariant | 'pills' | Which look to draw: pills for a few capsules over a microphone button, bars for a metering strip, line for a travelling wave, ambient for a glow that fills its parent, dots for a field of dots lit into a wave, radial for spokes around a centre, and ring for rings of dots lit outward by angle. |
state | SoundwaveState | 'listening' | What the app is doing. With no level it picks the motion the wave runs on its own, and it always sets what a screen reader announces. |
level | number | RefObject<number | null> | — | How loud the input is now, from 0 to 1. Pass a ref and write each reading into ref.current to keep updates out of React entirely. Leave it out and the wave runs its own motion for state. |
source | SoundwaveSource | null | — | Sound to draw, in place of level: a MediaStream from a microphone or a call, or an <audio> or <video> element. A media file has to be on the same site or sent with CORS headers, or the browser hides its sound from the page and the wave stays still. |
levels | readonly number[] | — | Fixed values, 0 to 1 each: the stored shape of a recording, a frequency analysis, or one value per angle for ring (24 for the hours of a day). Resampled to the bar count, and drawn still rather than animated. |
progress | number | — | How far a recording has played, from 0 to 1. Bars and dots behind it keep full ink and the rest are dimmed. bars and dots, with levels. |
bars | number | — | How many capsules, bars or spokes, or how many angles for ring. dots fits as many columns as the width allows. |
barWidth | number | — | Capsule or bar width, the stroke of line and radial, or the size of a dot. |
barGap | number | — | Space between capsules, or between dots. bars spaces itself across the width. |
height | number | — | Drawing height. For radial and ring it is the diameter. ambient fills its parent instead. |
mode | 'static' | 'scrolling' | 'static' | static gives every bar a share of the current level; scrolling keeps a history that slides across, newest at the trailing edge. bars and dots. |
centered | boolean | true | Grow bars and dots from the middle out rather than up from the bottom. |
fadeEdges | boolean | false | Fade a strip out at both ends, so it does not stop at a hard edge. |
sensitivity | number | 1 | Multiplier on the incoming level, applied before it is capped at 1. |
speed | number | 1 | Multiplier on the wave's own tempo, including how fast a history scrolls. |
color | SoundwaveColor | — | The ink, as a role from the theme. Left unset the wave is drawn in the text colour of whatever it sits in, which is right on most surfaces; ambient defaults to info, since a glow in the text colour reads as a shadow. |
gradient | readonly SoundwaveColor[] | — | Colours across the wave instead of one ink, as theme roles: left to right for the strips, up from the bottom for ambient. |
trackColor | SoundwaveColor | — | The colour of the unlit dots in dots and ring, and of the part of a recording not yet played in bars. Defaults to the ink, faint. |
paused | boolean | false | Hold the current frame. |
label | string | — | What a screen reader calls it. Defaults to the state: "Listening". |
useAudioLevel(source, options)
| Prop | Type | Default | Description |
|---|---|---|---|
floor | number | — | The quietest sound that still moves the wave, in decibels below full scale. Default -60. Raise it towards -45 in a noisy room. |
ceiling | number | — | The loudness that fills the wave, in decibels below full scale. Default -12. |
useMicrophone(options)
| Prop | Type | Default | Description |
|---|---|---|---|
constraints | MediaTrackConstraints | — | Passed to getUserMedia as audio, to pick a device or change its processing. |
useMicrophone() returns
| Prop | Type | Default | Description |
|---|---|---|---|
level | RefObject<number> | — | How loud the microphone is, from 0 to 1. Pass it to Soundwave as level. |
stream | MediaStream | null | — | The live stream while listening, for a recorder or a call. null otherwise. |
status | MicrophoneStatus | — | requesting while the browser asks for permission, denied if it was refused or there is no microphone. |
active | boolean | — | Whether it is listening now. |
start | () => Promise<void> | — | Ask for the microphone and start listening. Call it from a click: browsers only allow sound to start from one. |
stop | () => void | — | Stop listening and release the microphone, which turns the browser's recording light off. |
toggle | () => void | — | start when stopped, stop when listening. |
Every part also takes the props of the element it renders, and a className.
Notes
Sound in
There are three ways to give the wave something to draw, from least work to most control.
source: aMediaStreamor an<audio>or<video>element. The wave measures it with the browser's audio analyser on every frame. A media file has to be on the same site or sent with CORS headers (crossOrigin="anonymous"on the element); otherwise the browser hides its sound from the page and the wave stays still.useMicrophone(): asks for the microphone onstart, which has to be called from a click, and gives backstreamforsource, alevelref, astatusandstop. The microphone is released onstopand when the component unmounts. A refused permission or a missing microphone setsstatustodenied.level: a number from 0 to 1, or a ref holding one.useAudioLevel(source)returns such a ref, for when the reading is needed elsewhere too, such as to keep a recording's shape. Use a plain number for something slow, such as a speaking flag from a server.
With none of them the wave runs its own motion for state, so a screen can be built before any audio exists.
The level is smoothed with a fast rise and a slow fall, so the wave jumps on a syllable and settles gently instead of flickering on every sample. sensitivity multiplies it before it is capped at 1, for a quiet source; useMicrophone({ floor: -45 }) ignores more of a noisy room.
Drawing
Each look is one or two SVG paths, and every dot is a round end on a line of no length, so a field of several hundred dots is a single path. Each frame rewrites those paths and changes no React state.
The strips (bars, line and dots) take the width of their container, so give the container a width. pills, radial and ring size themselves from their props.
When it stops
The wave stops drawing when it is scrolled out of view or its tab is hidden, and starts again when it comes back. paused holds the current frame. With reduced motion turned on, the wave holds a still frame instead of moving.
A wave with levels and no scrolling never animates, so a list of twenty voice notes costs nothing until one plays.
Screen readers
The wave is one image, announced by its state ("Listening"). Pass label to say more, such as who is speaking. What is inside Soundwave.Center is part of that image and is not read separately.