← каталог
Image Ripple
Вода по фотографии на чистом canvas 2D: курсор гонит круги, клик роняет крупную каплю. Без WebGL и зависимостей — лёгкий вариант дисторсии.
cssmouse-moveclickmorphintermediate~3 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| image? | string | "https://motiva.pages.dev/img/1015-1600x… | URL картинки (демо-снимок каталога — замени на свой; хост должен отдавать CORS). |
| strength? | number | 320 | Глубина капли (формат «320»). |
| damping? | number | 5 | Затухание волны, 2–8: больше — дольше живёт (формат «5»). |
| scale? | number | 3 | Делитель разрешения буфера: больше — быстрее и «крупнее» волна (формат «3»). |
| click? | boolean | true | Клик — крупная капля. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/image-ripple.jsonЧто нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useRef } from "react";
export interface ImageRippleProps {
/** URL картинки (демо-снимок каталога — замени на свой; хост должен отдавать CORS). */
image?: string;
/** Глубина капли (формат «320»). */
strength?: number;
/** Затухание волны, 2–8: больше — дольше живёт (формат «5»). */
damping?: number;
/** Делитель разрешения буфера: больше — быстрее и «крупнее» волна (формат «3»). */
scale?: number;
/** Клик — крупная капля. */
click?: boolean;
}
/**
* Вода по фотографии на чистом canvas 2D — WebGL-free вариант дисторсии (ср. `image-distortion`).
* Схема «height-field»: два буфера высот, волна расходится и затухает, пиксели смещаются
* по градиенту высоты. rAF спит, когда вода успокоилась. Уважает `?noanim` и reduced-motion.
*/
export function ImageRipple({
image = "https://motiva.pages.dev/img/1015-1600x1000.jpg",
strength = 320,
damping = 5,
scale = 3,
click = true,
}: ImageRippleProps) {
const wrapRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const wrap = wrapRef.current;
const canvas = canvasRef.current;
if (!wrap || !canvas) return;
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const ctx = canvas.getContext("2d", { alpha: false })!;
const src = document.createElement("canvas");
const srcCtx = src.getContext("2d", { willReadFrequently: true })!;
let w = 0;
let h = 0;
let prev = new Int16Array(0);
let cur = new Int16Array(0);
let source: Uint32Array | null = null;
let out: ImageData | null = null;
let out32 = new Uint32Array(0);
let img: HTMLImageElement | null = null;
let raf = 0;
let running = false;
let disposed = false;
const render = () => {
if (!source || !out) return;
out32.set(source);
for (let y = 1; y < h - 1; y++) {
const row = y * w;
for (let x = 1; x < w - 1; x++) {
const i = row + x;
if (prev[i] === 0) continue;
// рефракция: выборку смещаем по наклону поверхности воды
const sx = x + ((prev[i - 1]! - prev[i + 1]!) >> 5);
const sy = y + ((prev[i - w]! - prev[i + w]!) >> 5);
if (sx < 0 || sx >= w || sy < 0 || sy >= h) continue;
out32[i] = source[sy * w + sx]!;
}
}
ctx.putImageData(out, 0, 0);
};
const build = () => {
const r = wrap.getBoundingClientRect();
w = Math.max(8, Math.round(r.width / scale));
h = Math.max(8, Math.round(r.height / scale));
canvas.width = w;
canvas.height = h;
src.width = w;
src.height = h;
prev = new Int16Array(w * h);
cur = new Int16Array(w * h);
out = ctx.createImageData(w, h);
out32 = new Uint32Array(out.data.buffer);
if (!img) return;
const ia = img.width / img.height;
const ca = w / h;
const dw = ia > ca ? h * ia : w;
const dh = ia > ca ? h : w / ia;
srcCtx.drawImage(img, (w - dw) / 2, (h - dh) / 2, dw, dh); // cover-fit
source = new Uint32Array(srcCtx.getImageData(0, 0, w, h).data.buffer);
render();
};
/** Шаг волнового уравнения; возвращает число «живых» ячеек. */
const step = () => {
let alive = 0;
for (let y = 1; y < h - 1; y++) {
const row = y * w;
for (let x = 1; x < w - 1; x++) {
const i = row + x;
const v = ((prev[i - 1]! + prev[i + 1]! + prev[i - w]! + prev[i + w]!) >> 1) - cur[i]!;
cur[i] = v - (v >> damping);
if (v > 4 || v < -4) alive++;
}
}
const t = prev;
prev = cur;
cur = t;
return alive;
};
const tick = () => {
const alive = step();
render();
if (alive === 0 || frozen) {
running = false; // вода успокоилась — спим до следующей капли
return;
}
raf = requestAnimationFrame(tick);
};
const wake = () => {
if (running || frozen) return;
running = true;
raf = requestAnimationFrame(tick);
};
const drop = (bx: number, by: number, power: number) => {
const r = 3;
for (let y = -r; y <= r; y++) {
for (let x = -r; x <= r; x++) {
const px = bx + x;
const py = by + y;
if (px < 1 || py < 1 || px >= w - 1 || py >= h - 1) continue;
if (x * x + y * y > r * r) continue;
prev[py * w + px] = power;
}
}
wake();
};
const toBuffer = (e: PointerEvent): [number, number] => {
const r = wrap.getBoundingClientRect();
return [
Math.round(((e.clientX - r.left) / r.width) * w),
Math.round(((e.clientY - r.top) / r.height) * h),
];
};
let lastX = -99;
let lastY = -99;
const onMove = (e: PointerEvent) => {
if (frozen || !source) return;
const [bx, by] = toBuffer(e);
// капли ставим не чаще, чем раз в 3 ячейки — иначе поле высот «забивается» и волна не читается
if (Math.abs(bx - lastX) + Math.abs(by - lastY) < 3) return;
lastX = bx;
lastY = by;
drop(bx, by, strength);
};
const onDown = (e: PointerEvent) => {
if (frozen || !source || !click) return;
drop(...toBuffer(e), strength * 3); // клик — крупная капля
};
const loader = new Image();
loader.crossOrigin = "anonymous";
loader.onload = () => {
if (disposed) return;
img = loader;
build();
};
loader.src = image;
const ro = new ResizeObserver(build);
ro.observe(wrap);
wrap.addEventListener("pointermove", onMove);
wrap.addEventListener("pointerdown", onDown);
return () => {
disposed = true;
cancelAnimationFrame(raf);
ro.disconnect();
wrap.removeEventListener("pointermove", onMove);
wrap.removeEventListener("pointerdown", onDown);
loader.onload = null;
};
}, [image, strength, damping, scale, click]);
return (
<div ref={wrapRef} style={{ position: "absolute", inset: 0, background: "#0b0b12" }}>
<canvas ref={canvasRef} style={{ display: "block", width: "100%", height: "100%", cursor: "crosshair" }} />
<div
style={{
position: "absolute",
left: 14,
bottom: 12,
fontFamily: "ui-monospace, monospace",
fontSize: "0.72rem",
color: "rgba(255,255,255,0.8)",
textShadow: "0 1px 6px rgba(0,0,0,0.7)",
pointerEvents: "none",
}}
>
веди курсором · клик — крупная капля
</div>
</div>
);
}
/**
* Image Ripple — вода по фотографии на чистом canvas 2D. Ноль зависимостей, без WebGL.
* Классическая схема «height-field»: два буфера высот, волна расходится и затухает,
* пиксели картинки смещаются по градиенту высоты (рефракция) + блик на склоне волны.
* Луп спит, когда вода успокоилась.
*
* <div id="pond" style="position:relative;width:100%;height:420px"></div>
* <script type="module">
* import { initImageRipple } from './image-ripple.vanilla.js';
* const destroy = initImageRipple(document.querySelector('#pond'), {
* image: 'https://motiva.pages.dev/img/1015-1600x1000.jpg', strength: 320,
* });
* </script>
*
* @param {HTMLElement} container — контейнер (position:relative)
* @param {{ image?: string, strength?: number, damping?: number, scale?: number, click?: boolean }} [options]
* strength — глубина капли (формат «320»), damping — затухание 2..8 (больше = дольше живёт),
* scale — делитель разрешения буфера (формат «3»; больше = быстрее и «крупнее» волна)
* @returns {() => void} destroy
*/
export function initImageRipple(
container,
{
image = "https://motiva.pages.dev/img/1015-1600x1000.jpg",
strength = 320,
damping = 5,
scale = 3,
click = true,
} = {},
) {
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const canvas = document.createElement("canvas");
canvas.style.cssText = "display:block;width:100%;height:100%;cursor:crosshair;";
container.appendChild(canvas);
const ctx = canvas.getContext("2d", { alpha: false });
/** @type {HTMLCanvasElement} офскрин с картинкой в разрешении буфера */
const src = document.createElement("canvas");
const srcCtx = src.getContext("2d", { willReadFrequently: true });
let w = 0;
let h = 0;
let prev; // высоты предыдущего шага
let cur; // высоты текущего
let source; // пиксели картинки (Uint32 для быстрого копирования)
let out; // ImageData вывода
let out32;
let img;
let raf = 0;
let running = false;
let disposed = false;
const build = () => {
const r = container.getBoundingClientRect();
w = Math.max(8, Math.round(r.width / scale));
h = Math.max(8, Math.round(r.height / scale));
canvas.width = w;
canvas.height = h;
src.width = w;
src.height = h;
prev = new Int16Array(w * h);
cur = new Int16Array(w * h);
out = ctx.createImageData(w, h);
out32 = new Uint32Array(out.data.buffer);
if (!img) return;
// cover-fit картинки в буфер
const ia = img.width / img.height;
const ca = w / h;
const dw = ia > ca ? h * ia : w;
const dh = ia > ca ? h : w / ia;
srcCtx.drawImage(img, (w - dw) / 2, (h - dh) / 2, dw, dh);
source = new Uint32Array(srcCtx.getImageData(0, 0, w, h).data.buffer);
render();
};
/** Один шаг волнового уравнения + рендер со смещением по градиенту высоты. */
const step = () => {
let alive = 0;
for (let y = 1; y < h - 1; y++) {
const row = y * w;
for (let x = 1; x < w - 1; x++) {
const i = row + x;
const v = ((prev[i - 1] + prev[i + 1] + prev[i - w] + prev[i + w]) >> 1) - cur[i];
cur[i] = v - (v >> damping); // затухание
if (v > 4 || v < -4) alive++;
}
}
const t = prev;
prev = cur;
cur = t;
return alive;
};
const render = () => {
if (!source) return;
out32.set(source);
for (let y = 1; y < h - 1; y++) {
const row = y * w;
for (let x = 1; x < w - 1; x++) {
const i = row + x;
const height = prev[i];
if (height === 0) continue;
// рефракция: смещаем выборку по наклону поверхности
const sx = x + ((prev[i - 1] - prev[i + 1]) >> 5);
const sy = y + ((prev[i - w] - prev[i + w]) >> 5);
if (sx < 0 || sx >= w || sy < 0 || sy >= h) continue;
out32[i] = source[sy * w + sx];
}
}
ctx.putImageData(out, 0, 0);
};
const tick = () => {
const alive = step();
render();
if (alive === 0 || frozen) {
running = false; // вода успокоилась — спим до следующей капли
return;
}
raf = requestAnimationFrame(tick);
};
const wake = () => {
if (running || frozen) return;
running = true;
raf = requestAnimationFrame(tick);
};
/** Капля радиусом r в точке буфера. */
const drop = (bx, by, power) => {
const r = 3;
for (let y = -r; y <= r; y++) {
for (let x = -r; x <= r; x++) {
const px = bx + x;
const py = by + y;
if (px < 1 || py < 1 || px >= w - 1 || py >= h - 1) continue;
if (x * x + y * y > r * r) continue;
prev[py * w + px] = power;
}
}
wake();
};
const toBuffer = (e) => {
const r = container.getBoundingClientRect();
return [Math.round(((e.clientX - r.left) / r.width) * w), Math.round(((e.clientY - r.top) / r.height) * h)];
};
let lastX = -99;
let lastY = -99;
const onMove = (e) => {
if (frozen || !source) return;
const [bx, by] = toBuffer(e);
// капли ставим не чаще, чем раз в 3 ячейки — иначе поле высот «забивается» и волна не читается
if (Math.abs(bx - lastX) + Math.abs(by - lastY) < 3) return;
lastX = bx;
lastY = by;
drop(bx, by, strength);
};
const onClick = (e) => {
if (frozen || !source || !click) return;
const [bx, by] = toBuffer(e);
drop(bx, by, strength * 3); // клик — крупная капля
};
const loader = new Image();
loader.crossOrigin = "anonymous";
loader.onload = () => {
if (disposed) return;
img = loader;
build();
};
loader.src = image;
const ro = new ResizeObserver(build);
ro.observe(container);
container.addEventListener("pointermove", onMove);
container.addEventListener("pointerdown", onClick);
return () => {
disposed = true;
cancelAnimationFrame(raf);
ro.disconnect();
container.removeEventListener("pointermove", onMove);
container.removeEventListener("pointerdown", onClick);
loader.onload = null;
canvas.remove();
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); классический height-field ripple (демосцена, ~1995)v1.0.0