Waveform
Animated audio waveform bars for voice-recording and playback states.
levels — bars follow a (simulated) mic signal, newest on the right.
import { Waveform } from "@/components/ui/waveform";
export function VoiceRecorder() {
const [recording, setRecording] = useState(false);
return (
<View className="items-center gap-4 p-4">
{/* active animates the bars — the recording indicator */}
<Waveform active={recording} />
</View>
);
}Installation#
npx @aniui/cli add waveformUsage#
Each bar pulses on its own tempo while active (the default) — drop it in wherever the app is listening. This ambient animation is the fallback when no real audio levels are wired up; bar heights are deterministic, so the wave looks organic but renders identically every time.
import { Waveform } from "@/components/ui/waveform";
export function VoiceRecorder() {
const [recording, setRecording] = useState(false);
return (
<View className="items-center gap-4 p-4">
{/* active animates the bars — the recording indicator */}
<Waveform active={recording} />
</View>
);
}Driven by real audio#
Pass levels — an array of 0–1 amplitudes — and the bars follow the audio instead of the ambient animation. Push mic metering from expo-av into a rolling state array: metering arrives in dBFS, so convert with 10 ** (metering / 20). The newest bars values are shown (newest on the right, left-padded with silence), and each bar eases to its level over ~100ms.
Simulated metering — on device this is real expo-av mic data.
// npx expo install expo-av
import { Audio } from "expo-av";
import { Waveform } from "@/components/ui/waveform";
const BARS = 28;
const [levels, setLevels] = useState<number[]>([]);
async function startRecording() {
await Audio.requestPermissionsAsync();
await Audio.setAudioModeAsync({ allowsRecordingIOS: true });
await Audio.Recording.createAsync(
{ ...Audio.RecordingOptionsPresets.HIGH_QUALITY, isMeteringEnabled: true },
(status) => {
if (!status.isRecording || status.metering === undefined) return;
// metering is dBFS (-160…0) — convert to a 0-1 linear amplitude
const level = Math.min(1, 10 ** (status.metering / 20));
setLevels((prev) => [...prev.slice(-(BARS - 1)), level]);
},
100 // progressUpdateIntervalMillis — matches the bars' ~100ms follow timing
);
}
// Newest values render on the right; the wave scrolls left as you speak.
<Waveform levels={levels} bars={BARS} />Playback waveform#
levels works for playback too: pass decoded peaks from the audio file with active={false} to render a static wave of the actual recording — the classic voice-message bubble in chat UIs.
// levels draws real playback data: decodedPeaks is a number[] of 0-1
// amplitudes extracted from the audio file (precomputed server-side or
// decoded on load). active={false} keeps it still, and progress fades
// the bars past the playhead — the voice-message scrubber look.
const [status, setStatus] = useState({ position: 0, duration: 1 });
// e.g. expo-av: sound.setOnPlaybackStatusUpdate((s) =>
// s.isLoaded && setStatus({ position: s.positionMillis, duration: s.durationMillis ?? 1 }));
<View className="flex-row items-center gap-3 rounded-2xl border border-input bg-background px-4 py-3">
<PlayButton onPress={togglePlay} />
<Waveform
levels={decodedPeaks}
active={false}
progress={status.position / status.duration}
bars={32}
size="sm"
className="flex-1"
/>
<Text className="text-xs text-muted-foreground">{remaining}</Text>
</View>Bars, size & color#
Tune density with bars, height with size, and pass color to override the theme foreground.
// Fewer, smaller bars for tight spaces
<Waveform size="sm" bars={16} />
// Default: 28 bars, md (20pt tall)
<Waveform />
// Big and branded — color overrides the theme foreground
<Waveform size="lg" bars={40} color="#ef4444" />In the composer#
Built for the Prompt Input recording state — while recording, the composer toolbar swaps its tools for a live waveform with cancel and confirm buttons on the right.
Tap the mic to enter the recording state.
// Inside the Prompt Input composer: while recording, the toolbar
// swaps its tools for a live waveform with cancel/confirm on the right.
import { X, Check } from "lucide-react-native";
import { Waveform } from "@/components/ui/waveform";
import { PromptInputToolbar, PromptInputButton } from "@/components/ui/prompt-input";
<PromptInputToolbar>
<Waveform active size="sm" className="flex-1" />
<PromptInputButton onPress={cancelRecording} accessibilityLabel="Cancel recording">
<X size={20} color="#71717a" />
</PromptInputButton>
<PromptInputButton onPress={finishRecording} accessibilityLabel="Finish recording" className="bg-primary">
<Check size={20} color="#fafafa" />
</PromptInputButton>
</PromptInputToolbar>Props#
barsnumber28Number of bars in the wave.
levelsnumber[]-Real audio amplitudes in 0–1 (mic metering or decoded playback data). The most recent bars values are shown, newest on the right, left-padded with silence; when provided, the bars follow the audio instead of the ambient animation.
activebooleantrueWithout levels: animate the ambient wave (recording). false renders a static wave. Also drives the accessibility label.
progressnumber-Playback position 0–1 — bars past the playhead render faded, the classic voice-message scrubber look.
size"sm" | "md" | "lg""md"colorstring-Bar color; defaults to the theme foreground.
classNamestring-Also accepts all View props. Requires react-native-reanimated for the bar animations.
Accessibility#
accessibilityLabelannounces “Recording” while active and “Audio waveform” when static.- The bars are decorative; the label conveys the state to assistive technology.
Source#
import React, { useEffect } from "react";
import { View, useColorScheme } from "react-native";
import Animated, { useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, cancelAnimation } from "react-native-reanimated";
import { cn } from "@/lib/utils";
const sizes = { sm: 12, md: 20, lg: 28 } as const;
// Ambient fallback shape when no real audio `levels` are wired up —
// deterministic per-bar (no Math.random) so renders and tests are stable.
const ambient = (i: number) => 0.35 + 0.65 * Math.abs(Math.sin(i * 2.4) * Math.cos(i * 0.7));
function Bar({ index, max, active, color, level, dim = 1 }: {
index: number; max: number; active: boolean; color: string; level?: number; dim?: number;
}) {
const height = useSharedValue(3);
const opacity = useSharedValue(dim);
useEffect(() => {
// Follows the playhead smoothly (100ms matches typical status-update ticks).
opacity.value = withTiming(dim, { duration: 100 });
}, [dim, opacity]);
useEffect(() => {
if (level !== undefined) {
// Audio-driven: follow the measured amplitude for this bar.
cancelAnimation(height);
height.value = withTiming(Math.max(3, max * Math.min(1, Math.max(0, level))), { duration: 100 });
} else if (active) {
const duration = 260 + (index % 5) * 70;
height.value = withRepeat(
withSequence(withTiming(max * ambient(index), { duration }), withTiming(max * 0.2, { duration })),
-1,
true
);
} else {
cancelAnimation(height);
height.value = withTiming(Math.max(3, max * ambient(index)), { duration: 150 });
}
return () => cancelAnimation(height);
}, [level, active, index, max, height]);
const style = useAnimatedStyle(() => ({ height: height.value, opacity: opacity.value }));
return <Animated.View style={[style, { backgroundColor: color }]} className="w-0.5 rounded-full" />;
}
export interface WaveformProps extends React.ComponentPropsWithoutRef<typeof View> {
className?: string;
/** Number of bars (default 28). */
bars?: number;
/**
* Real audio amplitudes in 0–1 (e.g. from mic metering or decoded playback
* data). The most recent `bars` values are shown, newest on the right.
* When provided, the bars follow the audio instead of the ambient animation.
*/
levels?: number[];
/** Without `levels`: animate an ambient wave (recording). false = static. */
active?: boolean;
/** Playback position 0–1 — bars past the playhead are faded (scrubber look). */
progress?: number;
size?: keyof typeof sizes;
/** Bar color; defaults to the theme foreground. */
color?: string;
}
export function Waveform({ className, bars = 28, levels, active = true, progress, size = "md", color, style, ...props }: WaveformProps) {
const dark = useColorScheme() === "dark";
const barColor = color ?? (dark ? "#fafafa" : "#18181b");
const window = levels?.slice(-bars);
const pad = window ? bars - window.length : 0;
// Continuous playhead in bar units — the boundary bar gets an interpolated
// opacity so the sweep is smooth instead of stepping bar-by-bar.
const playhead = progress !== undefined ? Math.min(1, Math.max(0, progress)) * bars : undefined;
const dimFor = (i: number) => {
if (playhead === undefined) return 1;
if (i + 1 <= playhead) return 1;
if (i >= playhead) return 0.35;
return 0.35 + 0.65 * (playhead - i);
};
return (
<View
className={cn("flex-row items-center justify-center gap-0.5", className)}
// Fixed to the tallest bar so the row never changes height while the
// bars animate (no layout bounce in composers / recording rows).
style={[{ height: sizes[size] }, style]}
accessibilityLabel={active ? "Recording" : "Audio waveform"}
{...props}
>
{Array.from({ length: bars }, (_, i) => (
<Bar
key={i}
index={i}
max={sizes[size]}
active={active}
color={barColor}
level={window ? (i < pad ? 0 : window[i - pad]) : undefined}
dim={dimFor(i)}
/>
))}
</View>
);
}