Skip to content
pixelfor Expo
Ctrl K

Interaction

Waveform Scrub

An expressive interaction rebuilt for touch

Try it on your phone

Waveform Scrub

Scan to open Waveform Scrub 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.

waveform-scrub.in-progress.tsx

import React, { useEffect, useId, useState } from "react"
import {
  Platform,
  Pressable,
  View,
  useColorScheme,
  useWindowDimensions,
  type StyleProp,
  type ViewStyle,
} from "react-native"
import Animated, {
  useAnimatedProps,
  cubicBezier,
  Easing,
  useAnimatedStyle,
  useFrameCallback,
  useSharedValue,
  withTiming,
} from "react-native-reanimated"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import { scheduleOnRN } from "react-native-worklets"
import Svg, {
  ClipPath,
  Defs,
  G,
  Path,
  Pattern,
  Rect,
  Filter,
  FeGaussianBlur,
  FeDropShadow,
} from "react-native-svg"
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)
const AR = Animated.createAnimatedComponent(Rect),
  AG = Animated.createAnimatedComponent(G)
const DEFAULT_WAVEFORM = [
  4, 7, 9, 6, 11, 14, 12, 8, 5, 10, 15, 13, 11, 9, 6, 10, 12, 9, 7, 5, 8, 12,
  10, 7, 6, 9, 13, 11, 8, 6, 5, 11, 8, 6, 5, 11, 8, 6, 5, 8, 5, 10, 15, 13, 11,
  9,
]
export interface WaveformScrubProps {
  duration?: number
  fileName?: string
  waveformHeights?: number[]
  onSeek?: (seconds: number) => void
  onPlayingChange?: (playing: boolean) => void
  theme?: "light" | "dark" | "system"
  width?: number
  radius?: number
  compact?: boolean
  fontFamily?: string
  colors?: Partial<{
    surface: string
    track: string
    inactive: string
    active: string
    text: string
    border: string
  }>
  style?: StyleProp<ViewStyle>
  testID?: string
}
export function WaveformScrub({
  duration = 30,
  fileName = "Mom.mp3",
  waveformHeights = DEFAULT_WAVEFORM,
  onSeek,
  onPlayingChange,
  theme = "system",
  width = 440,
  radius = 24,
  compact,
  fontFamily = Platform.OS === "web" ? "Arial" : undefined,
  colors: overrides,
  style,
  testID,
}: WaveformScrubProps) {
  const [playing, setPlaying] = useState(false),
    [finished, setFinished] = useState(false),
    [seconds, setSeconds] = useState(duration),
    [trackWidth, setTrackWidth] = useState(width - 16)
  const [cardOrigin, setCardOrigin] = useState({ x: 0, y: 40 })
  const [trackOrigin, setTrackOrigin] = useState({ x: 8, y: 58 })
  const scheme = useColorScheme(),
    window = useWindowDimensions(),
    dark = theme === "dark" || (theme === "system" && scheme === "dark"),
    small = compact ?? window.width < 640,
    id = useId().replace(/\W/g, "")
  const colors = {
    surface: dark ? "#171717" : "#f5f5f5",
    track: dark ? "#262626" : "#fefefe",
    inactive: dark ? "#a3a3a3" : "#e5e5e5",
    active: dark ? "#f5f5f5" : "#262626",
    text: dark ? "#f5f5f5" : "#737373",
    border: dark ? "#0a0a0ab3" : "#fefefeb3",
    ...overrides,
  }
  const time = useSharedValue(0),
    running = useSharedValue(false),
    elapsed = useSharedValue(0),
    phase = useSharedValue(0),
    lastSecond = useSharedValue(duration),
    dragStart = useSharedValue(0)
  const safeDuration = Math.max(0.001, duration),
    range = Math.max(1, trackWidth - 19.2)
  const inactiveFill = useSharedValue(colors.inactive),
    activeFill = useSharedValue(colors.active),
    markerFill = useSharedValue(dark ? "#fff" : "#1c1c1e")
  const stripeOpacity = useSharedValue(dark ? 0.1 : 0.04)
  useEffect(() => {
    stripeOpacity.set(
      withTiming(dark ? 0.1 : 0.04, {
        duration: 150,
        easing: Easing.bezier(0.4, 0, 0.2, 1),
      })
    )
  }, [dark])
  const stripeAlpha = useAnimatedProps(() => ({ opacity: stripeOpacity.get() }))
  useEffect(() => {
    const timing = { duration: 300, easing: Easing.bezier(0.4, 0, 0.2, 1) }
    inactiveFill.set(withTiming(colors.inactive, timing))
    activeFill.set(withTiming(colors.active, timing))
    markerFill.set(withTiming(dark ? "#fff" : "#1c1c1e", timing))
  }, [colors.inactive, colors.active, dark])
  const inactiveColor = useAnimatedProps(() => ({ fill: inactiveFill.get() }))
  const activeColor = useAnimatedProps(() => ({ fill: activeFill.get() }))
  function finish() {
    if (running.get() || time.get() < safeDuration) return
    setFinished(true)
    setPlaying(false)
    onPlayingChange?.(false)
  }
  useEffect(() => {
    lastSecond.set(Math.round(safeDuration - time.get()))
    setSeconds(Math.round(safeDuration - time.get()))
  }, [duration])
  useFrameCallback((frame) => {
    const delta = frame.timeSincePreviousFrame ?? 0
    phase.set((phase.get() + (delta / 500) * 4) % 4)
    if (running.get()) {
      elapsed.set(elapsed.get() + delta)
      if (elapsed.get() >= 100) {
        const steps = Math.floor(elapsed.get() / 100)
        elapsed.set(elapsed.get() % 100)
        time.set(Math.min(safeDuration, time.get() + steps * 0.1))
        if (time.get() >= safeDuration) {
          running.set(false)
          scheduleOnRN(finish)
        }
      }
    }
    const remaining = Math.round(safeDuration - time.get())
    if (remaining !== lastSecond.get()) {
      lastSecond.set(remaining)
      scheduleOnRN(setSeconds, remaining)
    }
  })
  function toggle() {
    if (time.get() >= safeDuration) time.set(0)
    setFinished(false)
    const next = !running.get()
    running.set(next)
    elapsed.set(0)
    setPlaying(next)
    onPlayingChange?.(next)
  }
  function pause() {
    if (running.get()) return
    setFinished(false)
    setPlaying(false)
    onPlayingChange?.(false)
  }
  const pan = Gesture.Pan()
    .minDistance(3)
    .onStart(() => {
      running.set(false)
      dragStart.set(time.get())
      scheduleOnRN(pause)
    })
    .onUpdate((e) => {
      time.set(
        Math.max(
          0,
          Math.min(
            safeDuration,
            dragStart.get() + (e.translationX / range) * safeDuration
          )
        )
      )
    })
    .onEnd(() => {
      scheduleOnRN(setFinished, time.get() >= safeDuration)
      if (onSeek) scheduleOnRN(onSeek, time.get())
    })
  const clip = useAnimatedProps(() => ({
      width: (time.get() / safeDuration) * range,
    })),
    stripeClip = useAnimatedProps(() => ({
      width: (time.get() / safeDuration) * trackWidth,
    })),
    stripes = useAnimatedProps(() => ({
      transform: `translate(${phase.get()} 0)`,
    })),
    marker = useAnimatedProps(() => ({
      fill: markerFill.get(),
      transform: `translate(${9.6 + (time.get() / safeDuration) * range - 10} -88)`,
    }))
  const position = useAnimatedStyle(() => ({
    left: Math.max(
      cardOrigin.x,
      Math.min(
        cardOrigin.x + trackOrigin.x + trackWidth - 44,
        cardOrigin.x +
          trackOrigin.x +
          9.6 -
          10 +
          11 -
          22 +
          (time.get() / safeDuration) * range
      )
    ),
  }))
  const bars = waveformHeights.map((height, i) => (
    <Rect
      key={i}
      x={
        9.6 +
        (i * (range - (small ? 4 : 3))) /
          Math.max(1, waveformHeights.length - 1)
      }
      y={34 - height * 0.8}
      width={small ? 4 : 3}
      height={height * 1.6}
      rx={2}
    />
  ))
  return (
    <View
      pointerEvents="box-none"
      style={{
        alignSelf: "center",
        width: Math.min(width, window.width - 32),
        paddingVertical: 40,
      }}
    >
      <Animated.View
        testID={testID}
        pointerEvents="box-none"
        onLayout={({ nativeEvent }) => {
          const { x, y } = nativeEvent.layout
          setCardOrigin((old) => (old.x === x && old.y === y ? old : { x, y }))
        }}
        style={[
          {
            alignSelf: "center",
            zIndex: 1,
            width: Math.min(width, window.width - 32),
            borderRadius: radius,
            backgroundColor: colors.surface,
            transitionProperty: "backgroundColor",
            transitionDuration: 300,
            paddingHorizontal: 8,
            paddingTop: 16,
            paddingBottom: 12,
            boxShadow: "0px 1px 2px rgba(0,0,0,.05)",
          },
          style,
        ]}
      >
        <View
          pointerEvents="box-none"
          style={{
            flexDirection: "row",
            alignItems: "center",
            justifyContent: "space-between",
            paddingLeft: 8,
            paddingRight: 16,
            marginBottom: 16,
          }}
        >
          <View
            pointerEvents="box-none"
            style={{
              flexDirection: "row",
              alignItems: "center",
              gap: 8,
              flex: 1,
              overflow: "hidden",
            }}
          >
            <Pressable
              accessibilityRole="button"
              accessibilityLabel={
                finished ? "Replay" : playing ? "Pause" : "Play"
              }
              onPress={toggle}
              hitSlop={12}
            >
              <PlaybackIcon
                mode={finished ? "replay" : playing ? "pause" : "play"}
                color={colors.text}
              />
            </Pressable>
            <Animated.Text
              pointerEvents="none"
              numberOfLines={1}
              style={{
                fontFamily,
                fontSize: small ? 17 : 19,
                letterSpacing: -(small ? 17 : 19) * 0.025,
                color: colors.text,
                transitionProperty: "color",
                transitionDuration: 300,
                transitionTimingFunction: cubicBezier(0.4, 0, 0.2, 1),
                flexShrink: 1,
              }}
            >
              {fileName}
            </Animated.Text>
          </View>
          <Animated.Text
            pointerEvents="none"
            style={{
              fontFamily,
              fontSize: small ? 18 : 20,
              fontWeight: "600",
              color: colors.text,
              transitionProperty: "color",
              transitionDuration: 300,
              transitionTimingFunction: cubicBezier(0.4, 0, 0.2, 1),
              fontVariant: ["tabular-nums"],
            }}
          >
            {seconds}s
          </Animated.Text>
        </View>
        <Animated.View
          pointerEvents="none"
          onLayout={({ nativeEvent }) => {
            const { x, y, width } = nativeEvent.layout
            setTrackWidth(width)
            setTrackOrigin((old) =>
              old.x === x && old.y === y ? old : { x, y }
            )
          }}
          style={{
            height: 68,
            borderRadius: 24,
            borderWidth: 1.6,
            borderColor: colors.border,
            backgroundColor: colors.track,
            transitionProperty: ["backgroundColor", "borderColor"],
            transitionDuration: 300,
            boxShadow: "inset 0px 1px 4px rgba(0,0,0,.02)",
          }}
        >
          <Svg width={trackWidth} height={68} style={{ overflow: "visible" }}>
            <Defs>
              <Filter
                id={`${id}headShadow`}
                x="-40"
                y="-40"
                width="102"
                height="102"
                filterUnits="userSpaceOnUse"
              >
                <FeDropShadow
                  dx={0}
                  dy={4}
                  stdDeviation={10}
                  floodColor="#000"
                  floodOpacity={0.3}
                />
              </Filter>
              <Filter
                id={`${id}stemShadow`}
                x="-16"
                y="10"
                width="54"
                height="170"
                filterUnits="userSpaceOnUse"
              >
                <FeDropShadow
                  dx={0}
                  dy={4}
                  stdDeviation={3}
                  floodColor="#000"
                  floodOpacity={0.1}
                />
              </Filter>
              <ClipPath id={`${id}played`}>
                <AR x={9.6} y={0} height={68} animatedProps={clip} />
              </ClipPath>
              <ClipPath id={`${id}stripe`}>
                <AR x={0} y={0} height={68} animatedProps={stripeClip} />
              </ClipPath>
              <Pattern
                id={`${id}pattern`}
                width={4}
                height={4}
                patternUnits="userSpaceOnUse"
              >
                <Path
                  d="M-1 1 1-1M0 4 4 0M3 5 5 3"
                  stroke={dark ? "#fff" : "#000"}
                  strokeWidth={1.4}
                />
              </Pattern>
            </Defs>
            <AG clipPath={`url(#${id}stripe)`} animatedProps={stripeAlpha}>
              <AG animatedProps={stripes}>
                <Rect
                  x={-4}
                  y={0}
                  width={trackWidth + 8}
                  height={68}
                  fill={`url(#${id}pattern)`}
                />
              </AG>
            </AG>
            <AG animatedProps={inactiveColor}>{bars}</AG>
            <AG clipPath={`url(#${id}played)`} animatedProps={activeColor}>
              {bars}
            </AG>
            <AG animatedProps={marker}>
              <Path
                d="M3.3 0H18.7L22 3.6V10.8L13.2 18H8.8L0 10.8V3.6Z"
                filter={`url(#${id}headShadow)`}
              />
              <Rect
                x={9}
                y={18}
                width={4}
                height={142}
                filter={`url(#${id}stemShadow)`}
                rx={2}
              />
            </AG>
          </Svg>
        </Animated.View>
      </Animated.View>
      <GestureDetector gesture={pan}>
        <Animated.View
          accessible
          accessibilityRole="adjustable"
          accessibilityLabel="Playback position"
          accessibilityValue={{
            min: 0,
            max: duration,
            now: duration - seconds,
          }}
          style={[
            {
              position: "absolute",
              zIndex: 0,
              top: cardOrigin.y + trackOrigin.y - 88,
              width: 44,
              height: 160,
            },
            position,
          ]}
        />
      </GestureDetector>
    </View>
  )
}
function PlaybackIcon({
  mode,
  color,
}: {
  mode: "play" | "pause" | "replay"
  color: string
}) {
  return (
    <View style={{ width: 22, height: 22 }}>
      {(["play", "pause", "replay"] as const).map((kind) => (
        <IconLayer
          key={kind}
          kind={kind}
          active={mode === kind}
          color={color}
        />
      ))}
    </View>
  )
}
function iconSpring(p: number) {
  "worklet"
  const t = 9.233413476 * p
  return p >= 1 ? 1 : 1 - Math.exp(-t) * (1 + t)
}
function IconLayer({
  kind,
  active,
  color,
}: {
  kind: "play" | "pause" | "replay"
  active: boolean
  color: string
}) {
  const progress = useSharedValue(active ? 1 : 0)
  const id = useId().replace(/\W/g, "")
  useEffect(() => {
    progress.set(
      withTiming(active ? 1 : 0, { duration: 300, easing: iconSpring })
    )
  }, [active])
  const motion = useAnimatedProps(() => ({
    opacity: progress.get(),
    transform: `translate(12 12) scale(${0.25 + 0.75 * progress.get()}) translate(-12 -12)`,
  }))
  const blur = useAnimatedProps(() => ({
    stdDeviation: 4 * (1 - progress.get()),
  }))
  return (
    <Svg
      width={22}
      height={22}
      viewBox="0 0 24 24"
      pointerEvents="none"
      style={{ position: "absolute", overflow: "visible" }}
    >
      <Defs>
        <Filter id={id} x="-75%" y="-75%" width="250%" height="250%">
          <AB animatedProps={blur} />
        </Filter>
      </Defs>
      <AG animatedProps={motion}>
        <G
          filter={`url(#${id})`}
          fill={kind === "replay" ? "none" : color}
          stroke={kind === "replay" ? color : undefined}
          strokeWidth={2}
          strokeLinecap="round"
          strokeLinejoin="round"
        >
          {kind === "play" ? (
            <Path d="M6 4v16a1 1 0 0 0 1.524 .852l13 -8a1 1 0 0 0 0 -1.704l-13 -8a1 1 0 0 0 -1.524 .852z" />
          ) : kind === "pause" ? (
            <>
              <Path d="M9 4h-2a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h2a2 2 0 0 0 2 -2v-12a2 2 0 0 0 -2 -2z" />
              <Path d="M17 4h-2a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h2a2 2 0 0 0 2 -2v-12a2 2 0 0 0 -2 -2z" />
            </>
          ) : (
            <>
              {[
                "M9 4.55a8 8 0 0 1 6 14.9m0 -4.45v5h5",
                "M5.63 7.16l0 .01",
                "M4.06 11l0 .01",
                "M4.63 15.1l0 .01",
                "M7.16 18.37l0 .01",
                "M11 19.94l0 .01",
              ].map((d) => (
                <Path key={d} d={d} />
              ))}
            </>
          )}
        </G>
      </AG>
    </Svg>
  )
}
export default WaveformScrub

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

Back to Watermelon UI