Scroll Split Card
A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.
Image cards that behave like water — your cursor drags a live wave simulation that refracts the photo with a chromatic edge.
Browser support · WebGL2 / WebGL1, no float-texture extensions needed. Without WebGL the image is shown still.
npx shadcn@latest add https://freedesignmd.com/r/image-ripple.json1/**2 * ImageRipple — an image card that behaves like a shallow pool. The cursor3 * 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 the5 * surface refracts the image with a slight chromatic split and a wet sheen.6 *7 * Props8 * - 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, strength43uniform 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 shading103 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 image154 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-deps189 }, [src, draw, drawSize[0], drawSize[1], failed]);190 191 // GL192 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 independent337 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 → wake407 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 wake431 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 layers536 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 lake554 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 + glints566 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 <ImageRipple660 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`;A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.
Eight hover reveals for profile cards: morph blob, diagonal wipe, strips, pixel dissolve, iris, slices, focus and a liquid lens.
Apple-style scrollytelling: pinned media morphs between four states while text steps scroll past a progress rail.