A WebGL shockwave page transition: clicking sends a noise-warped Gaussian wavefront outward from the pointer, displacing UVs with an RGB split (chromatic aberration on the wave ridge) and a glow burn, revealing the alternate texture behind the front. The two textures are GIRAGIRA colourways (ink-on-paper and paper-on-red) painted to offscreen canvases with registration-mark grids that give the displacement something to bite on. FBM noise warps the wavefront so it looks organic, not circular. Falls back to a CSS clip-path circle sweep when WebGL is unavailable.
Ripple
WebGL · click anywhere
Source
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import gsap from "gsap";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import { PanelSelect, PanelToggle } from "../_kansei/controls";
// Click-triggered shockwave transition (after ripple-gl): a noise-warped
// Gaussian wavefront expands from the pointer, pushes the UVs outward with an
// RGB split and a glow ridge, and reveals the other texture behind it. Here
// the two textures are painted GIRAGIRA colourways instead of photos.
const PAPER = "#f4f1ec";
const INK = "#4c4549";
const RED = "#ED1E26";
// FontFace-registered copy of KT Flux so the 2D texture canvas can use it.
const TEX_FONT = "KT Flux Ripple";
const VERT = `
attribute vec2 a_pos;
varying vec2 v_uv;
void main() {
v_uv = vec2(a_pos.x * 0.5 + 0.5, 0.5 - a_pos.y * 0.5);
gl_Position = vec4(a_pos, 0.0, 1.0);
}`;
const FRAG = `
precision highp float;
uniform sampler2D u_texA;
uniform sampler2D u_texB;
uniform vec2 u_resolution;
uniform vec2 u_center;
uniform float u_progress;
uniform float u_sigma;
uniform float u_waveFreq;
uniform float u_pushAmt;
uniform float u_caStrength;
uniform float u_glow;
uniform float u_noiseWarp;
uniform float u_swap;
uniform float u_pinch;
varying vec2 v_uv;
float hash21(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
float vnoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
float a = hash21(i);
float b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0));
float d = hash21(i + vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p, int octaves) {
float val = 0.0;
float amp = 0.5;
float freq = 1.0;
for (int i = 0; i < 8; i++) {
if (i >= octaves) break;
val += amp * vnoise(p * freq);
freq *= 2.0;
amp *= 0.5;
}
return val;
}
void main() {
vec2 uv = v_uv;
vec2 size = u_resolution;
vec2 center = u_center;
vec2 p = uv - center;
float aspect = size.x / size.y;
p.x *= aspect;
float dist = length(p);
float maxDist = length(vec2(0.5 * aspect, 0.5));
float normDist = clamp(dist / maxDist, 0.0, 1.0);
float noiseLarge = fbm(p * 4.0 + vec2(u_progress * 1.0, u_progress * 0.5), 4);
float noiseSmall = fbm(p * 12.0 + vec2(u_progress * 2.0, -u_progress * 1.5), 3);
float coverage = 1.0 + 0.5 * u_noiseWarp + 0.1;
float waveFront = u_progress * coverage;
float warpScale = smoothstep(0.0, 0.05, u_progress);
float warpedDist = normDist
+ (noiseLarge - 0.5) * u_noiseWarp * warpScale
+ (noiseSmall - 0.5) * (u_noiseWarp * 0.9) * warpScale;
float delta = warpedDist - waveFront;
float baseEnvelope = exp(-delta * delta / (2.0 * u_sigma * u_sigma));
float ripples = max(0.0, cos(delta * u_waveFreq));
float envelope = baseEnvelope * ripples;
float gate = smoothstep(0.0, 0.05, u_progress)
* (1.0 - smoothstep(0.85, 1.0, u_progress));
envelope *= gate;
vec2 dir = (dist > 0.001) ? normalize(p) : vec2(0.0);
float pushAmt = envelope * u_pushAmt;
float pinchSigma = 0.10;
float pinchG = exp(-dist * dist / (2.0 * pinchSigma * pinchSigma));
float pinchDisp = (dist / (pinchSigma * pinchSigma)) * pinchG * 0.01 * u_pinch;
vec2 toEdge = min(uv, 1.0 - uv);
float edgeFade = smoothstep(0.0, 0.14, min(toEdge.x, toEdge.y));
pinchDisp *= edgeFade;
vec2 uvOffset = dir * (pushAmt - pinchDisp);
uvOffset.x /= aspect;
float caStrength = envelope * u_caStrength;
vec2 caOffset = dir * caStrength;
caOffset.x /= aspect;
vec2 uvR = uv - uvOffset - caOffset;
vec2 uvG = uv - uvOffset;
vec2 uvB = uv - uvOffset + caOffset;
vec4 colorA = vec4(
texture2D(u_texA, uvR).r,
texture2D(u_texA, uvG).g,
texture2D(u_texA, uvB).b,
1.0
);
vec4 colorB = vec4(
texture2D(u_texB, uvR).r,
texture2D(u_texB, uvG).g,
texture2D(u_texB, uvB).b,
1.0
);
float feather = 0.04 + 0.05 * noiseLarge;
float reveal = smoothstep(waveFront + feather, waveFront - feather, warpedDist);
reveal *= smoothstep(0.0, 0.05, u_progress);
vec4 base = mix(colorA, colorB, u_swap);
vec4 target = mix(colorB, colorA, u_swap);
vec4 color = mix(base, target, reveal);
float glow = envelope * u_glow;
color.rgb = clamp(color.rgb / max(1.0 - glow, 0.01), 0.0, 1.0);
color.rgb *= 1.0 - 0.16 * pinchG * edgeFade * u_pinch;
color.rgb = clamp(color.rgb, 0.0, 1.0);
gl_FragColor = vec4(color.rgb, 1.0);
}`;
const EASES = {
inout: "power2.inOut",
out: "power3.out",
expo: "expo.out",
back: "back.out(1.4)",
} as const;
type EaseKey = keyof typeof EASES;
const DEFAULTS = { sigma: 0.15, waveFreq: 5, pushAmt: 0.145, ca: 0.02, glow: 0.73, noiseWarp: 1, duration: 1.4 };
// Paint one colourway poster: flat ground, registration-mark grid (gives the
// displacement wave something to bite on), fitted wordmark + caption.
function paintSide(ctx: CanvasRenderingContext2D, w: number, h: number, dpr: number, side: 0 | 1) {
const bg = side === 0 ? PAPER : RED;
const fg = side === 0 ? INK : PAPER;
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
const step = 64 * dpr;
const arm = 4.5 * dpr;
ctx.strokeStyle = fg;
ctx.lineWidth = Math.max(1, dpr * 0.9);
ctx.globalAlpha = side === 0 ? 0.14 : 0.22;
for (let y = step * 0.5; y < h; y += step) {
for (let x = step * 0.5; x < w; x += step) {
ctx.beginPath();
ctx.moveTo(x - arm, y);
ctx.lineTo(x + arm, y);
ctx.moveTo(x, y - arm);
ctx.lineTo(x, y + arm);
ctx.stroke();
}
}
ctx.globalAlpha = 1;
const word = "GIRAGIRA";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = `600 100px "${TEX_FONT}", sans-serif`;
const at100 = ctx.measureText(word).width;
let size = (w * 0.88 * 100) / Math.max(1, at100);
ctx.font = `600 ${size}px "${TEX_FONT}", sans-serif`;
// Re-measure at the final size: if the face resolved between the two
// measures the first fit is off — rescale so the word never overflows.
const atSize = ctx.measureText(word).width;
if (atSize > w * 0.88) {
size *= (w * 0.88) / atSize;
ctx.font = `600 ${size}px "${TEX_FONT}", sans-serif`;
}
ctx.fillStyle = fg;
ctx.fillText(word, w / 2, h * 0.5 - size * 0.06);
const spaced = ctx as CanvasRenderingContext2D & { letterSpacing: string };
if ("letterSpacing" in ctx) spaced.letterSpacing = `${3 * dpr}px`;
ctx.font = `500 ${11 * dpr}px "${TEX_FONT}", sans-serif`;
ctx.fillText(side === 0 ? "COLOURWAY 01 — INK ON PAPER" : "COLOURWAY 02 — PAPER ON RED", w / 2, h * 0.5 + size * 0.5);
if ("letterSpacing" in ctx) spaced.letterSpacing = "0px";
}
function FallbackFace({ side }: { side: 0 | 1 }) {
return (
<div className="absolute inset-0 flex items-center justify-center" style={{ background: side === 0 ? PAPER : RED }}>
<span
className="font-[family-name:var(--font-flux)] text-[12vw] leading-none"
style={{ fontVariationSettings: "'wght' 600, 'SRIF' 200", color: side === 0 ? INK : PAPER }}
>
GIRAGIRA
</span>
</div>
);
}
// No-WebGL fallback: same colourway swap as a clip-path circle sweep.
function CSSFallback({ side, onSwap }: { side: 0 | 1; onSwap: () => void }) {
const [sweep, setSweep] = useState<{ x: number; y: number; on: boolean } | null>(null);
const busy = useRef(false);
const onClick = (e: React.MouseEvent) => {
if (busy.current) return;
busy.current = true;
const x = (e.clientX / window.innerWidth) * 100;
const y = (e.clientY / window.innerHeight) * 100;
setSweep({ x, y, on: false });
requestAnimationFrame(() => requestAnimationFrame(() => setSweep((s) => (s ? { ...s, on: true } : s))));
setTimeout(() => {
onSwap();
setSweep(null);
busy.current = false;
}, 1250);
};
return (
<div className="absolute inset-0" onClick={onClick}>
<FallbackFace side={side} />
{sweep && (
<div
className="absolute inset-0"
style={{
clipPath: `circle(${sweep.on ? "150%" : "0%"} at ${sweep.x}% ${sweep.y}%)`,
transition: "clip-path 1.1s cubic-bezier(0.5, 0, 0.2, 1)",
}}
>
<FallbackFace side={side === 0 ? 1 : 0} />
</div>
)}
</div>
);
}
export default function Ripple() {
const [sigma, setSigma] = useState(DEFAULTS.sigma);
const [waveFreq, setWaveFreq] = useState(DEFAULTS.waveFreq);
const [pushAmt, setPushAmt] = useState(DEFAULTS.pushAmt);
const [ca, setCa] = useState(DEFAULTS.ca);
const [glow, setGlow] = useState(DEFAULTS.glow);
const [noiseWarp, setNoiseWarp] = useState(DEFAULTS.noiseWarp);
const [duration, setDuration] = useState(DEFAULTS.duration);
const [ease, setEase] = useState<EaseKey>("inout");
const [pinch, setPinch] = useState(true);
const [side, setSide] = useState<0 | 1>(0);
const [renderMode, setRenderMode] = useState<"gl" | "css" | null>(null);
const [glError, setGlError] = useState<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawRef = useRef<(() => void) | null>(null);
// Wave state lives outside React — gsap mutates it and redraws imperatively.
const animRef = useRef({ progress: 0, cx: 0.5, cy: 0.5, swap: 0, pinch: 0, busy: false });
const paramsRef = useRef({ sigma, waveFreq, pushAmt, ca, glow, noiseWarp, duration, ease, pinch });
// Keep the imperative draw's snapshot of the controls current (synced after each render).
useEffect(() => {
paramsRef.current = { sigma, waveFreq, pushAmt, ca, glow, noiseWarp, duration, ease, pinch };
drawRef.current?.();
});
const trigger = useCallback((cx: number, cy: number) => {
const a = animRef.current;
const draw = () => drawRef.current?.();
if (a.busy || !drawRef.current) return;
a.cx = cx;
a.cy = cy;
gsap.killTweensOf(a);
a.progress = 0;
a.pinch = 0;
a.busy = true;
const p = paramsRef.current;
if (p.pinch) {
gsap.to(a, {
keyframes: [
{ pinch: 0.3, duration: 0.1, ease: "power3.out" },
{ pinch: 0, duration: 0.4, ease: "power2.in" },
],
onUpdate: draw,
});
}
gsap.to(a, {
progress: 1,
duration: p.duration,
ease: EASES[p.ease],
onUpdate: draw,
onComplete: () => {
a.swap = a.swap > 0.5 ? 0 : 1;
a.progress = 0;
a.busy = false;
setSide(a.swap as 0 | 1);
draw();
},
});
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
let disposed = false;
let cleanup: (() => void) | null = null;
(async () => {
const gl = canvas.getContext("webgl", { antialias: false, premultipliedAlpha: false }) as WebGLRenderingContext | null;
if (!gl) {
setGlError("No WebGL context available");
setRenderMode("css");
return;
}
const compile = (type: number, src: string, label: string) => {
const s = gl.createShader(type)!;
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(s) || "(empty)";
gl.deleteShader(s);
throw new Error(`${label}: ${log}`);
}
return s;
};
let vs: WebGLShader, fs: WebGLShader, prog: WebGLProgram;
try {
vs = compile(gl.VERTEX_SHADER, VERT, "VERT");
fs = compile(gl.FRAGMENT_SHADER, FRAG, "FRAG");
prog = gl.createProgram()!;
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
throw new Error(`LINK: ${gl.getProgramInfoLog(prog) || "(empty)"}`);
}
} catch (err) {
setGlError(err instanceof Error ? err.message : String(err));
setRenderMode("css");
return;
}
gl.useProgram(prog);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
const posLoc = gl.getAttribLocation(prog, "a_pos");
gl.enableVertexAttribArray(posLoc);
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
gl.uniform1i(gl.getUniformLocation(prog, "u_texA"), 0);
gl.uniform1i(gl.getUniformLocation(prog, "u_texB"), 1);
const locs = {
res: gl.getUniformLocation(prog, "u_resolution"),
center: gl.getUniformLocation(prog, "u_center"),
progress: gl.getUniformLocation(prog, "u_progress"),
sigma: gl.getUniformLocation(prog, "u_sigma"),
waveFreq: gl.getUniformLocation(prog, "u_waveFreq"),
pushAmt: gl.getUniformLocation(prog, "u_pushAmt"),
caStrength: gl.getUniformLocation(prog, "u_caStrength"),
glow: gl.getUniformLocation(prog, "u_glow"),
noiseWarp: gl.getUniformLocation(prog, "u_noiseWarp"),
swap: gl.getUniformLocation(prog, "u_swap"),
pinch: gl.getUniformLocation(prog, "u_pinch"),
};
// KT Flux for the texture canvas — poster still paints in a fallback
// sans if the file is unreachable.
try {
const face = new FontFace(TEX_FONT, "url(/fonts/KT-Flux-2_Variable.ttf)");
await face.load();
document.fonts.add(face);
// Detached canvases resolve newly added faces lazily — force resolution
// at the spec we paint with, or measureText falls back mid-paint.
await document.fonts.load(`600 100px "${TEX_FONT}"`);
} catch {}
if (disposed) return;
const texA = gl.createTexture()!;
const texB = gl.createTexture()!;
const src = document.createElement("canvas");
const srcCtx = src.getContext("2d")!;
const uploadSide = (unit: number, tex: WebGLTexture, sideIdx: 0 | 1, dpr: number) => {
paintSide(srcCtx, src.width, src.height, dpr, sideIdx);
gl.activeTexture(gl.TEXTURE0 + unit);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, src);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
};
const draw = () => {
const p = paramsRef.current;
const a = animRef.current;
gl.uniform2f(locs.center, a.cx, a.cy);
gl.uniform1f(locs.progress, a.progress);
gl.uniform1f(locs.sigma, p.sigma);
gl.uniform1f(locs.waveFreq, p.waveFreq);
gl.uniform1f(locs.pushAmt, p.pushAmt);
gl.uniform1f(locs.caStrength, p.ca);
gl.uniform1f(locs.glow, p.glow);
gl.uniform1f(locs.noiseWarp, p.noiseWarp);
gl.uniform1f(locs.swap, a.swap);
gl.uniform1f(locs.pinch, a.pinch);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
};
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.round(canvas.clientWidth * dpr));
const h = Math.max(1, Math.round(canvas.clientHeight * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
src.width = w;
src.height = h;
gl.viewport(0, 0, w, h);
gl.uniform2f(locs.res, w, h);
uploadSide(0, texA, 0, dpr);
uploadSide(1, texB, 1, dpr);
draw();
};
drawRef.current = draw;
setRenderMode("gl");
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas);
cleanup = () => {
ro.disconnect();
drawRef.current = null;
gl.deleteTexture(texA);
gl.deleteTexture(texB);
gl.deleteBuffer(vbo);
gl.deleteProgram(prog);
gl.deleteShader(vs);
gl.deleteShader(fs);
};
})();
const anim = animRef.current;
return () => {
disposed = true;
gsap.killTweensOf(anim);
cleanup?.();
};
}, []);
const onPointerDown = (e: React.PointerEvent) => {
if (e.button !== 0 || renderMode !== "gl") return;
if ((e.target as HTMLElement).closest("a, button, input, .lab-panel")) return;
const canvas = canvasRef.current;
if (!canvas) return;
const r = canvas.getBoundingClientRect();
trigger((e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height);
};
const light = side === 1; // red ground → light chrome
return (
<div className="fixed inset-0 overflow-hidden select-none" style={{ background: PAPER, touchAction: "none" }} onPointerDown={onPointerDown}>
{renderMode !== "css" && <canvas ref={canvasRef} className="absolute inset-0 h-full w-full cursor-crosshair" />}
{renderMode === "css" && <CSSFallback side={side} onSwap={() => setSide((s) => (s === 0 ? 1 : 0))} />}
{glError && (
<div className="absolute left-3 top-3 max-w-sm rounded border border-red-500/30 bg-black/80 px-3 py-2 z-10 font-[family-name:var(--font-geist-mono)]">
<p className="text-[9px] uppercase tracking-widest text-red-400">WebGL shader failed (CSS fallback)</p>
<p className="mt-1 text-[10px] leading-tight text-red-300/70">{glError}</p>
</div>
)}
<div
className="fixed bottom-8 left-8 pointer-events-none z-10 font-[family-name:var(--font-flux)]"
style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}
>
<p className="text-[12px] tracking-[0.08em] uppercase" style={{ color: light ? "rgba(255,252,244,0.8)" : "rgba(23,23,23,0.5)" }}>Ripple</p>
<p className="text-[12px] tracking-[0.08em] mt-1" style={{ color: light ? "rgba(255,252,244,0.55)" : "rgba(23,23,23,0.3)" }}>WebGL · click anywhere</p>
</div>
<DraggablePanel
isLight={light}
controls={[
{ label: "Duration", value: duration, set: setDuration, min: 0.4, max: 3, step: 0.05 },
{ label: "Wave width", value: sigma, set: setSigma, min: 0.05, max: 0.5, step: 0.01 },
{ label: "Density", value: waveFreq, set: (v) => setWaveFreq(Math.round(v)), min: 5, max: 100, step: 1 },
{ label: "Displacement", value: pushAmt, set: setPushAmt, min: 0, max: 0.5, step: 0.005 },
{ label: "RGB split", value: ca, set: setCa, min: 0, max: 0.05, step: 0.005 },
{ label: "Glow", value: glow, set: setGlow, min: 0, max: 1, step: 0.01 },
{ label: "Noise warp", value: noiseWarp, set: setNoiseWarp, min: 0, max: 1, step: 0.01 },
]}
>
<PanelSelect label="Ease" value={ease} options={["inout", "out", "expo", "back"] as const} set={setEase} isLight={light} />
<PanelToggle label="Pinch" value={pinch} set={setPinch} isLight={light} />
</DraggablePanel>
<div className="fixed top-8 left-8 z-10">
<ScrambleLink
from="NEMAWASHI LAB"
to="← BACK"
href="/lab"
className={`text-[14px] tracking-[0.08em] uppercase pointer-events-auto font-[family-name:var(--font-flux)] ${light ? "text-white/70" : "text-foreground/60"}`}
style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
/>
</div>
</div>
);
}