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:
| Constant | Value | Effect |
|---|---|---|
LIFE | 2.6 | ripple lifetime in seconds |
REACH | 0.75 | how far the front travels, in uv units |
THICK | 500 | higher is a thinner ring |
AMP | 0.05 | bend 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.birthandcoremasks (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.floatArrayarrives asdevice const float *ptr, int count, so the loop strides by 4 rather than indexing afloat4.maxSampleOffsetmust exceed the peak displacement in points, or the edges clip. AtAMP = 0.05on 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 withsmoothstep(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
REACHoutruns 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
THICKbefore loweringAMP.
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.