ripple@0.1.0
Ripple
Concentric waves radiating from the pointer, phase driven by time. Move fast and the rings surge; go still and they settle to a calm pulse. An easing curve shapes each crest, and a small chromatic offset between channels makes the front shimmer.
Controls
Install
One command copies the composition into your project (with the @vshaders namespace registered in components.json; the plain URL https://vshaders.com/r/ripple.json works with no setup). npm dependencies install alongside.
npx shadcn@latest add @vshaders/rippleFiles that land in your tree: shaders/ripple.wgsl, components/ripple.tsx, lib/run-effect.ts, lib/shared-gpu.ts, wgsl-env.d.ts.
The shader imports WGSL modules from npm, resolved by vgpu's loader. In next.config.ts, wire @vgpu/wgsl/loader-webpack for *.wgsl under both turbopack.rules and the webpack() hook, and keep the installed wgsl-env.d.ts at your project root so .wgsl imports typecheck. Loader setup: https://vgpu.sh/docs. Effect gallery and sources: https://vshaders.com. ringColor is display-referred sRGB (hex/255); backgroundColor is linear-light. Copy params in the live editor (https://vshaders.com/effects/ripple) hands back exact values.
Imports
What this entry shader composes; every module resolves from npm.
import { easeOutCubic, easeOutQuint } from "@vshaders/ease/easing";
import { clamp01 } from "@vgpu/wgsl-std/math";
Source
The full entry shader, ripple.wgsl. Copy it and it is yours.
// effect 011 · ripple — concentric waves radiating from the pointer.
// Ring phase runs on time; amplitude runs on pointer speed, eased so fast
// movement surges and stillness settles to a calm pulse. Each crest is shaped
// by an easing curve, with a small chromatic offset between channels.
// Display-referred on purpose: these are luminous rings over ink, not a
// lit surface.
import { easeOutCubic, easeOutQuint } from "@vshaders/ease/easing";
import { clamp01 } from "@vgpu/wgsl-std/math";
// Tunable members follow resolution/time. Defaults live in lib/effects.ts and
// must be set by the runner: an unset uniform member reads as zero. Members are
// ordered so each vec3f lands on a 16-byte boundary with an f32 in its pad.
struct Uniforms {
resolution: vec2f,
time: f32,
// Rings per centered field unit.
frequency: f32,
// Crest color, display-referred (default #9FEAFF, an icy blue).
ringColor: vec3f,
// Outward crest speed, field units per second.
propagationSpeed: f32,
// Pointer position, physical pixels; canvas center before any interaction.
pointer: vec2f,
// Smoothed pointer velocity, physical pixels per second; zero when idle.
pointerVelocity: vec2f,
// Overall crest brightness.
strength: f32,
// Ground the effect sinks into at the edges, linear light: expose it so
// the effect sits on any page, not just this gallery's ink.
backgroundColor: vec3f,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
// Pointer speed (px/s) that reads as a full surge.
const surgeSpeed: f32 = 900.0;
// Idle crest brightness, as a fraction of a full surge.
const idleAmplitude: f32 = 0.3;
// One traveling crest profile: a soft leading edge into a fifth-power tail
// (the complement of easeOutQuint), periodic in distance, moving outward as
// travel grows. The 1.9 normalizes the peak (at cycle 0.12, where the front
// meets the tail) to one.
fn crest(dist: f32, travel: f32, frequency: f32) -> f32 {
let cycle = fract((dist - travel) * frequency);
return 1.9 * smoothstep(0.0, 0.12, cycle) * (1.0 - easeOutQuint(cycle));
}
@fragment
fn main(@builtin(position) position: vec4f) -> @location(0) vec4f {
let res = max(uniforms.resolution, vec2f(1.0));
let aspect = res.x / res.y;
let p = (position.xy / res - vec2f(0.5)) * vec2f(aspect, 1.0);
// The pointer transforms exactly like position.
let center = (uniforms.pointer / res - vec2f(0.5)) * vec2f(aspect, 1.0);
let dist = distance(p, center);
let frequency = max(uniforms.frequency, 0.01);
let travel = uniforms.time * uniforms.propagationSpeed;
// Amplitude follows pointer speed through an easing, so a flick surges
// instantly and decays with the runner's velocity smoothing.
let surge = easeOutCubic(clamp01(length(uniforms.pointerVelocity) / surgeSpeed));
let amplitude = uniforms.strength * mix(idleAmplitude, 1.0, surge);
// R/G/B sample the crest at slightly different radii: a 6% of a wavelength
// offset, enough to fringe the front without splitting it.
let chroma = 0.06 / frequency;
let rings = vec3f(
crest(dist + chroma, travel, frequency),
crest(dist, travel, frequency),
crest(dist - chroma, travel, frequency),
);
// Rings dim with distance from the source; a small glow marks the source
// itself so the origin reads even between crests.
let fade = exp(-dist * 1.1);
let ink = uniforms.backgroundColor;
var color = ink + uniforms.ringColor * rings * amplitude * fade;
color += uniforms.ringColor * 0.3 * amplitude * exp(-dist * 9.0);
return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), 1.0);
}