React Native Wave Animation with Skia and Reanimated
By Native Animation · Updated
A React Native wave animation can represent decorative water, a changing audio level, or a recorded waveform; those need different data models. For a decorative wave, let Reanimated advance one phase and pass a derived path directly to Skia. For audio, connect measured levels or decoded samples rather than presenting a sine loop as a real signal.
Choose water, audio, or decoration
| Pattern | Input | What it communicates |
|---|---|---|
| Decorative water edge | Phase, amplitude, wavelength | Atmosphere; no audio or measured progress |
| Voice activity indicator | Current normalized level | Incoming loudness, not a history of samples |
| Recorded waveform | Decoded or precomputed sample envelope | Signal shape across a known duration |
| Fluid loader | Loading state and visual clock | Activity; not a completion percentage unless connected |
The example below draws a filled sine-wave edge. It is not fluid simulation, refraction, audio capture, or an audio player. For measured completion, use the progress bar guide rather than inferring progress from a decorative loop.
Draw a responsive wave with Skia
Use Expo SDK 58, React Native 0.88.0, Skia 2.14.0, Reanimated 4.7.1, and Worklets 0.13.0. Install SDK-compatible dependencies with npx expo install @shopify/react-native-skia react-native-reanimated react-native-worklets (bunx expo install in Bun projects). Check the SDK 58 Skia setup.
Save this complete file as WaveExample.tsx outside the Expo Router route tree. Render it in a safe-area screen. On web, initialize CanvasKit before mounting it using the Skia web setup; keep that web bootstrap out of native imports.
import { useEffect, useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import { Canvas, Path, Skia } from '@shopify/react-native-skia';
import {
cancelAnimation, Easing, useDerivedValue, useReducedMotion,
useSharedValue, withRepeat, withTiming,
} from 'react-native-reanimated';
export function WaveExample() {
const [width, setWidth] = useState(0);
const [playing, setPlaying] = useState(true);
const reduced = useReducedMotion();
const phase = useSharedValue(0);
const height = 160;
useEffect(() => {
cancelAnimation(phase);
if (!playing || reduced) return;
phase.set(0);
phase.set(withRepeat(withTiming(Math.PI * 2, {
duration: 2400, easing: Easing.linear,
}), -1, false));
return () => cancelAnimation(phase);
}, [playing, reduced, phase]);
const path = useDerivedValue(() => {
const next = Skia.PathBuilder.Make();
const baseline = height * 0.5;
const amplitude = 12;
const samples = 64;
for (let index = 0; index <= samples; index += 1) {
const x = width * index / samples;
const y = baseline + amplitude * Math.sin(index / samples * Math.PI * 4 + phase.get());
if (index === 0) next.moveTo(x, y);
else next.lineTo(x, y);
}
next.lineTo(width, height);
next.lineTo(0, height);
next.close();
return next.detach();
}, [width, height]);
return (
<View style={styles.screen}>
<Text style={styles.title}>A quiet tide</Text>
<View style={styles.wave} accessible={false}
accessibilityElementsHidden importantForAccessibility="no-hide-descendants"
onLayout={(event) => setWidth(event.nativeEvent.layout.width)}>
{width > 0 && <Canvas style={{ width, height }}>
<Path path={path} color="#4862C7" />
</Canvas>}
</View>
<Text style={styles.note}>Decorative wave. No microphone or playback data.</Text>
<Button title={playing ? 'Pause wave' : 'Restart wave'}
onPress={() => setPlaying((value) => !value)} />
</View>
);
}
const styles = StyleSheet.create({
screen: { padding: 24, gap: 20, backgroundColor: '#FFFFFF' },
title: { fontSize: 26, color: '#231D2B' },
wave: { height: 160, overflow: 'hidden', borderRadius: 20, backgroundColor: '#E8EDFF' },
note: { fontSize: 16, lineHeight: 24, color: '#494151' },
});Phase versus amplitude
Phase controls horizontal travel; amplitude controls the height of the crest above the baseline. The example uses two full cycles across the measured container and a fixed decorative amplitude of 12 points. Increasing amplitude does not make it more accurate as an audio visualization. The fixed 64 segments bound path construction work as the screen grows; profile before increasing the detail or adding layers.
Build the geometry with Skia.PathBuilder.Make() and return its immutable path with detach(). This builder API works with Expo SDK 58 and Expo SDK 57; mutable SkPath methods are unavailable in the current Skia release.
Drive the path with one phase
A derived value reads phase and builds the path on the UI thread. Skia accepts that value directly as the Path's path prop; no React state update runs on each animation frame. React updates width only after layout and playing only after a button press.
The linear timing animation advances phase from zero to one complete turn. The end and beginning draw the same wave, so the repeated animation can loop without reversing travel. This is a visual periodic boundary, not a background timer for a product deadline.
Pause, restart, and lifecycle
Pause cancels phase and freezes the current drawing. Restart deliberately begins at phase zero; it does not promise seamless resume. Cleanup cancels the loop on unmount. If your navigation keeps a screen mounted when it loses focus, connect focus and application activity to the playing policy so an invisible canvas does not keep animating.
Connect real audio instead of simulated data
A level is not a waveform
An input meter can supply a normalized value from zero to one. Clamp invalid values, map the meter's documented scale, and smooth fast attacks separately from slower releases. A decibel reading is not already a normalized linear amplitude. Set the drawing amplitude from the processed level; do not regenerate a fictional audio history from that number.
Voice Orb accepts source="external" and a level shared value for an existing meter pipeline. This complete fixture demonstrates the contract without requesting microphone access. After copying the folder next to the screen:
import { Button, View } from 'react-native';
import { useSharedValue } from 'react-native-reanimated';
import VoiceOrb from './voice-orb/VoiceOrb';
export function ExternalLevelFixture() {
const level = useSharedValue(0);
return <View>
<VoiceOrb source="external" level={level} size={120} />
<Button title="Set sample level" onPress={() => level.set(0.65)} />
<Button title="Clear sample level" onPress={() => level.set(0)} />
</View>;
}Activate the orb to start reading the external level; setting a fixture value does not itself start its source. In an app, replace the two buttons with your meter updates. Voice Orb also supports microphone and simulated modes. Microphone mode needs permission and web HTTPS or localhost, and permission denial starts a simulated fallback. Do not tell users that fallback motion proves recording is active. Use the permission callback and a clear host status for that distinction.
Playback requires a real clock and samples
Waveform Voice Message uses a deterministic generated bar envelope and a simulated playback timeline. Its duration is simulated seconds, and its ref exposes play, pause, seekTo, cycleSpeed, and reset. It has no public audio URL, decoded sample array, or controlled playback-position prop. Connecting a real player therefore requires adapting the copied source; assigning an invented audioUri prop will not do it.
A real voice-message integration needs duration, a sample envelope, authoritative player time, seek requests and failures, buffering, and completion. Keep one player clock authoritative. A UI loop and an audio clock running independently will drift, even when their initial durations match.
Choose a catalog wave animation
Choose Voice Orb for an audio-level-driven shape and Waveform Voice Message for the simulated voice-note interaction described above. Liquid Orb Loader is a procedural fluid loading treatment with a completion state; it is a liquid alternative, not a water surface or audio visualizer. Check each README before adding it with the Native Animation CLI.
Each published piece is reviewed on physical iPhone and Android devices with Expo Go before release. Those catalog reviews do not measure the performance of your custom wave, recording pipeline, or player integration.
Accessibility and performance
The wave is decorative, so its canvas subtree is hidden from native accessibility navigation. The separate caption makes its purpose explicit and the button offers a way to stop motion. With Reduce Motion enabled at startup, the example never starts its loop. The hook does not automatically observe preference changes; add a host subscription to AccessibilityInfo.reduceMotionChanged if you need that behavior.
Keep meaningful audio state in ordinary controls and labels outside the canvas. A changing shape cannot be the only indication of listening, permission denial, buffering, or an error. Avoid announcing frame-level amplitudes. Voice Orb removes level-driven deformation under reduced motion but retains quiet breathing and color feedback; do not describe it as fully static.
A small path is cheaper to reason about than layered full-screen shaders, but no FPS claim follows from this code. Bound geometry and transparency, stop offscreen work, and profile on your target iPhone, Android devices, and web browser. For material blur instead of a liquid edge, see the liquid glass collection.
FAQ
How do I create a wave animation in React Native?
Measure a canvas, derive a sine-wave path from one animated phase, and pass it to Skia. Add an explicit pause policy and keep the shape decorative unless it is connected to real data.
Is an animated waveform the same as real audio?
No. Generated bars and a simulated timeline can demonstrate interaction without playing sound. Real playback needs decoded or precomputed samples and synchronization with the actual player clock.
Can I use Reanimated without Skia?
Yes, for effects made from ordinary views, such as a row of level bars. Skia is useful when you need a continuous custom path or shader rather than rectangular elements.
Does the wave work on web?
The drawing can run on web after CanvasKit is initialized. Web bootstrap, audio permission, and native module setup are separate responsibilities; a working browser preview does not exercise native microphone permissions.