Motiva
Мои коллекции
← каталог

Image Ripple

Вода по фотографии на чистом canvas 2D: курсор гонит круги, клик роняет крупную каплю. Без WebGL и зависимостей — лёгкий вариант дисторсии.

cssmouse-moveclickmorphintermediate~3 КБreduced-motion ✓
🎛 Настройки — крути и копируй

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
image?string"https://motiva.pages.dev/img/1015-1600x…URL картинки (демо-снимок каталога — замени на свой; хост должен отдавать CORS).
strength?number320Глубина капли (формат «320»).
damping?number5Затухание волны, 2–8: больше — дольше живёт (формат «5»).
scale?number3Делитель разрешения буфера: больше — быстрее и «крупнее» волна (формат «3»).
click?booleantrueКлик — крупная капля.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/image-ripple.json
Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); классический height-field ripple (демосцена, ~1995)v1.0.0