Image Ripple

Image cards that behave like water — your cursor drags a live wave simulation that refracts the photo with a chromatic edge.

  • Scroll & Hover Effects
  • Travel / Hospitality
  • React only · 0 deps
  • 1 file
Full screen

Browser support · WebGL2 / WebGL1, no float-texture extensions needed. Without WebGL the image is shown still.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/image-ripple.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/effects/image-ripple.tsx
1/**
2 * ImageRipple — an image card that behaves like a shallow pool. The cursor
3 * drags a wake through a real-time wave simulation (ping-pong framebuffers,
4 * 16-bit heights packed into RGBA8 so it runs on every WebGL device), and the
5 * surface refracts the image with a slight chromatic split and a wet sheen.
6 *
7 * Props
8 * - src?: string Image URL (crossOrigin="anonymous", cover-fitted).
9 * - draw?: (ctx, w, h)=>void Procedural source drawn to an offscreen canvas (used when no src).
10 * - drawSize?: [w, h] Size of the procedural canvas. Default [800, 1000].
11 * - strength?: number Refraction amount. Default 1.
12 * - chroma?: number Chromatic split 0–1.5. Default 0.6.
13 * - damping?: number Wave energy kept per step (0.95–0.995). Default 0.986.
14 * - cell?: number Simulation cell size in CSS px — bigger = broader, cheaper waves. Default 2.
15 * - radius?: number Drop radius in CSS px. Default 14.
16 * - alt?: string Accessible description (canvas gets role="img").
17 * - children Overlay content (captions) above the image.
18 * CSS vars: --fd-ripple-radius.
19 *
20 * Browser support: WebGL2 / WebGL1 (no float-texture extensions needed).
21 * Without WebGL the image is shown still. Reduced motion: no simulation —
22 * the image renders still. The loop sleeps once the water settles.
23 */
24import { useEffect, useRef, useState } from "react";
25import type { CSSProperties, ReactNode } from "react";
26
27type GL = WebGLRenderingContext | WebGL2RenderingContext;
28
29const HEAD = (gl2: boolean) =>
30 (gl2 ? "#version 300 es\n" : "") +
31 "#ifdef GL_FRAGMENT_PRECISION_HIGH\nprecision highp float;\n#else\nprecision mediump float;\n#endif\n" +
32 (gl2 ? "#define texture2D texture\n#define gl_FragColor fdOut\nout vec4 fdOut;\n" : "");
33
34const CODEC = `
35float dec(vec2 c){ vec2 b = floor(c * 255. + .5); return (b.x * 256. + b.y) / 65535. * 2. - 1.; }
36vec2 enc(float v){ float x = floor(clamp(v * .5 + .5, 0., 1.) * 65535. + .5); float hi = floor(x / 256.); return vec2(hi, x - hi * 256.) / 255.; }
37`;
38
39const SIM = `
40uniform sampler2D uState;
41uniform vec2 uSimRes;
42uniform vec4 uDrops[4]; // sim px x, y, radius, strength
43uniform float uDamp;
44${CODEC}
45void main(){
46 vec2 t = 1. / uSimRes;
47 vec2 uv = gl_FragCoord.xy * t;
48 vec4 c = texture2D(uState, uv);
49 float h = dec(c.rg);
50 float p = dec(c.ba);
51 float n = dec(texture2D(uState, uv + vec2(t.x, 0.)).rg) + dec(texture2D(uState, uv - vec2(t.x, 0.)).rg)
52 + dec(texture2D(uState, uv + vec2(0., t.y)).rg) + dec(texture2D(uState, uv - vec2(0., t.y)).rg);
53 float nh = (n * .5 - p) * uDamp;
54 for (int i = 0; i < 4; i++) {
55 vec4 d = uDrops[i];
56 if (d.w == 0.) continue;
57 float r = length(gl_FragCoord.xy - d.xy) / d.z;
58 if (r < 1.) nh += d.w * (.5 + .5 * cos(3.14159 * r));
59 }
60 gl_FragColor = vec4(enc(nh), enc(h));
61}
62`;
63
64const DRAW = `
65uniform sampler2D uState;
66uniform sampler2D uImg;
67uniform vec2 uRes;
68uniform vec2 uSimRes;
69uniform vec2 uImgSize;
70uniform float uStrength;
71uniform float uChroma;
72${CODEC}
73float hAt(vec2 uv){
74 vec2 p = uv * uSimRes - .5;
75 vec2 i = floor(p);
76 vec2 f = p - i;
77 vec2 t = 1. / uSimRes;
78 float a = dec(texture2D(uState, (i + vec2(.5, .5)) * t).rg);
79 float b = dec(texture2D(uState, (i + vec2(1.5, .5)) * t).rg);
80 float c = dec(texture2D(uState, (i + vec2(.5, 1.5)) * t).rg);
81 float d = dec(texture2D(uState, (i + vec2(1.5, 1.5)) * t).rg);
82 return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
83}
84vec2 cover(vec2 uv){
85 float ra = uRes.x / uRes.y;
86 float ia = uImgSize.x / uImgSize.y;
87 vec2 s = ra > ia ? vec2(1., ia / ra) : vec2(ra / ia, 1.);
88 return (uv - .5) * s + .5;
89}
90void main(){
91 vec2 uv = gl_FragCoord.xy / uRes;
92 vec2 e = 1. / uSimRes;
93 float gx = hAt(uv + vec2(e.x, 0.)) - hAt(uv - vec2(e.x, 0.));
94 float gy = hAt(uv + vec2(0., e.y)) - hAt(uv - vec2(0., e.y));
95 vec2 g = vec2(gx, gy);
96 vec2 off = g * .06 * uStrength;
97 float ch = uChroma * .22;
98 vec3 col;
99 col.r = texture2D(uImg, cover(uv + off * (1. + ch))).r;
100 col.g = texture2D(uImg, cover(uv + off)).g;
101 col.b = texture2D(uImg, cover(uv + off * (1. - ch))).b;
102 // wet sheen: highlight minus the flat-surface baseline, plus soft slope shading
103 vec3 n = normalize(vec3(-g * 9., 1.));
104 vec3 H = normalize(vec3(-.35, .55, 1.));
105 float base = pow(H.z / length(H), 90.);
106 float spec = max(pow(max(dot(n, H), 0.), 90.) - base, 0.);
107 col += spec * .55;
108 col *= 1. + (gy - gx) * 1.4;
109 gl_FragColor = vec4(col, 1.);
110}
111`;
112
113const BLIT_VS = (gl2: boolean) => (gl2 ? "#version 300 es\nin vec2 aP;" : "attribute vec2 aP;") + "void main(){gl_Position=vec4(aP,0.,1.);}";
114
115export interface ImageRippleProps {
116 src?: string;
117 draw?: (ctx: CanvasRenderingContext2D, w: number, h: number) => void;
118 drawSize?: [number, number];
119 strength?: number;
120 chroma?: number;
121 damping?: number;
122 cell?: number;
123 radius?: number;
124 alt?: string;
125 className?: string;
126 style?: CSSProperties;
127 children?: ReactNode;
128}
129
130export function ImageRipple({
131 src,
132 draw,
133 drawSize = [800, 1000],
134 strength = 1,
135 chroma = 0.6,
136 damping = 0.986,
137 cell = 2,
138 radius = 14,
139 alt = "",
140 className,
141 style,
142 children,
143}: ImageRippleProps) {
144 const rootRef = useRef<HTMLDivElement>(null);
145 const canvasRef = useRef<HTMLCanvasElement>(null);
146 const stillRef = useRef<HTMLCanvasElement>(null);
147 const [failed, setFailed] = useState(false);
148 const live = useRef({ strength, chroma, damping, cell, radius });
149 live.current = { strength, chroma, damping, cell, radius };
150 const source = useRef<{ el: TexImageSource | null; w: number; h: number; dirty: boolean }>({ el: null, w: 1, h: 1, dirty: false });
151 const api = useRef<{ kick: () => void; drop: (x: number, y: number, s: number, r?: number) => void }>({ kick: () => {}, drop: () => {} });
152
153 // source image
154 useEffect(() => {
155 let cancelled = false;
156 const done = (el: TexImageSource, w: number, h: number) => {
157 source.current = { el, w, h, dirty: true };
158 api.current.kick();
159 const still = stillRef.current;
160 if (still) {
161 const r = still.getBoundingClientRect();
162 still.width = Math.max(1, Math.round(r.width));
163 still.height = Math.max(1, Math.round(r.height));
164 const c = still.getContext("2d");
165 if (c) {
166 const s = Math.max(still.width / w, still.height / h);
167 c.drawImage(el as CanvasImageSource, (still.width - w * s) / 2, (still.height - h * s) / 2, w * s, h * s);
168 }
169 }
170 };
171 if (src) {
172 const img = new Image();
173 img.crossOrigin = "anonymous";
174 img.decoding = "async";
175 img.onload = () => !cancelled && done(img, img.naturalWidth, img.naturalHeight);
176 img.src = src;
177 } else if (draw) {
178 const c = document.createElement("canvas");
179 c.width = drawSize[0];
180 c.height = drawSize[1];
181 const ctx = c.getContext("2d");
182 if (ctx) draw(ctx, c.width, c.height);
183 done(c, c.width, c.height);
184 }
185 return () => {
186 cancelled = true;
187 };
188 // eslint-disable-next-line react-hooks/exhaustive-deps
189 }, [src, draw, drawSize[0], drawSize[1], failed]);
190
191 // GL
192 useEffect(() => {
193 const canvas = canvasRef.current;
194 if (!canvas) return;
195 const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
196 const attrs: WebGLContextAttributes = { antialias: false, alpha: false, preserveDrawingBuffer: false };
197 let gl: GL | null = canvas.getContext("webgl2", attrs) as WebGL2RenderingContext | null;
198 const gl2 = !!gl;
199 if (!gl) gl = canvas.getContext("webgl", attrs) as WebGLRenderingContext | null;
200 if (!gl) {
201 setFailed(true);
202 return;
203 }
204 const g = gl;
205 const build = (fs: string) => {
206 const mk = (type: number, s: string) => {
207 const sh = g.createShader(type)!;
208 g.shaderSource(sh, s);
209 g.compileShader(sh);
210 if (!g.getShaderParameter(sh, g.COMPILE_STATUS)) console.warn("[ImageRipple]", g.getShaderInfoLog(sh));
211 return sh;
212 };
213 const p = g.createProgram()!;
214 g.attachShader(p, mk(g.VERTEX_SHADER, BLIT_VS(gl2)));
215 g.attachShader(p, mk(g.FRAGMENT_SHADER, HEAD(gl2) + fs));
216 g.bindAttribLocation(p, 0, "aP");
217 g.linkProgram(p);
218 const u: Record<string, WebGLUniformLocation | null> = {};
219 for (const m of fs.matchAll(/uniform\s+\w+\s+(\w+)/g)) u[m[1]] = g.getUniformLocation(p, m[1]);
220 return { p, u, ok: !!g.getProgramParameter(p, g.LINK_STATUS) };
221 };
222 const sim = build(SIM);
223 const ren = build(DRAW);
224 if (!sim.ok || !ren.ok) {
225 setFailed(true);
226 return;
227 }
228 const buf = g.createBuffer();
229 g.bindBuffer(g.ARRAY_BUFFER, buf);
230 g.bufferData(g.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), g.STATIC_DRAW);
231 g.enableVertexAttribArray(0);
232 g.vertexAttribPointer(0, 2, g.FLOAT, false, 0, 0);
233
234 const tex = (filter: number) => {
235 const t = g.createTexture()!;
236 g.bindTexture(g.TEXTURE_2D, t);
237 g.texParameteri(g.TEXTURE_2D, g.TEXTURE_MIN_FILTER, filter);
238 g.texParameteri(g.TEXTURE_2D, g.TEXTURE_MAG_FILTER, filter);
239 g.texParameteri(g.TEXTURE_2D, g.TEXTURE_WRAP_S, g.CLAMP_TO_EDGE);
240 g.texParameteri(g.TEXTURE_2D, g.TEXTURE_WRAP_T, g.CLAMP_TO_EDGE);
241 return t;
242 };
243 const imgTex = tex(g.LINEAR);
244 g.texImage2D(g.TEXTURE_2D, 0, g.RGBA, 1, 1, 0, g.RGBA, g.UNSIGNED_BYTE, new Uint8Array([20, 22, 26, 255]));
245 let states: { t: WebGLTexture; f: WebGLFramebuffer }[] = [];
246 let cur = 0;
247 let SW = 1;
248 let SH = 1;
249 let W = 1;
250 let H = 1;
251 let dpr = 1;
252 const drops: number[][] = [];
253 let raf = 0;
254 let last = 0;
255 let awakeUntil = 0;
256 let visible = true;
257 let acc = 0;
258
259 const allocSim = () => {
260 for (const s of states) {
261 g.deleteTexture(s.t);
262 g.deleteFramebuffer(s.f);
263 }
264 SW = Math.max(8, Math.round(W / live.current.cell));
265 SH = Math.max(8, Math.round(H / live.current.cell));
266 // neutral height (0) encodes to 0x7FFF / 0x8000 → bytes [127/128, 255/0]
267 const init = new Uint8Array(SW * SH * 4);
268 for (let i = 0; i < init.length; i += 4) {
269 init[i] = 128;
270 init[i + 1] = 0;
271 init[i + 2] = 128;
272 init[i + 3] = 0;
273 }
274 states = [0, 1].map(() => {
275 const t = tex(g.NEAREST);
276 g.texImage2D(g.TEXTURE_2D, 0, g.RGBA, SW, SH, 0, g.RGBA, g.UNSIGNED_BYTE, init);
277 const f = g.createFramebuffer()!;
278 g.bindFramebuffer(g.FRAMEBUFFER, f);
279 g.framebufferTexture2D(g.FRAMEBUFFER, g.COLOR_ATTACHMENT0, g.TEXTURE_2D, t, 0);
280 return { t, f };
281 });
282 g.bindFramebuffer(g.FRAMEBUFFER, null);
283 };
284
285 const step = () => {
286 const src = states[cur];
287 const dst = states[1 - cur];
288 g.useProgram(sim.p);
289 g.bindFramebuffer(g.FRAMEBUFFER, dst.f);
290 g.viewport(0, 0, SW, SH);
291 g.activeTexture(g.TEXTURE0);
292 g.bindTexture(g.TEXTURE_2D, src.t);
293 g.uniform1i(sim.u.uState, 0);
294 g.uniform2f(sim.u.uSimRes, SW, SH);
295 g.uniform1f(sim.u.uDamp, live.current.damping);
296 const d = new Float32Array(16);
297 drops.splice(0, 4).forEach((v, i) => d.set(v, i * 4));
298 g.uniform4fv(sim.u.uDrops, d);
299 g.drawArrays(g.TRIANGLES, 0, 3);
300 cur = 1 - cur;
301 };
302
303 const render = () => {
304 const s = source.current;
305 g.bindFramebuffer(g.FRAMEBUFFER, null);
306 g.viewport(0, 0, canvas.width, canvas.height);
307 g.useProgram(ren.p);
308 g.activeTexture(g.TEXTURE1);
309 g.bindTexture(g.TEXTURE_2D, imgTex);
310 if (s.dirty && s.el) {
311 g.pixelStorei(g.UNPACK_FLIP_Y_WEBGL, true);
312 try {
313 g.texImage2D(g.TEXTURE_2D, 0, g.RGBA, g.RGBA, g.UNSIGNED_BYTE, s.el);
314 } catch {
315 /* tainted (no CORS) */
316 }
317 g.pixelStorei(g.UNPACK_FLIP_Y_WEBGL, false);
318 s.dirty = false;
319 }
320 g.activeTexture(g.TEXTURE0);
321 g.bindTexture(g.TEXTURE_2D, states[cur].t);
322 g.uniform1i(ren.u.uState, 0);
323 g.uniform1i(ren.u.uImg, 1);
324 g.uniform2f(ren.u.uRes, canvas.width, canvas.height);
325 g.uniform2f(ren.u.uSimRes, SW, SH);
326 g.uniform2f(ren.u.uImgSize, s.w, s.h);
327 g.uniform1f(ren.u.uStrength, live.current.strength);
328 g.uniform1f(ren.u.uChroma, live.current.chroma);
329 g.drawArrays(g.TRIANGLES, 0, 3);
330 };
331
332 const loop = (now: number) => {
333 raf = 0;
334 const dt = last ? Math.min(now - last, 50) : 16.7;
335 last = now;
336 // fixed 120 Hz sim so wave speed is frame-rate independent
337 acc += dt;
338 let n = 0;
339 while (acc >= 8.33 && n < 4) {
340 step();
341 acc -= 8.33;
342 n++;
343 }
344 if (n === 4) acc = 0;
345 render();
346 if (now < awakeUntil && visible && !document.hidden) raf = requestAnimationFrame(loop);
347 else last = 0;
348 };
349 const kick = (wake = 0) => {
350 awakeUntil = Math.max(awakeUntil, performance.now() + wake);
351 if (!raf) raf = requestAnimationFrame(loop);
352 };
353 api.current.kick = () => kick(0);
354 api.current.drop = (x, y, s, r) => {
355 if (reduced) return;
356 const k = live.current.cell;
357 drops.push([x / k, (H - y) / k, Math.max(1.5, (r ?? live.current.radius) / k), s]);
358 if (drops.length > 12) drops.splice(0, drops.length - 12);
359 kick(5000);
360 };
361
362 const resize = () => {
363 const r = canvas.getBoundingClientRect();
364 W = Math.max(1, r.width);
365 H = Math.max(1, r.height);
366 dpr = Math.min(window.devicePixelRatio || 1, 2);
367 canvas.width = Math.round(W * dpr);
368 canvas.height = Math.round(H * dpr);
369 allocSim();
370 kick(0);
371 };
372 const ro = new ResizeObserver(resize);
373 ro.observe(canvas);
374 const io = new IntersectionObserver(([e]) => {
375 visible = e.isIntersecting;
376 if (visible) kick(0);
377 });
378 io.observe(canvas);
379 const onVis = () => !document.hidden && kick(0);
380 const onLost = (e: Event) => {
381 e.preventDefault();
382 cancelAnimationFrame(raf);
383 raf = 0;
384 setFailed(true);
385 };
386 document.addEventListener("visibilitychange", onVis);
387 canvas.addEventListener("webglcontextlost", onLost);
388 return () => {
389 cancelAnimationFrame(raf);
390 ro.disconnect();
391 io.disconnect();
392 document.removeEventListener("visibilitychange", onVis);
393 canvas.removeEventListener("webglcontextlost", onLost);
394 for (const s of states) {
395 g.deleteTexture(s.t);
396 g.deleteFramebuffer(s.f);
397 }
398 g.deleteTexture(imgTex);
399 g.deleteBuffer(buf);
400 g.deleteProgram(sim.p);
401 g.deleteProgram(ren.p);
402 api.current = { kick: () => {}, drop: () => {} };
403 };
404 }, [cell]);
405
406 // pointer → wake
407 useEffect(() => {
408 const el = rootRef.current;
409 if (!el) return;
410 let lx = -1;
411 let ly = -1;
412 let lt = 0;
413 const local = (e: PointerEvent) => {
414 const r = el.getBoundingClientRect();
415 return [e.clientX - r.left, e.clientY - r.top];
416 };
417 const move = (e: PointerEvent) => {
418 const [x, y] = local(e);
419 const now = performance.now();
420 if (lx < 0) {
421 lx = x;
422 ly = y;
423 lt = now;
424 return;
425 }
426 const dist = Math.hypot(x - lx, y - ly);
427 const spacing = live.current.radius * 0.6;
428 if (dist < spacing) return;
429 const v = Math.min(dist / Math.max(1, now - lt), 3);
430 // stamp drops along the segment so fast moves leave a continuous wake
431 const steps = Math.min(4, Math.ceil(dist / spacing));
432 for (let i = 1; i <= steps; i++) {
433 const t = i / steps;
434 api.current.drop(lx + (x - lx) * t, ly + (y - ly) * t, 0.08 + 0.09 * v);
435 }
436 lx = x;
437 ly = y;
438 lt = now;
439 };
440 const down = (e: PointerEvent) => {
441 const [x, y] = local(e);
442 api.current.drop(x, y, 0.55, live.current.radius * 1.6);
443 };
444 const leave = () => {
445 lx = -1;
446 };
447 el.addEventListener("pointermove", move);
448 el.addEventListener("pointerdown", down);
449 el.addEventListener("pointerleave", leave);
450 return () => {
451 el.removeEventListener("pointermove", move);
452 el.removeEventListener("pointerdown", down);
453 el.removeEventListener("pointerleave", leave);
454 };
455 }, []);
456
457 return (
458 <div ref={rootRef} className={`fd-ripple${className ? " " + className : ""}`} style={style}>
459 <style>{css}</style>
460 {failed ? (
461 <canvas ref={stillRef} className="fd-ripple-canvas" role={alt ? "img" : undefined} aria-label={alt || undefined} aria-hidden={alt ? undefined : true} />
462 ) : (
463 <canvas ref={canvasRef} className="fd-ripple-canvas" role={alt ? "img" : undefined} aria-label={alt || undefined} aria-hidden={alt ? undefined : true} />
464 )}
465 {children ? <div className="fd-ripple-overlay">{children}</div> : null}
466 </div>
467 );
468}
469
470const css = `
471.fd-ripple, .fd-ripple *, .fd-ripple *::before, .fd-ripple *::after { box-sizing: border-box; }
472.fd-ripple {
473 position: relative; display: block; margin: 0; padding: 0; border: 0; overflow: hidden;
474 border-radius: var(--fd-ripple-radius, 24px); background: #14161A; color: inherit; isolation: isolate; touch-action: pan-y;
475}
476.fd-ripple-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; margin: 0; padding: 0; border: 0; }
477.fd-ripple-overlay { position: absolute; inset: 0; margin: 0; padding: 0; pointer-events: none; }
478.fd-ripple-overlay a, .fd-ripple-overlay button { pointer-events: auto; }
479`;
480
481/* ───────────────────────── demo ───────────────────────── */
482
483type Scene = {
484 sky: [string, string, string];
485 sun: [number, number, string];
486 ridges: { color: string; base: number; amp: number; freq: number; seed: number; kind: "peaks" | "karst" | "dunes" }[];
487 water: number | null;
488 haze: string;
489};
490
491function ridgeY(x: number, r: Scene["ridges"][number], w: number) {
492 const u = (x / w) * r.freq;
493 const s = r.seed;
494 if (r.kind === "dunes") return Math.sin(u * 2.1 + s) * 0.6 + Math.sin(u * 4.3 + s * 2) * 0.25 + Math.sin(u * 0.7 + s) * 0.5;
495 if (r.kind === "karst") {
496 let v = 0;
497 for (let k = 0; k < 5; k++) {
498 const c = ((s * 0.37 + k * 0.23) % 1) * r.freq;
499 const wdt = 0.18 + ((s + k) % 3) * 0.06;
500 const d = (u - c) / wdt;
501 v = Math.max(v, (1 - ((s + k * 7) % 5) * 0.12) * Math.exp(-Math.pow(Math.abs(d), 2.6)));
502 }
503 return v * 2 - 0.6;
504 }
505 return Math.abs(Math.sin(u * 1.3 + s)) * 0.7 + Math.sin(u * 3.7 + s * 3) * 0.2 + Math.sin(u * 9.1 + s) * 0.06;
506}
507
508function paintScene(scene: Scene) {
509 return (ctx: CanvasRenderingContext2D, w: number, h: number) => {
510 const horizon = scene.water ?? 1;
511 const sky = ctx.createLinearGradient(0, 0, 0, h * horizon);
512 sky.addColorStop(0, scene.sky[0]);
513 sky.addColorStop(0.6, scene.sky[1]);
514 sky.addColorStop(1, scene.sky[2]);
515 ctx.fillStyle = sky;
516 ctx.fillRect(0, 0, w, h);
517 const [sx, sy, sc] = scene.sun;
518 const glow = ctx.createRadialGradient(sx * w, sy * h, 0, sx * w, sy * h, w * 0.6);
519 glow.addColorStop(0, sc);
520 glow.addColorStop(0.08, sc);
521 glow.addColorStop(0.09, sc + "aa");
522 glow.addColorStop(0.35, sc + "33");
523 glow.addColorStop(1, sc + "00");
524 ctx.fillStyle = glow;
525 ctx.fillRect(0, 0, w, h);
526 const land = () =>
527 scene.ridges.forEach((r) => {
528 ctx.fillStyle = r.color;
529 ctx.beginPath();
530 ctx.moveTo(0, h);
531 for (let x = 0; x <= w; x += 4) ctx.lineTo(x, h * (r.base - r.amp * ridgeY(x, r, w)));
532 ctx.lineTo(w, h);
533 ctx.closePath();
534 ctx.fill();
535 // aerial haze between layers
536 const hz = ctx.createLinearGradient(0, h * (r.base - r.amp), 0, h * r.base + h * 0.05);
537 hz.addColorStop(0, scene.haze + "00");
538 hz.addColorStop(1, scene.haze + "55");
539 ctx.fillStyle = hz;
540 ctx.fill();
541 });
542 if (scene.water == null) {
543 land();
544 return;
545 }
546 const wy = h * scene.water;
547 ctx.save();
548 ctx.beginPath();
549 ctx.rect(0, 0, w, wy);
550 ctx.clip();
551 land();
552 ctx.restore();
553 // mirror the upper half into the lake
554 ctx.save();
555 ctx.translate(0, wy * 2);
556 ctx.scale(1, -1);
557 ctx.globalAlpha = 0.85;
558 ctx.drawImage(ctx.canvas, 0, 0, w, wy, 0, 0, w, wy);
559 ctx.restore();
560 const deep = ctx.createLinearGradient(0, wy, 0, h);
561 deep.addColorStop(0, "rgba(10,18,30,.18)");
562 deep.addColorStop(1, "rgba(6,10,18,.62)");
563 ctx.fillStyle = deep;
564 ctx.fillRect(0, wy, w, h - wy);
565 // shoreline + glints
566 ctx.fillStyle = "rgba(0,0,0,.35)";
567 ctx.fillRect(0, wy - 1, w, 3);
568 for (let i = 0; i < 70; i++) {
569 const y = wy + Math.pow(i / 70, 1.6) * (h - wy);
570 ctx.fillStyle = `rgba(255,255,255,${0.05 + (1 - i / 70) * 0.08})`;
571 ctx.fillRect(((i * 97) % 100) / 100 * w - 40, y, 30 + (i % 5) * 26, 1.5);
572 }
573 };
574}
575
576const SCENES: { place: string; country: string; coords: string; nights: string; price: string; scene: Scene }[] = [
577 {
578 place: "Reine",
579 country: "Lofoten, Norway",
580 coords: "67.93° N, 13.09° E",
581 nights: "7 nights · Mar–Apr",
582 price: "from €2,180",
583 scene: {
584 sky: ["#1b2a4a", "#c98a9a", "#f6c9a0"],
585 sun: [0.62, 0.5, "#fff1d6"],
586 haze: "#e6b8b0",
587 water: 0.6,
588 ridges: [
589 { color: "#7d6f8c", base: 0.56, amp: 0.2, freq: 3, seed: 1.2, kind: "peaks" },
590 { color: "#4a4766", base: 0.6, amp: 0.26, freq: 2.2, seed: 4.1, kind: "peaks" },
591 { color: "#1e2236", base: 0.62, amp: 0.12, freq: 4, seed: 2.7, kind: "peaks" },
592 ],
593 },
594 },
595 {
596 place: "Hạ Long Bay",
597 country: "Quảng Ninh, Vietnam",
598 coords: "20.91° N, 107.18° E",
599 nights: "5 nights · Oct–Dec",
600 price: "from €1,460",
601 scene: {
602 sky: ["#a9c7c2", "#dfe6d2", "#f3ecd4"],
603 sun: [0.3, 0.3, "#fffbe8"],
604 haze: "#e7ecdc",
605 water: 0.64,
606 ridges: [
607 { color: "#9fb5a8", base: 0.62, amp: 0.2, freq: 1, seed: 3.3, kind: "karst" },
608 { color: "#5f7d6e", base: 0.64, amp: 0.3, freq: 1, seed: 7.9, kind: "karst" },
609 { color: "#2a3f36", base: 0.65, amp: 0.36, freq: 1, seed: 5.2, kind: "karst" },
610 ],
611 },
612 },
613 {
614 place: "Wadi Rum",
615 country: "Aqaba, Jordan",
616 coords: "29.57° N, 35.42° E",
617 nights: "4 nights · Nov–Mar",
618 price: "from €1,120",
619 scene: {
620 sky: ["#2c1f3d", "#e0805a", "#f7c58a"],
621 sun: [0.5, 0.56, "#fff0d2"],
622 haze: "#f2a878",
623 water: null,
624 ridges: [
625 { color: "#b8644a", base: 0.6, amp: 0.12, freq: 1.4, seed: 2.1, kind: "peaks" },
626 { color: "#c9774e", base: 0.74, amp: 0.06, freq: 1.6, seed: 0.4, kind: "dunes" },
627 { color: "#9c4c33", base: 0.86, amp: 0.07, freq: 1.2, seed: 3.6, kind: "dunes" },
628 { color: "#5e2a1f", base: 0.98, amp: 0.08, freq: 1.1, seed: 5.1, kind: "dunes" },
629 ],
630 },
631 },
632];
633
634const DRAWERS = SCENES.map((s) => paintScene(s.scene));
635
636export function Demo() {
637 const [order, setOrder] = useState([0, 1, 2]);
638 const next = () => setOrder(([a, ...rest]) => [...rest, a]);
639 const top = SCENES[order[0]];
640 return (
641 <section className="fd-ripple-demo">
642 <style>{demoCss}</style>
643 <div className="fd-ripple-demo-copy">
644 <p className="fd-ripple-demo-kicker">Slowtrail · Winter 2026 collection</p>
645 <h1 className="fd-ripple-demo-title">Places that make you stop scrolling.</h1>
646 <p className="fd-ripple-demo-body">Small-group journeys to the world's quietest edges. Run your cursor over the water — then plan the trip you've been putting off.</p>
647 <div className="fd-ripple-demo-row">
648 <a className="fd-ripple-demo-cta" href="#book" onClick={(e) => e.preventDefault()}>Explore {top.place}</a>
649 <button type="button" className="fd-ripple-demo-next" onClick={next} aria-label="Next destination">
650 <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M4 9h10M10 5l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
651 </button>
652 <span className="fd-ripple-demo-count" aria-live="polite">{String(order[0] + 1).padStart(2, "0")} / {String(SCENES.length).padStart(2, "0")}</span>
653 </div>
654 </div>
655 <div className="fd-ripple-demo-stack">
656 {SCENES.map((s, i) => {
657 const pos = order.indexOf(i);
658 return (
659 <ImageRipple
660 key={s.place}
661 className="fd-ripple-demo-card"
662 draw={DRAWERS[i]}
663 alt={`${s.place}, ${s.country}`}
664 style={{ ["--pos" as string]: pos, zIndex: SCENES.length - pos } as CSSProperties}
665 >
666 <div className="fd-ripple-demo-cap">
667 <span className="fd-ripple-demo-coords">{s.coords}</span>
668 <span className="fd-ripple-demo-place">{s.place}</span>
669 <span className="fd-ripple-demo-country">{s.country}</span>
670 <span className="fd-ripple-demo-meta">
671 <span>{s.nights}</span>
672 <span>{s.price}</span>
673 </span>
674 </div>
675 </ImageRipple>
676 );
677 })}
678 </div>
679 </section>
680 );
681}
682
683const demoCss = `
684.fd-ripple-demo, .fd-ripple-demo *, .fd-ripple-demo *::before, .fd-ripple-demo *::after { box-sizing: border-box; }
685.fd-ripple-demo {
686 --fd-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
687 --fd-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
688 min-height: 100vh; margin: 0; padding: clamp(32px, 6vw, 88px) clamp(20px, 5vw, 72px);
689 display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; gap: clamp(40px, 6vw, 96px);
690 background: #F3F0EA; color: #16181D; overflow: hidden;
691 font: 400 15px/1.5 ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif;
692}
693.fd-ripple-demo-copy { max-width: 480px; justify-self: end; margin: 0; padding: 0; }
694.fd-ripple-demo-kicker { margin: 0 0 18px; padding: 0; color: #8A6A4E; font: 600 12px/1.3 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; letter-spacing: 0.1em; text-transform: uppercase; }
695.fd-ripple-demo-title { margin: 0 0 20px; padding: 0; color: #16181D; font: 700 clamp(2.5rem, 4.6vw, 4.25rem)/1 "SF Pro Display", ui-sans-serif, -apple-system, "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.04em; }
696.fd-ripple-demo-body { max-width: 42ch; margin: 0 0 32px; padding: 0; color: #575A63; font: 400 1.0625rem/1.55 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.01em; }
697.fd-ripple-demo-row { display: flex; align-items: center; gap: 12px; margin: 0; padding: 0; }
698.fd-ripple-demo-cta {
699 display: inline-flex; align-items: center; height: 50px; margin: 0; padding: 0 26px; border: 0; border-radius: 9999px;
700 background: #16181D; color: #F7F5F0; text-decoration: none;
701 font: 600 15px/1 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.01em;
702 transition: transform 120ms var(--fd-ease-out), background 120ms var(--fd-ease-out);
703}
704.fd-ripple-demo-cta:hover { background: #2C2F37; }
705.fd-ripple-demo-cta:active, .fd-ripple-demo-next:active { transform: scale(0.96); }
706.fd-ripple-demo-next {
707 appearance: none; -webkit-appearance: none; display: inline-flex; align-items: center; justify-content: center;
708 width: 50px; height: 50px; margin: 0; padding: 0; border: 1px solid rgba(22,24,29,.16); border-radius: 9999px;
709 background: transparent; color: #16181D; cursor: pointer; font: inherit;
710 transition: transform 120ms var(--fd-ease-out), background 120ms var(--fd-ease-out);
711}
712.fd-ripple-demo-next svg { display: block; }
713.fd-ripple-demo-next:hover { background: rgba(22,24,29,.06); }
714.fd-ripple-demo-cta:focus-visible, .fd-ripple-demo-next:focus-visible { outline: 2px solid rgba(0,122,255,.7); outline-offset: 2px; }
715.fd-ripple-demo-count { margin: 0 0 0 6px; padding: 0; color: #8A8D96; font: 500 13px/1 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; font-variant-numeric: tabular-nums; }
716.fd-ripple-demo-stack { position: relative; justify-self: start; width: min(100%, 420px); aspect-ratio: 4 / 5; margin: 0; padding: 0; }
717.fd-ripple-demo-stack .fd-ripple-demo-card {
718 position: absolute; inset: 0; --fd-ripple-radius: 28px;
719 transform: translate(calc(var(--pos) * 34px), calc(var(--pos) * -18px)) rotate(calc(var(--pos) * 4deg)) scale(calc(1 - var(--pos) * 0.06));
720 filter: brightness(calc(1 - var(--pos) * 0.12));
721 box-shadow: 0 1px 2px rgba(22,24,29,.1), 0 30px 60px -24px rgba(22,24,29,.45);
722 transition: transform 420ms var(--fd-spring), filter 420ms var(--fd-ease-out);
723 cursor: crosshair;
724}
725.fd-ripple-demo-cap {
726 position: absolute; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; margin: 0; padding: 72px 24px 22px;
727 background: linear-gradient(180deg, rgba(8,10,14,0) 0%, rgba(8,10,14,.55) 55%, rgba(8,10,14,.72) 100%); color: #FFFFFF;
728}
729.fd-ripple-demo-coords { margin: 0 0 8px; padding: 0; color: rgba(255,255,255,.72); font: 500 11px/1 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; letter-spacing: .08em; }
730.fd-ripple-demo-place { margin: 0; padding: 0; color: #FFFFFF; font: 700 2rem/1 "SF Pro Display", ui-sans-serif, -apple-system, "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.03em; }
731.fd-ripple-demo-country { margin: 6px 0 16px; padding: 0; color: rgba(255,255,255,.85); font: 500 15px/1.3 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; }
732.fd-ripple-demo-meta { display: flex; justify-content: space-between; margin: 0; padding: 14px 0 0; border-top: 1px solid rgba(255,255,255,.25); color: #FFFFFF; font: 500 13px/1 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; }
733.fd-ripple-demo-meta span { margin: 0; padding: 0; }
734@media (max-width: 860px) {
735 .fd-ripple-demo { grid-template-columns: 1fr; }
736 .fd-ripple-demo-copy { justify-self: start; }
737 .fd-ripple-demo-stack { justify-self: center; width: min(78%, 360px); margin-right: 40px; }
738}
739@media (prefers-reduced-motion: reduce) { .fd-ripple-demo-card { transition: none; } }
740`;

More Scroll & Hover Effects. Same file contract.

Scroll Split Card

A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.

Hover Transitions

Eight hover reveals for profile cards: morph blob, diagonal wipe, strips, pixel dissolve, iris, slices, focus and a liquid lens.

Sticky Storytelling

Apple-style scrollytelling: pinned media morphs between four states while text steps scroll past a progress rail.