Skip to content

Ripple Refraction

A UI principle for coding agents. Also covers water ripple, refraction, displacement shader, liquid distortion, touch ripple, shader onboarding, and 1 more.

Show all 7 aliases

water ripple, refraction, displacement shader, liquid distortion, touch ripple, shader onboarding, image distortion

A pointer-driven effect where expanding rings bend an image as they travel across it, the way a dropped stone bends the reflection on water. It suits full-bleed onboarding and hero screens where the artwork is the whole surface and you want it to respond to touch.

The interaction layer stays flat: text and buttons sit above the distorted image, untouched. Bending your own copy is illegible, not atmospheric.

When It Earns Its Place

Use it when the image IS the screen. A full-bleed onboarding background, a splash, a hero that holds attention for a few seconds. The payoff is that a static picture starts answering the user's touch.

Skip it everywhere else:

  • Behind dense UI. Motion under a form or a list competes with the task.
  • On content the user must read or judge. Product photos, avatars, charts. Distorting these misrepresents them.
  • As a page-load flourish. A ripple nobody caused is decoration; the effect is worth its cost only when it answers input.
  • On low-end hardware without a fallback. It runs a fragment shader every frame.

The Displacement Profile

Every ripple is four numbers: origin x, origin y, birth time, strength. Keep a fixed ring buffer of them, 8 to 16, and hand the whole buffer to the shader as one uniform. Each frame the shader walks the live ripples, accumulates a displacement per pixel, then samples the image at the displaced position.

The profile that reads as water is a lens, one crest and one trough, not a sine train:

band     = dist - radius              // signed distance to the ring
envelope = exp(-band * band * THICK)  // confine the effect to the ring
lens     = band * envelope * sqrt(2 * THICK) / 0.606

The sqrt(2 * THICK) / 0.606 term normalises the peak to roughly 1, so AMP stays the only amplitude control when you retune thickness. A sine wave inside the band instead produces concentric smearing that reads as a video artefact.

Four constants control the whole feel:

ConstantValueEffect
LIFE2.6ripple lifetime in seconds
REACH0.75how far the front travels, in uv units
THICK500higher is a thinner ring
AMP0.05bend strength

Four more terms keep it physical:

  • radius = REACH * pow(t, 0.7) moves fast at birth and coasts, like a real wavefront.
  • decay = pow(1 - t, 1.1) fades it over its life.
  • falloff = 1 / (1 + dist * 0.5) spreads energy thinner as the ring grows.
  • birth and core masks (smoothstep) stop it punching at frame zero or collapsing at the origin.

Rings Must Stay Circular

Measure distance in aspect-corrected space, then map the displacement back:

vec2 d = (uv - origin) * vec2(aspect, 1.0);   // circular in a tall viewport
// ...
offset += normalize(d) / vec2(aspect, 1.0) * force * AMP;

Skip this on a phone-shaped viewport and the rings render as ellipses. Dividing by aspect on the way back is correct rather than a fudge: equal displacement in pixels is unequal in uv when the axes have different pixel lengths.

Web Implementation

WebGL2, a fullscreen triangle, the image as a texture, the UI as ordinary DOM above the canvas.

uniform sampler2D uTex;
uniform vec2  uRes;
uniform float uTime;
uniform vec4  uRipples[12];   // xy = origin, z = birth, w = strength

void main() {
  vec2 uv = vUv;
  float aspect = uRes.x / uRes.y;
  vec2 offset = vec2(0.0);

  for (int i = 0; i < 12; i++) {
    vec4 r = uRipples[i];
    float age = uTime - r.z;
    if (r.w <= 0.0 || age < 0.0 || age > LIFE) continue;

    vec2 d = (uv - r.xy) * vec2(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);
    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) / vec2(aspect, 1.0) * force * AMP;
  }

  outColor = vec4(texture(uTex, coverUv(uv + offset)).rgb, 1.0);
}

On the host side, push a ripple on pointerdown, push a weaker one on throttled pointermove, and write the buffer into the uniform each frame with gl.uniform4fv. Cap device pixel ratio at 2 and pause the frame loop when the canvas scrolls out of view.

SwiftUI Implementation

View.distortionEffect(_:maxSampleOffset:isEnabled:), iOS 17 and macOS 14 and up, is exactly this primitive. The Metal function takes a destination pixel and returns the source pixel to sample, so bending that mapping bends the image.

[[ stitchable ]] float2 ripple(float2 position, float2 size, float time,
                               device const float *ripples, int count) {
  float2 uv = position / size;
  float aspect = size.x / size.y;
  float2 offset = float2(0.0);

  for (int i = 0; i + 3 < count; i += 4) {   // x, y, birth, strength
    float strength = ripples[i + 3];
    float age = time - ripples[i + 2];
    if (strength <= 0.0 || age < 0.0 || age > LIFE) { continue; }
    // identical body to the GLSL above
  }

  // sampling past the layer returns transparent, which reads as black edges
  return clamp(position + offset * size, float2(0.0), size);
}
TimelineView(.animation) { context in
  artwork
    .distortionEffect(
      ShaderLibrary.ripple(
        .float2(size.width, size.height),
        .float(store.elapsed(at: context.date)),
        .floatArray(store.packed)
      ),
      maxSampleOffset: CGSize(width: 60, height: 60)
    )
}

Three things to get right:

  • Shader.Argument.floatArray arrives as device const float *ptr, int count, so the loop strides by 4 rather than indexing a float4.
  • maxSampleOffset must exceed the peak displacement in points, or the edges clip. At AMP = 0.05 on an 844 point tall screen that is about 42 points, so 60 is a safe value.
  • Apply the effect to the image only, never a parent that contains the text.

For React Native, see the Skia recipe in the react-native area.

Pitfalls

  • Black bars at the edges. A displaced sample outside the source is transparent. Clamp the sampled position into bounds. This appears on iOS first because the layer is exactly the view.
  • A vortex at the origin. As distance approaches zero, normalize(d) explodes. Mask the centre with smoothstep(0.0, 0.06, dist).
  • A punch on the first frame. Ease the amplitude in over the first 8 percent of the life.
  • A ripple that vanishes instantly. If REACH outruns the visible area, the ring spends its life off screen. Verify by holding a ripple at a fixed age rather than watching it live.
  • Everything looks smeared. The band is too wide or the amplitude too high. Raise THICK before lowering AMP.

Accessibility And Performance

Honour prefers-reduced-motion, and its platform equivalents, by rendering the image with no ripples at all. Do not merely slow it down: this is ambient motion with no informational content, so removing it costs the user nothing.

The shader is O(pixels x ripples) per frame, so the ripple cap is the real budget. A dozen is generous; each one costs a full-screen pass of arithmetic. Cap DPR at 2, stop the loop when the surface is off screen, and never run two of these on one screen.

Contrast survives distortion, but only if the copy has its own backing. Put a scrim behind text that sits over the artwork, sized to the text block, and check the ratio against the brightest frame the ripple can produce rather than the still image.

Verifying It

Ripples are hard to screenshot: the interesting frames last under a second. Add a debug hook that freezes the shader clock at a fixed ripple age, then capture at several ages.

window.__demo = {
  freeze(age, x = 0.5, y = 0.55) { /* one ripple, clock pinned to birth + age */ },
  resume() { /* release the clock */ },
};

Without it you are guessing whether a missing ripple means broken maths, a dead frame loop, or a capture that landed between ripples.

Use this guidance in your coding agent

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

get-ui-principle({ topic: "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