Expanding rings that bend an image as they cross it, driven by touch. Skia's RuntimeEffect
runs the shader, Reanimated supplies the clock and the ripple buffer, Gesture Handler feeds it
touches. Everything stays on the UI thread, so there is no bridge traffic per frame.
For the technique itself, the displacement maths and when the effect is worth its cost, see the ripple-refraction principle in the ui area. This is the React Native recipe.
The Shader
Declare the ripple buffer as a uniform array. Skia supports array uniforms, and it hands the
shader pixel coordinates rather than normalised ones, so divide by size first.
import { Skia } from "@shopify/react-native-skia";
export const RIPPLE_COUNT = 12;
export const rippleSource = Skia.RuntimeEffect.Make(`
uniform shader image;
uniform float2 size;
uniform float time;
uniform float4 ripples[${RIPPLE_COUNT}]; // xy = origin, z = birth, w = strength
const float LIFE = 2.6;
const float REACH = 0.75;
const float THICK = 500.0;
const float AMP = 0.05;
half4 main(float2 xy) {
float2 uv = xy / size;
float aspect = size.x / size.y;
float2 offset = float2(0.0);
for (int i = 0; i < ${RIPPLE_COUNT}; i++) {
float4 r = ripples[i];
float age = time - r.z;
if (r.w <= 0.0 || age < 0.0 || age > LIFE) { continue; }
float2 d = (uv - r.xy) * float2(aspect, 1.0);
float dist = length(d);
if (dist < 1e-5) { continue; }
float t = age / LIFE;
float band = dist - REACH * pow(t, 0.7);
float envelope = exp(-band * band * THICK);
// one crest and one trough, the profile of a lens
float lens = band * envelope * sqrt(2.0 * THICK) / 0.606;
float force = lens
* pow(1.0 - t, 1.1) * r.w
* (1.0 / (1.0 + dist * 0.5))
* smoothstep(0.0, 0.08, t)
* smoothstep(0.0, 0.06, dist);
offset += normalize(d) / float2(aspect, 1.0) * force * AMP;
}
// clamp so a displaced sample never falls outside the image
return image.eval(clamp(xy + offset * size, float2(0.0), size));
}`)!;
RuntimeEffect.Make returns null when the SkSL fails to compile, and the non-null assertion
turns that into a crash at import time. During development, branch on it and log instead.
The Component
An ImageShader child becomes the uniform shader image. useClock drives time, and
useDerivedValue rebuilds the uniforms on the UI thread.
const ripples = useSharedValue<number[]>(new Array(RIPPLE_COUNT * 4).fill(0));
const cursor = useSharedValue(0);
const clock = useClock();
const addRipple = useCallback((x: number, y: number, strength: number) => {
"worklet";
const next = [...ripples.value];
const i = cursor.value * 4;
next[i] = x / width;
next[i + 1] = y / height;
next[i + 2] = clock.value / 1000;
next[i + 3] = strength;
ripples.value = next;
cursor.value = (cursor.value + 1) % RIPPLE_COUNT;
}, [ripples, cursor, clock, width, height]);
const uniforms = useDerivedValue(() => ({
size: vec(width, height),
time: clock.value / 1000,
ripples: ripples.value,
}));
<Canvas style={StyleSheet.absoluteFill}>
<Fill>
<Shader source={rippleSource} uniforms={uniforms}>
<ImageShader image={image} fit="cover" rect={{ x: 0, y: 0, width, height }} />
</Shader>
</Fill>
</Canvas>
Keep the text and buttons as ordinary React Native views layered above the Canvas, so they
stay crisp while the artwork bends underneath.
Touch And Idle Motion
A pan gesture with minDistance(0) catches both taps and drags. Throttle the move handler, or
one swipe fills the entire ring buffer and the oldest ripples die before they are seen.
const gesture = Gesture.Pan()
.minDistance(0)
.onBegin((e) => { "worklet"; addRipple(e.x, e.y, 1); })
.onUpdate((e) => {
"worklet";
if (clock.value - lastMove.value < 180) return;
lastMove.value = clock.value;
addRipple(e.x, e.y, 0.8);
});
Gesture Handler 3 deprecates this builder in favour of hooks such as usePanGesture({ minDistance, onBegin, onUpdate }), re-exported from the package root. Check which major version your Expo SDK
pins before writing either form. The builder still works in 3.x but warns.
Use an idle heartbeat, a ripple every 1.8 seconds or so with no touch behind it, to develop and
test the effect without driving gestures. Do not ship it. A ripple nobody asked for is decoration,
and it keeps the shader running on every frame of an idle screen, which costs battery for nothing.
The effect should answer a touch. Gate the heartbeat behind a development flag, and gate the
gesture handlers behind AccessibilityInfo.isReduceMotionEnabled.
Setup Gotchas
- Reanimated 4 needs
react-native-workletsas a peer, plusreact-native-worklets/plugininbabel.config.js. Without it the worklets never run and nothing animates. - Assets resolve relative to the file.
require("./assets/painting.jpg")fails from a component that is not a sibling ofassets. Metro reports this as a resolution error naming the file twice. - Skia needs a native build. A dev client via
npx expo run:ios, not Expo Go. - Emoji as an icon. iOS resolves characters such as
U+2733to the colour emoji font, and aU+FE0Esuffix does not override it. Draw the glyph as a SkiaPathinstead.
Debugging A Stale Bundle
If the simulator keeps rendering old JS after an edit, confirm what Metro is actually serving before you touch the code:
curl -s "http://localhost:8081/index.bundle?platform=ios&dev=true" | grep -c "some-string-from-your-edit"
A hit means the bundle is current and the app is the stale part. Reinstalling the app may not
fix that; re-running npx expo run:ios does. A miss means Metro is serving a different project
root, usually because an earlier instance still holds port 8081. Kill it by port, since a
process-name match often misses it:
lsof -ti :8081 | xargs -r kill -9
Then restart with npx expo start --clear.