Interaction
Voice Note
An expressive interaction rebuilt for touch
Expo code
Original web component
Rare UIOur Expo component
React Native WebComponent 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.