Skip to content

Ripple Refraction (React Native)

A React Native guide for coding agents. Also covers skia ripple, water ripple react native, runtime effect, sksl shader, image displacement, touch ripple expo, and 1 more.

Show all 7 aliases

skia ripple, water ripple react native, runtime effect, sksl shader, image displacement, touch ripple expo, skia image shader

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-worklets as a peer, plus react-native-worklets/plugin in babel.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 of assets. 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+2733 to the colour emoji font, and a U+FE0E suffix does not override it. Draw the glyph as a Skia Path instead.

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.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-react-native-guide({ topic: "react-native-ripple-refraction" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems