AniUI

Waveform

Animated audio waveform bars for voice-recording and playback states.

levels — bars follow a (simulated) mic signal, newest on the right.

Web preview — components render natively on iOS & Android
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 waveform

Usage#

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.

app/recorder.tsx
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.

Web preview — components render natively on iOS & Android
// 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.

0:12
Web preview — components render natively on iOS & Android
// 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.

Web preview — components render natively on iOS & Android
// 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.

Web preview — components render natively on iOS & Android
// 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#

PropTypeDefault
bars
number
28

Number of bars in the wave.

levels
number[]
-

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.

active
boolean
true

Without levels: animate the ambient wave (recording). false renders a static wave. Also drives the accessibility label.

progress
number
-

Playback position 0–1 — bars past the playhead render faded, the classic voice-message scrubber look.

size
"sm" | "md" | "lg"
"md"
color
string
-

Bar color; defaults to the theme foreground.

className
string
-

Also accepts all View props. Requires react-native-reanimated for the bar animations.

Accessibility#

  • accessibilityLabel announces “Recording” while active and “Audio waveform” when static.
  • The bars are decorative; the label conveys the state to assistive technology.

Source#

components/ui/waveform.tsx
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>
  );
}