media
Voice Note Pulse
A voice-note waveform with recording, playback, scrubbing, stop, and send states.
iOSAndroidExpo Go
hapticsgesture handlerreanimated
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add expo-haptics react-native-gesture-handler react-native-reanimatedData shape
Positive amplitude samples are normalized automatically. Unrecorded slots remain transparent.
Audio integration
Pass recorder samples through amplitudes, playback state through state, and
normalized playback position through progress. Use onProgressChange to
seek with any audio engine.
Install tailwind variants
pnpm add tailwind-variantsUsage
VoiceNotePulseExample.tsximport { VoiceNotePulse } from "@animations/ui/components/media/voice-note-pulse/uniwind";const amplitudes = [0.28, 0.62, 0.44, 0.81, 0.53, 0.7];export function VoiceNotePulseExample() { return ( <VoiceNotePulse activeColor="#8b5cf6" amplitudes={amplitudes} className="w-full" classNames={{ container: "bg-slate-100", send: "bg-violet-600", }} defaultProgress={0.35} duration={24} defaultState="ready" onProgressChange={(progress) => console.log({ progress })} onReset={() => console.log("Recording discarded")} onSend={() => console.log("Recording sent")} onStateChange={(state) => console.log({ state })} /> );}voice-note-pulse/config.tsimport type { VoiceNoteAction, VoiceNoteState,} from "./types";export const VOICE_NOTE_PULSE_CONFIG = { progressStep: 0.01, sampleCount: 32, waveformHeight: 48,} as const;export const VOICE_NOTE_PULSE_COLORS = { active: "#6f5cff", inactive: "#d8d7d0",} as const;export const VOICE_NOTE_STATE_CONFIG: Record< VoiceNoteState, { action: VoiceNoteAction; label: string; nextState: VoiceNoteState; }> = { idle: { action: "record", label: "Ready to record", nextState: "recording", }, playing: { action: "pause", label: "Playing", nextState: "ready", }, ready: { action: "play", label: "Ready to send", nextState: "playing", }, recording: { action: "send", label: "Recording", nextState: "recording", },};voice-note-pulse/icons.tsimport type { ComponentProps } from "react";import type { Feather } from "@expo/vector-icons";import type { VoiceNoteAction } from "./types";export const VOICE_NOTE_ACTION_ICONS: Record< VoiceNoteAction, ComponentProps<typeof Feather>["name"]> = { pause: "pause", play: "play", record: "mic", send: "send",};voice-note-pulse/types.tsexport const VOICE_NOTE_STATES = [ "idle", "recording", "ready", "playing",] as const;export type VoiceNoteState = (typeof VOICE_NOTE_STATES)[number];export type VoiceNoteAction = | "pause" | "play" | "record" | "send";export type VoiceNotePulseBaseProps = { accessibilityLabel?: string; activeColor?: string; amplitudes: readonly number[]; defaultProgress?: number; defaultState?: VoiceNoteState; disabled?: boolean; duration?: number; haptics?: boolean; inactiveColor?: string; onProgressChange?: (progress: number) => void; onReset?: () => void; onSend?: () => void; onStateChange?: (state: VoiceNoteState) => void; progress?: number; sampleCount?: number; state?: VoiceNoteState; waveformHeight?: number;};voice-note-pulse/uniwind/index.tsximport { Feather } from "@expo/vector-icons";import { Pressable, Text, View,} from "react-native";import { GestureDetector } from "react-native-gesture-handler";import Animated from "react-native-reanimated";import type { SlotsToClasses } from "#shared/types/slots";import { VOICE_NOTE_PULSE_CONFIG } from "../config";import { VOICE_NOTE_ACTION_ICONS } from "../icons";import type { VoiceNotePulseBaseProps } from "../types";import { formatVoiceNoteDuration, useVoiceNotePulse,} from "../use-voice-note-pulse";import { WaveformBar } from "../waveform-bar";import { voiceNotePulseVariants, type VoiceNotePulseSlot,} from "./variants";export type VoiceNotePulseProps = VoiceNotePulseBaseProps & { className?: string; classNames?: SlotsToClasses<VoiceNotePulseSlot>;};type WaveformBarsProps = { amplitudes: readonly (number | null)[]; className: string; color?: string; height: number;};function WaveformBars({ amplitudes, className, color, height,}: WaveformBarsProps) { return amplitudes.map((amplitude, index) => ( <WaveformBar amplitude={amplitude} className={className} height={height} key={index} style={color ? { backgroundColor: color } : undefined} /> ));}export function VoiceNotePulse({ accessibilityLabel = "Voice note", activeColor, amplitudes, className, classNames, defaultProgress, defaultState, disabled, duration = 0, haptics, inactiveColor, onProgressChange, onReset, onSend, onStateChange, progress, sampleCount, state, waveformHeight = VOICE_NOTE_PULSE_CONFIG.waveformHeight,}: VoiceNotePulseProps) { const { accessibilityActions, action, canScrub, currentProgress, currentState, elapsed, normalizedAmplitudes, onAccessibilityAction, onWaveformLayout, performPrimaryAction, progressAnimatedStyle, reset, scrubGesture, send, statusLabel, stopPlayback, thumbAnimatedStyle, waveformAnimatedStyle, waveformContentAnimatedStyle, } = useVoiceNotePulse({ amplitudes, defaultProgress, defaultState, disabled, duration, haptics, onProgressChange, onReset, onSend, onStateChange, progress, sampleCount, state, waveformHeight, }); const { action: actionSlot, activeBar, activeWaveform, activeWaveformRow, bar, container, controls, duration: durationSlot, elapsed: elapsedSlot, header, send: sendSlot, status, stop: stopSlot, thumb, thumbDot, time, waveform, waveformRow, } = voiceNotePulseVariants(); const showStop = currentState === "recording"; const showPlaybackStop = currentState === "playing" || currentState === "ready"; const showSend = currentState === "ready" && onSend !== undefined; const primaryIcon = VOICE_NOTE_ACTION_ICONS[action]; return ( <View className={container({ className: [className, classNames?.container], })} > <View className={header({ className: classNames?.header })}> <Text className={status({ className: classNames?.status })}> {statusLabel} </Text> <View className={time({ className: classNames?.time })}> <Text className={elapsedSlot({ className: classNames?.elapsed, })} > {formatVoiceNoteDuration(elapsed)} </Text> <Text className={durationSlot({ className: classNames?.duration, })} > / {formatVoiceNoteDuration(duration)} </Text> </View> </View> <GestureDetector gesture={scrubGesture}> <Animated.View accessibilityActions={accessibilityActions} accessibilityLabel={`${accessibilityLabel} waveform`} accessibilityRole="adjustable" accessibilityState={{ disabled: disabled || !canScrub }} accessibilityValue={{ max: 100, min: 0, now: Math.round(currentProgress * 100), }} accessible className={waveform({ className: classNames?.waveform, })} onAccessibilityAction={onAccessibilityAction} onLayout={onWaveformLayout} style={[ { height: waveformHeight }, waveformAnimatedStyle, ]} > <View className={waveformRow({ className: classNames?.waveformRow, })} > <WaveformBars amplitudes={normalizedAmplitudes} className={bar({ className: classNames?.bar })} color={inactiveColor} height={waveformHeight} /> </View> <Animated.View className={activeWaveform({ className: classNames?.activeWaveform, })} pointerEvents="none" style={[ { height: waveformHeight }, progressAnimatedStyle, ]} > <Animated.View className={activeWaveformRow({ className: classNames?.activeWaveformRow, })} style={waveformContentAnimatedStyle} > <WaveformBars amplitudes={normalizedAmplitudes} className={bar({ className: [ activeBar({ className: classNames?.activeBar, }), classNames?.bar, ], })} color={activeColor} height={waveformHeight} /> </Animated.View> </Animated.View> <Animated.View className={thumb({ className: classNames?.thumb })} pointerEvents="none" style={thumbAnimatedStyle} > <View className={thumbDot({ className: classNames?.thumbDot, })} /> </Animated.View> </Animated.View> </GestureDetector> <View className={controls({ className: classNames?.controls })} > <Pressable accessibilityLabel={action} accessibilityRole="button" className={actionSlot({ className: [ currentState === "recording" && sendSlot({ className: classNames?.send }), classNames?.action, ], })} disabled={disabled} onPress={performPrimaryAction} > <Feather color="#ffffff" name={primaryIcon} size={19} /> </Pressable> {showStop && ( <Pressable accessibilityLabel="Reset recording" accessibilityRole="button" className={actionSlot({ className: [ stopSlot({ className: classNames?.stop }), classNames?.action, ], })} disabled={disabled} onPress={reset} > <Feather color="#171713" name="square" size={17} /> </Pressable> )} {showPlaybackStop && ( <Pressable accessibilityLabel="Stop playback" accessibilityRole="button" className={actionSlot({ className: [ stopSlot({ className: classNames?.stop }), classNames?.action, ], })} disabled={disabled} onPress={stopPlayback} > <Feather color="#171713" name="square" size={17} /> </Pressable> )} {showSend && ( <Pressable accessibilityLabel="Send voice note" accessibilityRole="button" className={actionSlot({ className: [ sendSlot({ className: classNames?.send }), classNames?.action, ], })} disabled={disabled} onPress={send} > <Feather color="#ffffff" name="send" size={18} /> </Pressable> )} </View> </View> );}voice-note-pulse/uniwind/variants.tsimport { tv } from "tailwind-variants";export const voiceNotePulseSlots = { action: "size-11 items-center justify-center rounded-full bg-[#171713]", activeBar: "bg-[#6f5cff]", activeWaveform: "absolute inset-y-0 left-0 overflow-hidden", activeWaveformRow: "absolute inset-y-0 left-0 flex-row items-center gap-0.5", bar: "min-w-0.5 flex-1 rounded-sm bg-[#d8d7d0]", container: "gap-4 rounded-3xl bg-[#f0efe9] p-4", controls: "flex-row items-center gap-2.5", duration: "text-xs font-bold text-[#77776f]", elapsed: "text-xs font-black tabular-nums text-[#171713]", header: "flex-row items-center justify-between", send: "bg-[#6f5cff]", status: "text-[11px] font-black uppercase tracking-[1.2px] text-[#77776f]", stop: "bg-white", thumb: "absolute inset-y-0 w-0.5 rounded-full bg-[#171713]", thumbDot: "absolute -left-1 -top-1.25 size-2.5 rounded-full bg-[#171713]", time: "flex-row items-center gap-1.5", waveform: "relative justify-center", waveformRow: "absolute inset-0 flex-row items-center gap-0.5",} as const;export type VoiceNotePulseSlot = keyof typeof voiceNotePulseSlots;export const voiceNotePulseVariants = tv({ slots: voiceNotePulseSlots,});voice-note-pulse/use-voice-note-pulse.tsimport { useCallback, useMemo, useState } from "react";import * as Haptics from "expo-haptics";import type { AccessibilityActionEvent, LayoutChangeEvent,} from "react-native";import { Gesture } from "react-native-gesture-handler";import { cancelAnimation, runOnJS, useAnimatedReaction, useAnimatedStyle, useReducedMotion, useSharedValue, withRepeat, withSequence, withTiming,} from "react-native-reanimated";import { VOICE_NOTE_PULSE_CONFIG, VOICE_NOTE_STATE_CONFIG,} from "./config";import type { VoiceNotePulseBaseProps, VoiceNoteState,} from "./types";function clamp(value: number, minimum = 0, maximum = 1) { "worklet"; return Math.min(maximum, Math.max(minimum, value));}export function formatVoiceNoteDuration(seconds: number) { const safeSeconds = Math.max(0, Math.floor(seconds)); const minutes = Math.floor(safeSeconds / 60); const remainder = String(safeSeconds % 60).padStart(2, "0"); return `${minutes}:${remainder}`;}export function useVoiceNotePulse({ amplitudes, defaultProgress = 0, defaultState = "idle", disabled = false, duration = 0, haptics = true, onProgressChange, onReset, onSend, onStateChange, progress, sampleCount = VOICE_NOTE_PULSE_CONFIG.sampleCount, state,}: VoiceNotePulseBaseProps) { if (sampleCount < 1) { throw new Error( "VoiceNotePulse sampleCount must be greater than zero.", ); } const [internalProgress, setInternalProgress] = useState( clamp(defaultProgress), ); const [internalState, setInternalState] = useState<VoiceNoteState>(defaultState); const isProgressControlled = progress !== undefined; const isStateControlled = state !== undefined; const currentProgress = clamp(progress ?? internalProgress); const currentState = state ?? internalState; const waveformWidth = useSharedValue(0); const progressX = useSharedValue(0); const dragging = useSharedValue(false); const lastReportedStep = useSharedValue( Math.round( currentProgress / VOICE_NOTE_PULSE_CONFIG.progressStep, ), ); const recordingPulse = useSharedValue(1); const reducedMotion = useReducedMotion(); const canScrub = currentState === "playing" || currentState === "ready"; const normalizedAmplitudes = useMemo(() => { const visibleAmplitudes = amplitudes.slice(-sampleCount); const maximum = Math.max(1, ...visibleAmplitudes); const normalized = visibleAmplitudes.map((amplitude) => Math.max(0.08, Math.min(1, amplitude / maximum)), ); return [ ...normalized, ...Array<null>(sampleCount - normalized.length).fill(null), ]; }, [amplitudes, sampleCount]); const recordedFraction = Math.min( 1, amplitudes.length / sampleCount, ); const visualProgress = currentState === "recording" ? recordedFraction : currentProgress * recordedFraction; const changeState = useCallback( (nextState: VoiceNoteState) => { if (disabled || nextState === currentState) { return; } if (!isStateControlled) { setInternalState(nextState); } if (haptics) { void Haptics.selectionAsync(); } onStateChange?.(nextState); }, [ currentState, disabled, haptics, isStateControlled, onStateChange, ], ); const changeProgress = useCallback( (nextProgress: number) => { const boundedProgress = Math.min( 1, Math.max(0, nextProgress), ); if (!isProgressControlled) { setInternalProgress(boundedProgress); } onProgressChange?.(boundedProgress); }, [isProgressControlled, onProgressChange], ); const reset = useCallback(() => { changeProgress(0); changeState("idle"); onReset?.(); }, [changeProgress, changeState, onReset]); const send = useCallback(() => { if (disabled) { return; } if (haptics) { void Haptics.notificationAsync( Haptics.NotificationFeedbackType.Success, ); } onSend?.(); reset(); }, [disabled, haptics, onSend, reset]); const stopPlayback = useCallback(() => { changeProgress(0); changeState("ready"); }, [changeProgress, changeState]); const performPrimaryAction = useCallback(() => { if (currentState === "recording") { send(); return; } if (currentState === "ready" && currentProgress === 1) { changeProgress(0); } changeState( VOICE_NOTE_STATE_CONFIG[currentState].nextState, ); }, [ changeProgress, changeState, currentProgress, currentState, send, ]); useAnimatedReaction( () => ({ dragging: dragging.get(), target: visualProgress * waveformWidth.get(), }), (current, previous) => { if ( current.dragging || current.target === previous?.target ) { return; } progressX.set( reducedMotion ? current.target : withTiming(current.target, { duration: 180 }), ); lastReportedStep.set( Math.round( currentProgress / VOICE_NOTE_PULSE_CONFIG.progressStep, ), ); }, [currentProgress, reducedMotion, visualProgress], ); useAnimatedReaction( () => currentState === "recording", (isRecording, wasRecording) => { if (isRecording === wasRecording) { return; } cancelAnimation(recordingPulse); if (!isRecording || reducedMotion) { recordingPulse.set(1); return; } recordingPulse.set( withRepeat( withSequence( withTiming(1.03, { duration: 520 }), withTiming(0.98, { duration: 520 }), ), -1, true, ), ); }, [currentState, reducedMotion], ); const scrubGesture = useMemo( () => Gesture.Pan() .enabled(!disabled && canScrub) .minDistance(0) .onBegin((event) => { dragging.set(true); progressX.set(clamp(event.x, 0, waveformWidth.get())); }) .onUpdate((event) => { const width = Math.max(1, waveformWidth.get()); const nextX = clamp(event.x, 0, width); const nextProgress = nextX / width; const nextStep = Math.round( nextProgress / VOICE_NOTE_PULSE_CONFIG.progressStep, ); progressX.set(nextX); if (nextStep !== lastReportedStep.get()) { lastReportedStep.set(nextStep); runOnJS(changeProgress)(nextProgress); } }) .onFinalize(() => { const width = Math.max(1, waveformWidth.get()); dragging.set(false); runOnJS(changeProgress)(progressX.get() / width); }), [ canScrub, changeProgress, disabled, dragging, lastReportedStep, progressX, waveformWidth, ], ); const onWaveformLayout = useCallback( (event: LayoutChangeEvent) => { waveformWidth.set(event.nativeEvent.layout.width); }, [waveformWidth], ); const onAccessibilityAction = useCallback( (event: AccessibilityActionEvent) => { if (event.nativeEvent.actionName === "increment") { changeProgress(currentProgress + 0.05); } if (event.nativeEvent.actionName === "decrement") { changeProgress(currentProgress - 0.05); } }, [changeProgress, currentProgress], ); const progressAnimatedStyle = useAnimatedStyle(() => ({ width: progressX.get(), })); const thumbAnimatedStyle = useAnimatedStyle(() => ({ opacity: canScrub ? 1 : 0, transform: [{ translateX: progressX.get() - 1 }], })); const waveformAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ scaleY: recordingPulse.get() }], })); const waveformContentAnimatedStyle = useAnimatedStyle(() => ({ width: waveformWidth.get(), })); return { accessibilityActions: [ { name: "increment" as const }, { name: "decrement" as const }, ], action: VOICE_NOTE_STATE_CONFIG[currentState].action, canScrub, currentProgress, currentState, elapsed: currentState === "recording" ? duration : currentProgress * duration, normalizedAmplitudes, onAccessibilityAction, onWaveformLayout, performPrimaryAction, progressAnimatedStyle, scrubGesture, send, statusLabel: VOICE_NOTE_STATE_CONFIG[currentState].label, reset, stopPlayback, thumbAnimatedStyle, waveformAnimatedStyle, waveformContentAnimatedStyle, };}voice-note-pulse/waveform-bar.tsximport type { StyleProp, ViewStyle } from "react-native";import Animated, { useAnimatedStyle, useDerivedValue, useReducedMotion, withTiming,} from "react-native-reanimated";type WaveformBarProps = { amplitude: number | null; className?: string; height: number; style?: StyleProp<ViewStyle>;};export function WaveformBar({ amplitude, className, height, style,}: WaveformBarProps) { const reducedMotion = useReducedMotion(); const targetHeight = amplitude === null ? 0 : Math.max(4, amplitude * height); const targetOpacity = amplitude === null ? 0 : 1; const animatedHeight = useDerivedValue(() => reducedMotion ? targetHeight : withTiming(targetHeight, { duration: 240 }), ); const animatedOpacity = useDerivedValue(() => reducedMotion ? targetOpacity : withTiming(targetOpacity, { duration: 180 }), ); const animatedStyle = useAnimatedStyle(() => ({ height: animatedHeight.get(), opacity: animatedOpacity.get(), })); return ( <Animated.View className={className} style={[style, animatedStyle]} /> );}