Soundwave
What a voice looks like while an app listens.
Draws the level of a voice, in four looks: capsules over a microphone button, a metering strip for a transcript, a travelling wave for a reply being spoken, and an ambient glow that fills a screen.
It draws a level; it does not record one. Your app owns the recorder and hands over a number between 0 and 1 — so there is no audio dependency here, and the same component works against a real meter, a synthesised one, or a remote peer's. With no level at all it animates plausible motion for the current state, which is enough to build and review a voice screen before any audio exists.
Installation
Soundwave ships with the library — no separate install.
import { Soundwave, Card, Message, MicIcon, Text } from 'panelui-native';Or copy the source into your project, to own and edit it:
npx panelui-cli@latest add soundwaveUsage
<Soundwave variant="pills" state="listening" level={level} />Examples
Driving it from a recorder
A recorder reports every 30–60ms. Putting that in React state is dozens of renders a second for a number that only moves pixels, so level also takes a SharedValue — write metering straight into it and nothing above the wave re-renders. A plain number is accepted too, and smoothed identically; it is the right choice for something slow, like a server-side speaking flag.
const level = useSharedValue(0);
useEffect(() => {
recorder.onMetering(({ db }) => {
// dBFS is negative and logarithmic; the wave wants 0–1.
level.value = Math.max(0, (db + 60) / 60);
});
}, [level]);
<Soundwave variant="pills" level={level} />A metering strip in a transcript
static gives every bar a band of the current level, for a live meter. scrolling keeps a history that slides across with the newest sample at the trailing edge, for a recording in progress. centered decides whether bars grow from the middle out or up from a baseline.
<Card>
<Card.Content className="flex-row items-center gap-3 p-4">
<MicIcon size={18} />
<View className="flex-1">
<Soundwave
variant="bars"
mode="scrolling"
centered={false}
bars={28}
barWidth={5}
height={40}
level={level}
/>
</View>
<Text size="sm" muted>0:12</Text>
</Card.Content>
</Card>A voice note
levels is the shape of a finished recording — collect the metering while recording and store the numbers beside the file. Supplying it draws the wave still rather than animated, and stops the engine entirely, so a transcript of twenty notes runs no animation at all until one is played. progress is the playhead: bars behind it keep full ink and the rest are dimmed.
const player = useAudioPlayer(note.uri);
const status = useAudioPlayerStatus(player);
const progress = status.duration ? status.currentTime / status.duration : 0;
<Message align="end">
<Message.Content>
<Message.Bubble>
<View className="w-64 flex-row items-center gap-3">
<Pressable onPress={() => player.play()}>
<PlayIcon size={16} />
</Pressable>
<View className="flex-1">
<Soundwave
variant="bars"
levels={note.levels}
progress={progress}
bars={40}
barWidth={2}
height={28}
/>
</View>
<Text size="xs" muted>0:12</Text>
</View>
</Message.Bubble>
</Message.Content>
</Message>Real frequency bands
If your app already runs an analysis, hand the bands over directly with levels and bars in static mode draws them instead of synthesising a shape. They are resampled to the bar count, so the two numbers do not have to match.
<Soundwave variant="bars" levels={bands} bars={24} />The state, when there is no level yet
state always sets what a screen reader announces. With no level supplied it also picks the motion: idle barely breathes, listening and speaking run layered waves at unrelated tempi so neither repeats on a beat, and thinking is slower and shallower than both.
<Soundwave variant="line" state="speaking" />
<Soundwave variant="line" state="thinking" />
<Soundwave variant="line" state="idle" />Ambient, over a whole screen
ambient positions itself absolutely and takes no touches, so it goes behind a screen's content rather than wrapping it. radius traces the screen's own corners. It takes its colour from --color-info rather than the foreground, since a bloom in the text colour reads as a shadow.
<View className="flex-1">
<Soundwave variant="ambient" state="listening" level={level} radius={40} />
<View className="flex-1 items-center justify-center">
<Text size="xl">Start chatting anytime</Text>
</View>
</View>Colour
color takes a literal colour or a theme token name, which resolves against the active theme and follows it into dark mode. gradient paints across the wave instead of one flat ink — left to right for bars and line, up from the bottom edge for ambient. trackColor sets the part of a recording that has not played yet, which otherwise draws as the ink turned down.
{/* Follows the theme. */}
<Soundwave variant="bars" color="--color-info" />
{/* Or a colour of your own. */}
<Soundwave variant="bars" color="#f97316" />
{/* Across the wave. */}
<Soundwave variant="line" gradient={['#6366f1', '#ec4899', '#f59e0b']} />
{/* Played and unplayed, separately. */}
<Soundwave
variant="bars"
levels={note.levels}
progress={progress}
color="--color-success"
trackColor="--color-muted"
/>Versions
Capsules
The voice-mode screen: a few big capsules over a microphone button. Each capsule runs at its own hashed tempo and phase, so the group responds to one level without moving as a block, and the middle ones lead — a flat profile reads as a progress bar.
<Soundwave
variant="pills"
state="listening"
level={level}
height={120}
barWidth={34}
barGap={12}
/>Metering bars
The same strip in both modes, at the size it is actually used: static bands, a scrolling history, and a compact recording row with a duration beside it.
<Soundwave variant="bars" mode="static" level={level} height={64} />
<Soundwave variant="bars" mode="scrolling" level={level} height={64} />Travelling wave
One ribbon, tapered to nothing at both ends so it leaves and meets the edges flat instead of stopping at a hard cut. Shown at full size with a state switcher, and again inline under a reply.
<Soundwave variant="line" state="speaking" height={96} />
// Inline in a bubble, where the stroke has to be finer.
<Soundwave variant="line" state="speaking" height={36} barWidth={2} />Ambient glow
A bloom rising off the bottom edge and a rim of light around the rest, both breathing on the level — a glow that only changes opacity reads as a screen dimming, one that also grows reads as something in the room getting louder.
<Soundwave variant="ambient" state="listening" level={level} radius={40} />Voice notes
Recorded waveforms in bubbles, filling as they play. Each note stores forty numbers rather than the audio, so the wave draws instantly on scroll — decoding a file to work out a bar chart is work no phone should repeat.
<Soundwave
variant="bars"
levels={note.levels}
progress={progress}
bars={40}
barWidth={2}
height={28}
/>Recording composer
A composer that turns into a recorder over a live transcript: the last few seconds slide past in scrolling mode while a timer runs, cancel throws the recording away, and send appends it to the thread as a note.
<Soundwave
variant="bars"
mode="scrolling"
level={level}
bars={32}
barWidth={3}
height={36}
/>API Reference
Soundwave
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
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. |
state | SoundwaveState | 'listening' | What the app is doing. With no level supplied this picks the motion the wave runs on its own; it always sets what a screen reader announces. |
level | number | SharedValue<number> | — | Input level, 0–1, from your own recorder's metering. Pass a SharedValue to keep updates off the JS thread entirely. Omit it and the wave animates plausible motion for the current state. |
levels | number[] | — | Per-band levels, 0–1 each, for bars in static mode when the app has a real frequency analysis — or the stored shape of a finished recording. Resampled to the bar count, and drawn still rather than animated. |
progress | number | SharedValue<number> | — | Playback position through a recording, 0–1. Bars behind it keep full ink and the rest are dimmed, which is the voice-note progress bar. bars only, and it goes with levels — a recording has a fixed shape to play through. |
bars | number | — | How many capsules (pills) or bars (bars) to draw. |
barWidth | number | — | Capsule width, or bar stroke width. Also the stroke width of line. |
barGap | number | — | Gap between capsules. bars spaces itself evenly across the width. |
height | number | — | Drawing height. ambient ignores it and fills its parent. |
mode | 'static' | 'scrolling' | 'static' | static gives every bar a band of the current level; scrolling keeps a history that slides across, newest at the trailing edge. bars only. |
centered | boolean | true | Grow bars from the middle out rather than up from the baseline. |
fadeEdges | boolean | — | Fade the wave 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 clamped to 1. |
speed | number | 1 | Multiplier on the wave's own tempo, including how fast history scrolls. |
color | string | — | Ink. Takes a colour — #f97316, rgba(…) — or a theme token name, color="--color-info", which resolves against the active theme and follows it into dark mode. Left unset, a wave inside a surface that publishes a foreground (a chat bubble, a button) is drawn in that foreground, and anywhere else in --color-foreground — or --color-info for ambient. |
gradient | readonly string[] | — | Colour across the wave instead of one flat ink: two or more colours spread left to right, or ramped up from the bottom edge for ambient. Literal colours only — for a themed one, resolve the tokens with useCSSVariable and pass the result. |
trackColor | string | — | Colour of the part of a recording that has not played yet. Defaults to the ink at low opacity, which is right for most surfaces; set it when you want the track to read as its own thing. bars with progress. |
paused | boolean | false | Freeze on the current frame. |
radius | number | 44 | Corner radius ambient traces. Match it to the screen it sits on. |
accessibilityLabel | string | — | Overrides the per-state default announced to screen readers. |
Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.
Notes
Nothing here touches the microphone. Recording, permissions and the audio session stay in your app; this takes the number that falls out of them. There is a full walkthrough in Voice — metering, an assistant loop, and voice notes.
Levels are smoothed with a fast attack and a slow release, which is what makes a meter read as a meter — it snaps up on a syllable and falls back gently instead of chattering around every sample. sensitivity multiplies the incoming value before it is clamped, for a quiet source.
Ink follows the surface. A wave inside something that publishes a foreground — a chat bubble, a button — is drawn in that foreground rather than the page's, because a sent bubble is painted in the primary colour and in most themes the primary colour is the text colour. ambient opts out: it is a glow behind a screen, not ink on a surface.
Anything above that is a prop: color (a colour or a theme token name), gradient across the wave, and trackColor for the unplayed part of a recording. Only gradient needs literal colours — resolve tokens with useCSSVariable if you want a themed one.
bars and line are drawn as a single animated SVG path — one segment per bar with a round cap — so forty bars cost one animated prop a frame rather than forty animated views. With progress it is two: one for the played part, one for the rest. Both measure their own width, so they need a parent with one.
paused and the system's reduced-motion setting both hold a representative frame rather than clearing to a flat line: a stopped wave is not an empty one.