Skip to content
pixelfor Expo
Ctrl K

Interaction

Voice Note

An expressive interaction rebuilt for touch

Try it on your phone

Voice Note

Scan to open Voice Note in Expo Go

Install Expo Go first, then scan this QR to run the native component.

GET EXPO GO ONGoogle Play

This demo uses Expo SDK 54. If your installed Expo Go supports a different SDK, get the compatible build. iOS needs a build supporting this SDK.

Expo code

Original web component

Rare UI

Our Expo component

React Native Web

Component source

Make it yours

One file to copy into your app.

voice-note.tsx

import React, {
  createContext,
  useContext,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
} from "react"
import { Platform, Pressable, Text, View } from "react-native"
import { useAudioPlayer, useAudioPlayerStatus } from "expo-audio"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import Animated, {
  useAnimatedProps,
  useAnimatedStyle,
  useFrameCallback,
  useReducedMotion,
  useSharedValue,
  withSpring,
  withTiming,
  type SharedValue,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import Svg, {
  Circle,
  Defs,
  Ellipse,
  LinearGradient,
  Mask,
  Path,
  RadialGradient,
  Rect,
  Stop,
} from "react-native-svg"
const APath = Animated.createAnimatedComponent(Path),
  ACircle = Animated.createAnimatedComponent(Circle)
const LIGHTS = [
  { size: 2, alpha: 0.5, lap: 11, offset: 0.04, pulse: 0.12 },
  { size: 1.5, alpha: 0.4, lap: 17, offset: 0.19, pulse: 0.14 },
  { size: 2.3, alpha: 0.45, lap: 23, offset: 0.47, pulse: 0.1 },
  { size: 1.2, alpha: 0.35, lap: 13, offset: 0.71, pulse: 0.16 },
]
function AuroraLight({ light, clock, width, height, paint }: { light: typeof LIGHTS[number]; clock: SharedValue<number>; width: number; height: number; paint: string }) {
  const props = useAnimatedProps(() => {
    const radius = height / 2
    const straight = Math.max(0, width - height)
    const arc = Math.PI * radius
    const perimeter = 2 * straight + 2 * arc
    let distance = ((light.offset + clock.get() / light.lap) * perimeter) % perimeter
    let x, y
    if (distance < straight) { x = radius + distance; y = 0 }
    else if ((distance -= straight) < arc) {
      const angle = -Math.PI / 2 + distance / radius
      x = width - radius + radius * Math.cos(angle); y = radius + radius * Math.sin(angle)
    } else if ((distance -= arc) < straight) { x = width - radius - distance; y = height }
    else {
      const angle = Math.PI / 2 + (distance - straight) / radius
      x = radius + radius * Math.cos(angle); y = radius + radius * Math.sin(angle)
    }
    const pulse = 1 + Math.sin(clock.get() * 0.6 + light.offset * Math.PI * 2) * light.pulse
    return { cx: x, cy: y, r: (light.size / 2 + 0.32) * height * pulse }
  })
  return <ACircle animatedProps={props} fill={paint} opacity={light.alpha} />
}
function Aurora({ active, reduced, accent, width, height }: { active: boolean; reduced: boolean; accent: string; width: number; height: number }) {
  const clock = useSharedValue(6.2), rate = useSharedValue(0), glow = useSharedValue(Number(active))
  const id = useId().replace(/:/g, "")
  useEffect(() => {
    glow.set(reduced ? Number(active) : withTiming(Number(active), { duration: 500 }))
  }, [active, reduced, glow])
  useFrameCallback(({ timeSincePreviousFrame }) => {
    if (reduced || timeSincePreviousFrame == null || (!active && rate.get() < 0.002)) return
    const dt = Math.min(50, timeSincePreviousFrame) / 1000
    rate.set(rate.get() + (Number(active) - rate.get()) * (1 - Math.exp(-dt / 0.45)))
    clock.set(clock.get() + dt * rate.get())
  })
  const style = useAnimatedStyle(() => ({ opacity: glow.get() * 0.62 }))
  return <Animated.View pointerEvents="none" style={[{ position: "absolute", top: 0, right: 0, bottom: 0, left: 0 }, style]}>
    <Svg width={width} height={height}>
      <Defs>
        <RadialGradient id={`${id}-light`}>
          <Stop offset="0" stopColor={accent} stopOpacity={0.85} />
          <Stop offset="0.5" stopColor={accent} stopOpacity={0.5} />
          <Stop offset="1" stopColor={accent} stopOpacity={0} />
        </RadialGradient>
        <LinearGradient id={`${id}-fade`} x1="0" y1="0" x2="0" y2="1">
          <Stop offset="0" stopColor="#fff" /><Stop offset="0.46" stopColor="#fff" stopOpacity={0.3} />
          <Stop offset="0.54" stopColor="#fff" stopOpacity={0.3} /><Stop offset="1" stopColor="#fff" />
        </LinearGradient>
        <Mask id={`${id}-mask`}><Rect width={width} height={height} fill={`url(#${id}-fade)`} /></Mask>
      </Defs>
      <Svg mask={`url(#${id}-mask)`}>
        <Ellipse cx={width * 0.08} cy={height * 1.15} rx={width * 0.7} ry={height * 1.7} fill={`url(#${id}-light)`} />
        <Ellipse cx={width * 0.4} cy={height * 1.3} rx={width * 0.55} ry={height * 1.5} fill={`url(#${id}-light)`} />
        {LIGHTS.map((light, i) => <AuroraLight key={i} light={light} clock={clock} width={width} height={height} paint={`url(#${id}-light)`} />)}
      </Svg>
    </Svg>
  </Animated.View>
}
const Group = createContext<{
  register: (id: string, pause: () => void) => () => void
  activate: (id: string) => void
} | null>(null)
export function VoiceNoteGroup({ children }: { children: React.ReactNode }) {
  const notes = useRef(new Map<string, () => void>())
  return (
    <Group.Provider
      value={{
        register: (id, pause) => {
          notes.current.set(id, pause)
          return () => {
            notes.current.delete(id)
          }
        },
        activate: (id) =>
          notes.current.forEach((pause, key) => {
            if (key !== id) pause()
          }),
      }}
    >
      {children}
    </Group.Provider>
  )
}
export interface VoiceNoteProps {
  src?: string
  duration?: number
  waveform?: number[]
  bars?: number
  seed?: number
  playing?: boolean
  defaultPlaying?: boolean
  onPlayingChange?: (playing: boolean) => void
  onEnded?: () => void
  accent?: string
  size?: "sm" | "md" | "lg"
  seekable?: boolean
  speeds?: number[]
  onSpeedChange?: (speed: number) => void
  backgroundColor?: string
  textColor?: string
  testID?: string
}
const PLAY = [
  7.7, 5.8, 13, 8.9, 13, 15.1, 7.7, 18.2, 13, 8.9, 18.3, 12, 18.3, 12, 13, 15.1,
]
const PAUSE = [
  8.2, 6.8, 10.9, 6.8, 10.9, 17.2, 8.2, 17.2, 13.1, 6.8, 15.8, 6.8, 15.8, 17.2,
  13.1, 17.2,
]
function WaveBar({
  height,
  index,
  fill,
  peak,
}: {
  height: number
  index: number
  fill: string
  peak: number
}) {
  return (
    <Rect
      fill={fill}
      x={index * 6}
      y={(peak - height) / 2}
      width={3}
      height={height}
      rx={1.5}
    />
  )
}
export function VoiceNote({
  src = "https://pixel-video-cdn.vercel.app/rare-ui/voice-note-user.mp3",
  duration = 53,
  waveform,
  bars = 40,
  seed = 7,
  playing,
  defaultPlaying = false,
  onPlayingChange,
  onEnded,
  accent = "#FC4C01",
  size = "md",
  seekable = true,
  speeds = [1, 1.5, 2],
  onSpeedChange,
  backgroundColor = "#1C1C1C",
  textColor = "#868593",
  testID,
}: VoiceNoteProps) {
  const player = useAudioPlayer(src ?? null, { updateInterval: 200 }),
    status = useAudioPlayerStatus(player)
  const [localPlaying, setLocalPlaying] = useState(defaultPlaying),
    [speed, setSpeed] = useState(speeds[0] ?? 1),
    [clock, setClock] = useState(0),
    [failed, setFailed] = useState(false)
  const active = playing ?? localPlaying,
    total = src && Number.isFinite(status.duration) && status.duration > 0 ? status.duration : duration
  const id = useId(),
    group = useContext(Group),
    reduced = useReducedMotion(),
    latest = useRef({ onEnded, onPlayingChange, active })
  latest.current = { onEnded, onPlayingChange, active }
  const progress = useSharedValue(0),
    shape = useSharedValue(Number(active)),
    waveWidth = useSharedValue(200)
  function change(next: boolean) {
    if (next && failed) return
    if (next) group?.activate(id)
    if (playing === undefined) setLocalPlaying(next)
    onPlayingChange?.(next)
  }
  useEffect(
    () =>
      group?.register(id, () => {
        player.pause()
        if (playing === undefined) setLocalPlaying(false)
        latest.current.onPlayingChange?.(false)
      }),
    [group, id, player, playing]
  )
  useEffect(() => {
    if (!src || !status.isLoaded) return
    try {
      if (active) {
        player.play()
      } else player.pause()
    } catch {
      setFailed(true)
      change(false)
    }
  }, [active, src, player, status.isLoaded])
  useEffect(() => {
    if (src && Number.isFinite(status.currentTime)) setClock(status.currentTime)
    if (status.didJustFinish) {
      setClock(total)
      change(false)
      onEnded?.()
    }
  }, [src, status.currentTime, status.didJustFinish])
  useEffect(() => {
    if (src || !active) return
    const interval = setInterval(
      () =>
        setClock((time) => {
          const next = Math.min(total, time + 0.1 * speed)
          return next
        }),
      100
    )
    return () => clearInterval(interval)
  }, [src, active, total, speed])
  useEffect(() => {
    if (!src && clock >= total && active) {
      change(false)
      onEnded?.()
    }
  }, [clock, total, src, active])
  useEffect(() => {
    const fraction = total > 0 ? Math.max(0, Math.min(1, clock / total)) : 0
    progress.value = reduced ? fraction : withTiming(fraction, { duration: 180 })
  }, [clock, total, reduced, progress])
  useEffect(() => {
    shape.value = reduced
      ? Number(active)
      : withSpring(Number(active), { duration: 340, dampingRatio: 0.8 })
  }, [active, reduced, shape])
  const icon = useAnimatedProps(() => {
    let d = ""
    for (let q = 0; q < 16; q += 8) {
      for (let p = 0; p < 8; p += 2)
        d += `${p ? "L" : "M"}${PLAY[q + p] + (PAUSE[q + p] - PLAY[q + p]) * shape.value} ${PLAY[q + p + 1] + (PAUSE[q + p + 1] - PLAY[q + p + 1]) * shape.value} `
      d += "Z "
    }
    return { d }
  })
  const amplitudes = useMemo(() => {
    if (waveform) return waveform
    let state = (seed >>> 0) + 0x9e3779b9
    return Array.from({ length: bars }, (_, i) => {
      state = (state * 1664525 + 1013904223) >>> 0
      const envelope = Math.sin((Math.PI * (i + 0.5)) / bars) ** 0.55
      const swell = 0.5 + 0.5 * Math.sin(i * 0.9 + seed)
      return Math.round(Math.max(
        0.14,
        Math.min(
          1,
          envelope * (0.3 + (0.5 * state) / 0x100000000 + 0.2 * swell)
        )
      ) * 1000) / 1000
    })
  }, [waveform, bars, seed])
  function seek(fraction: number) {
    const time = Math.max(0, Math.min(total, fraction * total))
    setClock(time)
    if (src) void player.seekTo(time).catch(() => setFailed(true))
  }
  const pan = Gesture.Pan()
    .enabled(seekable)
    .minDistance(0)
    .onUpdate((e) => {
      progress.value = Math.max(0, Math.min(1, e.x / waveWidth.value))
    })
    .onEnd(() => scheduleOnRN(seek, progress.value))
  const tap = Gesture.Tap()
    .enabled(seekable)
    .onEnd((e) => {
      progress.value = Math.max(0, Math.min(1, e.x / waveWidth.value))
      scheduleOnRN(seek, progress.value)
    })
  const progressClip = useAnimatedStyle(() => ({ width: waveWidth.get() * progress.get() }))
  const height = size === "sm" ? 40 : size === "lg" ? 64 : 52,
    peak = height * 0.68,
    elapsed = Math.max(0, Math.floor(clock)),
    control = Math.round(height * 0.76),
    width = control + Math.round((height-control)/2) + amplitudes.length*6-3 + 20 + 14 + 29 + (speed === 1 ? 0 : 28)
  return (
    <View
      testID={testID}
      style={{
        height,
        width,
        alignSelf: "center",
        borderRadius: height / 2,
        backgroundColor,
        paddingLeft: Math.round((height - control) / 2),
        paddingRight: 14,
        flexDirection: "row",
        gap: 10,
        alignItems: "center",
        overflow: "hidden",
      }}
    >
      <Aurora {...{ active, reduced, accent, width, height }} />
      <Pressable
        disabled={failed || (!!src && !status.isLoaded)}
        accessibilityRole="button"
        accessibilityLabel={active ? "Pause voice note" : "Play voice note"}
        onPress={async () => {
          if (clock >= total) {
            setClock(0)
            if (src) await player.seekTo(0)
          }
          change(!active)
        }}
        style={({ pressed }) => ({
          width: control,
          height: control,
          borderRadius: control / 2,
            backgroundColor: "#0E0E0E",
          alignItems: "center",
          justifyContent: "center",
          opacity: pressed ? 0.7 : failed ? 0.4 : 1,
        })}
      >
        <Svg width={control * 0.72} height={control * 0.72} viewBox="0 0 24 24">
          <APath
            animatedProps={icon}
            fill="#fff"
            stroke="#fff"
            strokeWidth={1.2}
            strokeLinejoin="round"
          />
        </Svg>
      </Pressable>
      <GestureDetector gesture={Gesture.Race(pan, tap)}>
        <View
          onLayout={(e) => {
            waveWidth.value = e.nativeEvent.layout.width
          }}
          style={{ flex: 1, height: peak, justifyContent: "center" }}
          accessible
          accessibilityRole="adjustable"
          accessibilityLabel="Playback position"
          aria-valuemin={0}
          aria-valuemax={Math.round(total)}
          aria-valuenow={Math.floor(clock)}
          accessibilityValue={{
            min: 0,
            max: Math.round(total),
            now: Math.round(clock),
          }}
          accessibilityActions={[
            { name: "increment", label: "Forward five seconds" },
            { name: "decrement", label: "Back five seconds" },
          ]}
          onAccessibilityAction={(e) =>
            seek(
              (clock + (e.nativeEvent.actionName === "increment" ? 5 : -5)) /
                total
            )
          }
        >
          <Svg
            width="100%"
            height={peak}
            viewBox={`0 0 ${amplitudes.length * 6} ${peak}`}
            preserveAspectRatio="none"
          >
            {amplitudes.map((a, i) => (
              <WaveBar
                key={i}
                height={a * peak}
                peak={peak}
                index={i}
                fill="#ffffff66"
              />
            ))}
          </Svg>
          <Animated.View pointerEvents="none" style={[{ position: "absolute", left: 0, top: 0, bottom: 0, overflow: "hidden" }, progressClip]}>
            <Svg width={amplitudes.length * 6 - 3} height={peak} viewBox={`0 0 ${amplitudes.length * 6} ${peak}`} preserveAspectRatio="none">
              {amplitudes.map((a, i) => <WaveBar key={i} height={a * peak} peak={peak} index={i} fill="#fff" />)}
            </Svg>
          </Animated.View>
        </View>
      </GestureDetector>
      <Pressable
        accessibilityRole="button"
        accessibilityLabel={`Playback speed ${speed} times`}
        onPress={() => {
          const next = speeds[(speeds.indexOf(speed) + 1) % speeds.length] ?? 1
          setSpeed(next)
          if (src) player.setPlaybackRate(next)
          onSpeedChange?.(next)
        }}
        style={{ flexDirection: "row", gap: 4, paddingVertical: 8 }}
      >
        <Text style={{ color: textColor, fontSize: 12, fontWeight: "600", fontFamily: Platform.OS === "web" ? "Arial" : undefined, fontVariant: ["tabular-nums"] }}>{Math.floor(elapsed / 60)}:{String(elapsed % 60).padStart(2, "0")}</Text>
        {speed !== 1 && <Text style={{ color: textColor, fontSize: 11 }}>{speed}×</Text>}
      </Pressable>
    </View>
  )
}
export default VoiceNote

Give the gestures and motion a run on a real Android phone.

Back to Rare UI