Interaction
Waveform Scrub
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.
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.