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

Frequency9.0
Propagation speed0.16
Strength1.00
Ring color
#9FEAFF
Background
#222733

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/ripple

Files 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);
}