Interaction
Voice Note
An expressive interaction rebuilt for touch
Expo code
Original web component
Watermelon UIOur Expo component
React Native WebComponent source
Make it yours
One file to copy into your app.
voice-note.in-progress.tsx
import React, { useCallback, useEffect, useId, useRef, useState } from "react"
import {
Platform,
Pressable,
View,
useColorScheme,
type StyleProp,
type ViewStyle,
} from "react-native"
import Animated, {
cubicBezier,
LinearTransition,
Easing,
cancelAnimation,
useAnimatedProps,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withSpring,
withTiming,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import Svg, {
Defs,
Filter,
FeGaussianBlur,
G,
Path,
Rect,
Text as SvgText,
} from "react-native-svg"
export const RecorderState = {
IDLE: "IDLE",
RECORDING: "RECORDING",
REVIEWING: "REVIEWING",
PLAYING: "PLAYING",
} as const
export type RecorderState = (typeof RecorderState)[keyof typeof RecorderState]
export interface VoiceNoteProps {
onSend?: (data: { duration: number; blob: null }) => void
onCancel?: () => void
onStateChange?: (state: RecorderState) => void
maxDuration?: number
theme?: "light" | "dark" | "system"
fontFamily?: string
radius?: number
colors?: Partial<{
surface: string
center: string
border: string
actionBorder: string
check: string
send: string
text: string
recording: string
waveform: string
progress: string
}>
style?: StyleProp<ViewStyle>
testID?: string
}
const SPRING = { stiffness: 400, damping: 40 }
const REVIEW_LAYOUT = LinearTransition.springify().stiffness(400).damping(40)
const DIGIT_SPRING = { stiffness: 200, damping: 16, mass: 1.2 }
class BlurTarget extends FeGaussianBlur {
setNativeProps = (
props: React.ComponentProps<typeof FeGaussianBlur> & {
style?: React.ComponentProps<typeof FeGaussianBlur>
}
) => {
if (Platform.OS === "web") {
const node = (
this as unknown as {
elementRef?: {
current: {
setAttribute: (key: string, value: string) => void
} | null
}
}
).elementRef?.current
const amount = props.style?.stdDeviation ?? props.stdDeviation
if (amount !== undefined)
node?.setAttribute("stdDeviation", String(amount))
} else this.root?.setNativeProps(props)
}
}
const AB = Animated.createAnimatedComponent(BlurTarget),
AG = Animated.createAnimatedComponent(G),
AR = Animated.createAnimatedComponent(Rect)
const CHECK =
"M438.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 338.7 393.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"
const PLANE =
"M1.94607 9.31543C1.42353 9.14125 1.4194 8.86022 1.95682 8.68108L21.043 2.31901C21.5715 2.14285 21.8746 2.43866 21.7265 2.95694L16.2733 22.0432C16.1223 22.5716 15.8177 22.59 15.5944 22.0876L11.9999 14L17.9999 6.00005L9.99992 12L1.94607 9.31543Z"
export function VoiceNote({
onSend,
onCancel,
onStateChange,
maxDuration = 4,
theme = "system",
fontFamily = Platform.OS === "web" ? "Arial" : undefined,
radius = 32,
colors: overrides,
style,
testID,
}: VoiceNoteProps) {
const [state, setState] = useState<RecorderState>(RecorderState.IDLE),
[duration, setDuration] = useState(0),
[playback, setPlayback] = useState(0)
const seconds = useRef(0),
remaining = useRef(0)
const scheme = useColorScheme(),
dark = theme === "dark" || (theme === "system" && scheme === "dark")
const colors = {
surface: dark ? "#171717" : "#fefefe",
center: dark ? "#1a1a1e" : "#fefefe",
border: dark ? "#2d2d33" : "#E8E7EF",
actionBorder: dark ? "#ffffff0d" : "#E8E7EF",
check: dark ? "#f5f5f5" : "#334155",
send: dark ? "#f5f5f5" : "#272727",
text: dark ? "#f5f5f5" : "#282828",
recording: dark ? "#441010" : "#FEE5E4",
waveform: "#FC3229",
progress: "#ef4444",
...overrides,
}
const active = state !== RecorderState.IDLE,
recording = state === RecorderState.RECORDING,
review =
state === RecorderState.REVIEWING || state === RecorderState.PLAYING
const actionSurface = useSharedValue(colors.surface)
const actionBorder = useSharedValue(colors.actionBorder)
useEffect(() => {
const timing = { duration: 300, easing: Easing.bezier(0.4, 0, 0.2, 1) }
actionSurface.set(withTiming(colors.surface, timing))
actionBorder.set(withTiming(colors.actionBorder, timing))
}, [colors.surface, colors.actionBorder])
const actionColors = useAnimatedProps(() => ({
fill: actionSurface.get(),
stroke: actionBorder.get(),
}))
const centerWidth = useSharedValue(65),
sideProgress = useSharedValue(0),
cancelShown = useSharedValue(0),
ringProgress = useSharedValue(0),
reviewProgress = useSharedValue(0),
reviewY = useSharedValue(5),
barsProgress = useSharedValue(0)
const [barHeights] = useState(() =>
Array.from({ length: 6 }, () => [
8 + Math.random() * 6,
18 + Math.random() * 10,
12 + Math.random() * 8,
24 + Math.random() * 12,
10 + Math.random() * 6,
])
)
function transition(next: RecorderState) {
setState(next)
onStateChange?.(next)
}
function start() {
seconds.current = 0
setDuration(0)
transition(RecorderState.RECORDING)
}
function reset() {
seconds.current = 0
remaining.current = 0
setDuration(0)
setPlayback(0)
transition(RecorderState.IDLE)
}
function cancel() {
reset()
onCancel?.()
}
function play() {
remaining.current = duration
setPlayback(duration)
transition(RecorderState.PLAYING)
}
function stopPlayback() {
remaining.current = 0
setPlayback(0)
transition(RecorderState.REVIEWING)
}
function send() {
onSend?.({ duration, blob: null })
reset()
}
useEffect(() => {
if (!recording) return
const timer = setInterval(() => {
if (seconds.current >= maxDuration) transition(RecorderState.REVIEWING)
else {
seconds.current += 1
setDuration(seconds.current)
}
}, 1000)
return () => clearInterval(timer)
}, [recording, maxDuration])
useEffect(() => {
if (state !== RecorderState.PLAYING) return
const timer = setInterval(() => {
if (remaining.current <= 0) stopPlayback()
else {
remaining.current -= 1
setPlayback(remaining.current)
}
}, 1000)
return () => clearInterval(timer)
}, [state])
useEffect(() => {
centerWidth.set(withSpring(active ? 110 : 65, SPRING))
if (active) cancelShown.set(1)
sideProgress.set(
withSpring(active ? 1 : 0, SPRING, (finished) => {
if (finished && !active) cancelShown.set(0)
})
)
}, [active])
useEffect(() => {
if (recording) {
ringProgress.set(0)
ringProgress.set(
withTiming(1, {
duration: Math.max(0, maxDuration) * 1000,
easing: Easing.linear,
})
)
}
barsProgress.set(withSpring(recording ? 1 : 0, SPRING))
}, [recording, maxDuration])
useEffect(() => () => cancelAnimation(ringProgress), [])
useEffect(() => {
if (review && reviewProgress.get() === 0) reviewY.set(5)
reviewProgress.set(withSpring(review ? 1 : 0, SPRING))
reviewY.set(withSpring(review ? 0 : -5, SPRING))
}, [review])
const center = useAnimatedStyle(() => ({
width: centerWidth.get(),
left: (262 - centerWidth.get()) / 2,
}))
const cancelPosition = useAnimatedProps(() => ({
opacity: active ? sideProgress.get() : cancelShown.get(),
transform: `translate(${95 * (1 - sideProgress.get())} 0)`,
}))
const sendPosition = useAnimatedProps(() => ({
opacity: sideProgress.get(),
transform: `translate(${198 - 95 * (1 - sideProgress.get())} 0)`,
}))
const sideBlur = useAnimatedProps(() => ({
stdDeviation: 4 * (1 - sideProgress.get()),
}))
const recordingMotion = useAnimatedStyle(() => ({
opacity: barsProgress.get(),
}))
const reviewMotion = useAnimatedStyle(() => ({
opacity: reviewProgress.get(),
transform: [{ translateY: reviewY.get() }],
}))
const cancelFilter = useId().replace(/\W/g, "")
const sendFilter = useId().replace(/\W/g, "")
return (
<View
testID={testID}
style={[{ alignSelf: "center", width: 262, height: 64 }, style]}
>
<Svg
width={262}
height={64}
pointerEvents="none"
style={{ position: "absolute", overflow: "visible" }}
>
<Defs>
{[cancelFilter, sendFilter].map((id) => (
<Filter
key={id}
id={id}
x="-50%"
y="-50%"
width="200%"
height="200%"
>
<AB animatedProps={sideBlur} />
</Filter>
))}
</Defs>
<AG animatedProps={cancelPosition}>
<G filter={`url(#${cancelFilter})`}>
<AR
animatedProps={actionColors}
x={0.8}
y={0.8}
width={62.4}
height={62.4}
rx={radius}
strokeWidth={1.6}
/>
<Path
d="m41.33 22.67-18.66 18.66m0-18.66 18.66 18.66"
stroke={dark ? "#f5f5f5" : "#334155"}
strokeWidth={2.33}
strokeLinecap="round"
/>
</G>
</AG>
<AG animatedProps={sendPosition}>
<G filter={`url(#${sendFilter})`}>
<AR
animatedProps={actionColors}
x={0.8}
y={0.8}
width={62.4}
height={62.4}
rx={radius}
strokeWidth={1.6}
/>
</G>
</AG>
</Svg>
<Pressable
disabled={!active}
accessibilityRole="button"
accessibilityLabel="Cancel voice note"
onPress={cancel}
style={{ position: "absolute", left: 0, top: 0, width: 64, height: 64 }}
/>
<Animated.View
style={[
{
position: "absolute",
top: 0,
height: 64,
borderRadius: radius,
borderWidth: recording ? 0 : 1.6,
borderColor: colors.border,
backgroundColor: recording ? colors.recording : colors.center,
overflow: "hidden",
transitionProperty: ["backgroundColor", "borderColor"],
transitionDuration: 300,
transitionTimingFunction: cubicBezier(0.4, 0, 0.2, 1),
},
center,
]}
>
<ProgressRing
width={centerWidth}
progress={ringProgress}
active={recording}
color={colors.progress}
/>
<View
pointerEvents="none"
style={{
position: "absolute",
left: 0,
right: 0,
top: 18,
alignItems: "center",
}}
>
<BlurredLayer
width={28}
height={28}
active={state === RecorderState.IDLE}
initiallyVisible
blur={8}
>
<Svg
width={28}
height={28}
viewBox="0 0 24 24"
fill="none"
stroke={dark ? "#f5f5f5" : "#1e293b"}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<Rect x={9} y={2} width={6} height={12} rx={3} />
<Path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3m-4 0h8" />
</Svg>
</BlurredLayer>
</View>
<Pressable
disabled={state !== RecorderState.IDLE}
accessibilityRole="button"
accessibilityLabel="Start recording demo"
onPress={start}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
height: 64,
}}
/>
<Animated.View
pointerEvents="none"
style={[
{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 64,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 6,
},
recordingMotion,
]}
>
{barHeights.map((heights, i) => (
<WaveBar
key={i}
heights={heights}
index={i}
active={recording}
presence={barsProgress}
color={colors.waveform}
/>
))}
</Animated.View>
<Animated.View
pointerEvents={review ? "auto" : "none"}
accessibilityElementsHidden={!review}
importantForAccessibility={review ? "auto" : "no-hide-descendants"}
style={[
{
position: "absolute",
left: 0,
right: 0,
top: 0,
height: 64,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
},
reviewMotion,
]}
>
<Pressable
accessibilityRole="button"
accessibilityLabel={
state === RecorderState.PLAYING
? "Stop playback demo"
: "Play voice note demo"
}
onPress={state === RecorderState.PLAYING ? stopPlayback : play}
style={{
width: 40,
height: 40,
alignItems: "center",
justifyContent: "center",
}}
>
<View style={{ width: 22, height: 22 }}>
<BlurredLayer
width={22}
height={22}
active={state === RecorderState.REVIEWING}
blur={4}
scaleFrom={0.25}
absolute
>
<Svg
width={22}
height={22}
viewBox="0 0 24 24"
fill={dark ? "#f5f5f5" : "#1e293b"}
stroke={dark ? "#f5f5f5" : "#1e293b"}
strokeWidth={2}
strokeLinejoin="round"
>
<Path d="m6 3 14 9-14 9V3Z" />
</Svg>
</BlurredLayer>
<BlurredLayer
width={22}
height={22}
active={state === RecorderState.PLAYING}
blur={4}
scaleFrom={0.25}
absolute
>
<Svg
width={22}
height={22}
viewBox="0 0 24 24"
fill={dark ? "#f87171" : "#ef4444"}
stroke={dark ? "#f87171" : "#ef4444"}
strokeWidth={2}
>
<Rect x={3} y={3} width={18} height={18} rx={2} />
</Svg>
</BlurredLayer>
</View>
</Pressable>
<View style={{ flexDirection: "row", gap: 2, alignItems: "center" }}>
<AnimatedNumber
value={state === RecorderState.PLAYING ? playback : duration}
color={colors.text}
fontFamily={fontFamily}
/>
<Animated.Text
layout={REVIEW_LAYOUT}
style={{
fontFamily,
fontSize: 20,
fontWeight: "700",
color: colors.text,
}}
>
s
</Animated.Text>
</View>
</Animated.View>
</Animated.View>
<RightButton
active={active}
recording={recording}
checkColor={colors.check}
sendColor={colors.send}
onPress={recording ? () => transition(RecorderState.REVIEWING) : send}
/>
</View>
)
}
function RightButton({
active,
recording,
checkColor,
sendColor,
onPress,
}: {
active: boolean
recording: boolean
checkColor: string
sendColor: string
onPress: () => void
}) {
const p = useSharedValue(0)
const outerFilter = useId().replace(/\W/g, "")
useEffect(() => {
p.set(withSpring(active ? 1 : 0, SPRING))
}, [active])
const position = useAnimatedStyle(() => ({
opacity: p.get(),
transform: [{ translateX: -95 * (1 - p.get()) }],
}))
const outerBlur = useAnimatedProps(() => ({
stdDeviation: 4 * Math.max(0, 1 - p.get()),
}))
return (
<Animated.View
pointerEvents={active ? "auto" : "none"}
style={[
{ position: "absolute", left: 198, top: 0, width: 64, height: 64 },
position,
]}
>
<Pressable
disabled={!active}
accessibilityRole="button"
accessibilityLabel={
recording ? "Finish recording demo" : "Send voice note demo"
}
onPress={onPress}
style={{
width: 64,
height: 64,
alignItems: "center",
justifyContent: "center",
}}
>
<Svg width={26} height={26} style={{ overflow: "visible" }}>
<Defs>
<Filter
id={outerFilter}
x="-100%"
y="-100%"
width="300%"
height="300%"
>
<AB animatedProps={outerBlur} />
</Filter>
</Defs>
<G filter={`url(#${outerFilter})`}>
<BlurredLayer
width={26}
height={26}
active={active && recording}
blur={4}
scaleFrom={0.25}
absolute
>
<Svg
width={26}
height={26}
viewBox="0 0 448 512"
fill={checkColor}
>
<Path d={CHECK} />
</Svg>
</BlurredLayer>
<BlurredLayer
width={26}
height={26}
active={active && !recording}
blur={4}
scaleFrom={0.25}
absolute
>
<Svg width={26} height={26} viewBox="0 0 24 24" fill={sendColor}>
<Path d={PLANE} />
</Svg>
</BlurredLayer>
</G>
</Svg>
</Pressable>
</Animated.View>
)
}
function WaveBar({
heights,
index,
active,
presence,
color,
}: {
heights: number[]
index: number
active: boolean
presence: ReturnType<typeof useSharedValue<number>>
color: string
}) {
const clock = useSharedValue(0)
useAnimatedReaction(
() => active || presence.get() > 0,
(running, previous) => {
if (running === previous) return
if (running)
clock.set(
withDelay(
index * 80,
withRepeat(
withTiming(1, { duration: 1000, easing: Easing.linear }),
-1,
false
)
)
)
else {
cancelAnimation(clock)
clock.set(0)
}
}
)
useEffect(() => () => cancelAnimation(clock), [])
const motion = useAnimatedStyle(() => ({
height: waveformHeight(heights, clock.get()),
}))
return (
<Animated.View
style={[{ width: 6, borderRadius: 999, backgroundColor: color }, motion]}
/>
)
}
function waveformHeight(heights: number[], progress: number) {
"worklet"
const position = Math.min(1, Math.max(0, progress)) * (heights.length - 1)
const index = Math.min(heights.length - 2, Math.floor(position))
return (
heights[index] + (heights[index + 1] - heights[index]) * (position - index)
)
}
function ProgressRing({
width,
progress,
active,
color,
}: {
width: ReturnType<typeof useSharedValue<number>>
progress: ReturnType<typeof useSharedValue<number>>
active: boolean
color: string
}) {
const id = useId().replace(/\W/g, ""),
p = useSharedValue(0)
useEffect(() => {
p.set(withSpring(active ? 1 : 0, SPRING))
}, [active])
const box = useAnimatedProps(() => {
const w = width.get() - 4,
r = Math.min(30, w / 2),
length = 2 * (w + 60 - 4 * r) + 2 * Math.PI * r
return {
width: w,
rx: r,
strokeDasharray: [length, length],
strokeDashoffset: length * (1 - progress.get()),
}
})
const opacity = useAnimatedProps(() => ({ opacity: p.get() })),
blur = useAnimatedProps(() => ({ stdDeviation: 8 * (1 - p.get()) }))
return (
<Svg
width={110}
height={64}
pointerEvents="none"
style={{ position: "absolute", top: 0, left: 0 }}
>
<Defs>
<Filter id={id} x="-20%" y="-30%" width="140%" height="160%">
<AB animatedProps={blur} />
</Filter>
</Defs>
<AG animatedProps={opacity}>
<G filter={`url(#${id})`}>
<AR
x={2}
y={2}
height={60}
animatedProps={box}
fill="none"
stroke={color}
strokeWidth={3}
strokeLinecap="round"
/>
</G>
</AG>
</Svg>
)
}
function BlurredLayer({
active,
initiallyVisible = false,
blur = 0,
scaleFrom = 1,
width,
height,
absolute,
yIn = 0,
yOut = 0,
spring = SPRING,
onExit,
children,
}: {
active: boolean
initiallyVisible?: boolean
blur?: number
scaleFrom?: number
width: number
height: number
absolute?: boolean
yIn?: number
yOut?: number
spring?: { stiffness: number; damping: number; mass?: number }
onExit?: () => void
children: React.ReactNode
}) {
const id = useId().replace(/\W/g, ""),
p = useSharedValue(initiallyVisible ? 1 : 0),
y = useSharedValue(initiallyVisible ? 0 : yIn)
useEffect(() => {
if (active && p.get() === 0) y.set(yIn)
p.set(
withSpring(active ? 1 : 0, spring, (finished) => {
if (finished && !active && onExit) scheduleOnRN(onExit)
})
)
y.set(withSpring(active ? 0 : yOut, spring))
return () => {
cancelAnimation(p)
cancelAnimation(y)
}
}, [active, yIn, yOut])
const motion = useAnimatedProps(() => ({
opacity: Math.max(0, Math.min(1, p.get())),
transform: `translate(${width / 2} ${height / 2 + y.get()}) scale(${scaleFrom + (1 - scaleFrom) * p.get()}) translate(${-width / 2} ${-height / 2})`,
}))
const filter = useAnimatedProps(() => ({
stdDeviation: blur * Math.max(0, 1 - p.get()),
}))
return (
<Svg
width={width}
height={height}
pointerEvents="none"
style={{
position: absolute ? "absolute" : "relative",
overflow: "visible",
}}
>
<Defs>
<Filter id={id} x="-100%" y="-60%" width="300%" height="220%">
<AB animatedProps={filter} />
</Filter>
</Defs>
<AG animatedProps={motion}>
<G filter={`url(#${id})`}>{children}</G>
</AG>
</Svg>
)
}
export function AnimatedNumber({
value,
color = "#282828",
fontFamily,
}: {
value: number
color?: string
fontFamily?: string
}) {
const [motion, setMotion] = useState({ value, direction: 0 })
if (motion.value !== value)
setMotion({ value, direction: value > motion.value ? 1 : -1 })
const digits = String(value).split("")
return (
<View style={{ flexDirection: "row", gap: 4 }}>
{digits.map((char, i) => (
<Digit
key={`${i}-${digits.length}`}
value={char}
direction={motion.direction}
color={color}
fontFamily={fontFamily}
/>
))}
</View>
)
}
function Digit({
value,
direction,
color,
fontFamily,
}: {
value: string
direction: number
color: string
fontFamily?: string
}) {
const last = useRef(value),
serial = useRef(1),
[layers, setLayers] = useState([
{ id: 0, char: value, exit: false, initial: true },
])
useEffect(() => {
if (last.current === value) return
last.current = value
setLayers((current) => [
...current.map((layer) => ({ ...layer, exit: true })),
{ id: serial.current++, char: value, exit: false, initial: false },
])
}, [value])
const remove = useCallback(
(id: number) =>
setLayers((current) => current.filter((layer) => layer.id !== id)),
[]
)
return (
<View style={{ width: 12, height: 28 }}>
{layers.map((layer) => (
<BlurredLayer
key={layer.id}
width={12}
height={28}
absolute
active={!layer.exit}
initiallyVisible={layer.initial}
blur={2}
scaleFrom={0.5}
yIn={direction > 0 ? 8 : -8}
yOut={direction > 0 ? -8 : 8}
spring={DIGIT_SPRING}
onExit={() => remove(layer.id)}
>
<SvgText
x={6}
y={21}
textAnchor="middle"
fontFamily={fontFamily}
fontSize={20}
fontWeight="700"
fill={color}
>
{layer.char}
</SvgText>
</BlurredLayer>
))}
</View>
)
}
export default VoiceNote
Give the gestures and motion a run on a real Android phone.