Veltrix

media

Voice Note Pulse

A voice-note waveform with recording, playback, scrubbing, stop, and send states.

AppleiOSAndroidAndroidExpoExpo Go
hapticsgesture handlerreanimated
GitHubOpen in GitHub

Installation

Install the dependencies, then copy the files for your preferred renderer.

pnpm add expo-haptics react-native-gesture-handler react-native-reanimated

Data 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-variants

Usage

VoiceNotePulseExample.tsx
import { 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 })}    />  );}

Component files

7 files

voice-note-pulse/config.ts
import 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.ts
import 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.ts
export 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.tsx
import { 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.ts
import { 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.ts
import { 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.tsx
import 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]}    />  );}

API reference

Generated directly from the exported component props.

VoiceNotePulse