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

Watermelon UI

Our Expo component

React Native Web

Component 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.

Back to Watermelon UI