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

Displacement Slider

Слайдер, у которого кадры не сменяют друг друга, а растекаются один в другой: оба берутся из смещённых координат, поэтому ничего не гаснет и в середине перехода картинка остаётся резкой. Характер перехода живёт не в коде, а в КАРТЕ — маленькой текстуре с вектором смещения на точку; шейдер один на все четыре варианта.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
slides?string[]["https://motiva.pages.dev/img/1015-900x…Кадры слайдера (формат «["https://motiva.pages.dev/img/1015-900x700.jpg"]»).
labels?string[]["Фьорд", "Побережье", "Дорога"]Подписи к кадрам — для кнопок и для диктора.
pattern?DisplacementPattern"noise"Карта искажения: она и задаёт ХАРАКТЕР перехода.
strength?number0.35Насколько далеко карта уводит пиксели, доля кадра (формат «0.35»).
duration?number1.1Длительность перехода, с (формат «1.1»).
fromClick?booleantrueПереход расходится волной ИЗ ТОЧКИ КЛИКА, а не по всему кадру разом.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/displacement-slider.json

зависимости: ogl

Что нужно в проекте до установки
  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, useState } from "react";
import { Mesh, Program, Renderer, Texture, Triangle } from "ogl";

export type DisplacementPattern = "noise" | "radial" | "swirl" | "slats" | "ripples" | "mosaic";

export interface DisplacementSliderProps {
  /** Кадры слайдера (формат «["https://motiva.pages.dev/img/1015-900x700.jpg"]»). */
  slides?: string[];
  /** Подписи к кадрам — для кнопок и для диктора. */
  labels?: string[];
  /** Карта искажения: она и задаёт ХАРАКТЕР перехода. */
  pattern?: DisplacementPattern;
  /** Насколько далеко карта уводит пиксели, доля кадра (формат «0.35»). */
  strength?: number;
  /** Длительность перехода, с (формат «1.1»). */
  duration?: number;
  /** Переход расходится волной ИЗ ТОЧКИ КЛИКА, а не по всему кадру разом. */
  fromClick?: boolean;
}

const VERT = /* glsl */ `
  attribute vec2 uv;
  attribute vec2 position;
  varying vec2 vUv;
  void main() { vUv = uv; gl_Position = vec4(position, 0.0, 1.0); }
`;

/**
 * Весь переход — три строки: взять из карты вектор и сместить по нему выборку ДВУХ кадров
 * в противоположные стороны. Ни один пиксель не «затухает» — он уезжает, и на его место
 * приезжает пиксель второго кадра.
 */
const FRAG = /* glsl */ `
  precision highp float;
  uniform sampler2D uA, uB, uDisp;
  uniform vec2 uRes, uImgRes, uOrigin;
  uniform float uProgress, uStrength, uFromClick, uMaxDist, uAspect;
  varying vec2 vUv;

  // вписать кадр по большей стороне: иначе фото растягивает под пропорции окна
  vec2 cover(vec2 uv) {
    float rs = uRes.x / uRes.y;
    float ri = uImgRes.x / uImgRes.y;
    vec2 s = vec2(min(rs / ri, 1.0), min(ri / rs, 1.0));
    return (uv - 0.5) * s + 0.5;
  }

  void main() {
    float p = smoothstep(0.0, 1.0, uProgress);
    // Локальный прогресс: в режиме «из точки клика» переход — фронт, расходящийся
    // волной из uOrigin. За фронтом уже новый кадр, перед ним ещё старый; ширина
    // фронта 0.35 в долях высоты. uMaxDist гарантирует, что к p=1 фронт накрыл
    // самый дальний угол при ЛЮБОМ положении клика и любых пропорциях кадра.
    float lp = p;
    if (uFromClick > 0.5) {
      vec2 rel = (vUv - uOrigin) * vec2(uAspect, 1.0);
      float dist = length(rel);
      lp = smoothstep(dist, dist + 0.35, p * (uMaxDist + 0.35));
    }
    // карта хранит ВЕКТОР: r — сдвиг по x, g — по y, оба вокруг 0.5
    vec2 d = texture2D(uDisp, vUv).rg * 2.0 - 1.0;
    // у кромки увод гасим в ноль: иначе выборка уходит за край кадра, край размазывается
    // в длинные полосы (CLAMP_TO_EDGE повторяет последний пиксель), и переход выглядит
    // не растеканием, а браком печати
    vec2 e = smoothstep(vec2(0.0), vec2(0.16), vUv) * smoothstep(vec2(0.0), vec2(0.16), 1.0 - vUv);
    d *= e.x * e.y;
    vec2 base = cover(vUv);
    vec4 a = texture2D(uA, base + d * uStrength * lp);
    vec4 b = texture2D(uB, base - d * uStrength * (1.0 - lp));
    gl_FragColor = vec4(mix(a.rgb, b.rgb, lp), 1.0);
  }
`;

/** Карта искажения размером N×N: r/g — вектор смещения, закодированный вокруг 0.5. */
function buildMap(kind: DisplacementPattern, N = 128) {
  const px = new Uint8Array(N * N * 4);
  const hash = (x: number, y: number) => {
    const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
    return s - Math.floor(s);
  };
  // значение-шум: решётка случайных чисел + сглаженная интерполяция между узлами
  const noise = (u: number, v: number, f: number) => {
    const gx = u * f;
    const gy = v * f;
    const x0 = Math.floor(gx);
    const y0 = Math.floor(gy);
    const fx = gx - x0;
    const fy = gy - y0;
    const sx = fx * fx * (3 - 2 * fx);
    const sy = fy * fy * (3 - 2 * fy);
    const a = hash(x0, y0);
    const b = hash(x0 + 1, y0);
    const c = hash(x0, y0 + 1);
    const e = hash(x0 + 1, y0 + 1);
    return a * (1 - sx) * (1 - sy) + b * sx * (1 - sy) + c * (1 - sx) * sy + e * sx * sy;
  };

  for (let y = 0; y < N; y++) {
    for (let x = 0; x < N; x++) {
      const u = x / N;
      const v = y / N;
      let dx = 0;
      let dy = 0;
      if (kind === "noise") {
        dx = noise(u, v, 4) * 2 - 1;
        dy = noise(u + 5.7, v + 2.3, 4) * 2 - 1;
      } else if (kind === "radial") {
        dx = (u - 0.5) * 2;
        dy = (v - 0.5) * 2;
      } else if (kind === "swirl") {
        dx = -(v - 0.5) * 2;
        dy = (u - 0.5) * 2;
      } else if (kind === "ripples") {
        // кольца: направление от центра, знак меняется синусом по расстоянию — вода
        const cx = u - 0.5;
        const cy = v - 0.5;
        const r = Math.hypot(cx, cy);
        const s = Math.sin(r * Math.PI * 10);
        dx = r > 0 ? (cx / r) * s : 0;
        dy = r > 0 ? (cy / r) * s : 0;
      } else if (kind === "mosaic") {
        // мозаика: у каждой клетки 10×10 своё случайное направление — кадр рассыпается плитками
        const ang = hash(Math.floor(u * 10), Math.floor(v * 10)) * Math.PI * 2;
        dx = Math.cos(ang);
        dy = Math.sin(ang);
      } else {
        // рейки: соседние полосы уезжают в разные стороны
        dx = Math.floor(u * 12) % 2 ? 1 : -1;
        dy = 0;
      }
      const i = (y * N + x) * 4;
      px[i] = Math.round((dx * 0.5 + 0.5) * 255);
      px[i + 1] = Math.round((dy * 0.5 + 0.5) * 255);
      px[i + 2] = 0;
      px[i + 3] = 255;
    }
  }
  return { px, N };
}

/**
 * Слайдер, у которого кадры не сменяют друг друга, а РАСТЕКАЮТСЯ один в другой.
 *
 * Обычный кроссфейд смешивает два изображения в одной и той же точке: пиксель гаснет,
 * на его месте проступает другой, и всё промежуточное время на экране мутная каша из двух
 * кадров сразу. Здесь оба кадра берутся из СМЕЩЁННЫХ координат — уходящий уезжает по карте,
 * приходящий приезжает ей навстречу. Ничего не гаснет, поэтому в середине перехода картинка
 * остаётся резкой: видно движение, а не полупрозрачность.
 *
 * Главное в записи — где живёт характер перехода. Он не в коде: шейдер один и тот же для
 * всех четырёх вариантов, а меняется только КАРТА — маленькая текстура, в которой `r` и `g`
 * это вектор смещения для каждой точки. Шум даёт растекание, радиальная карта — разлёт
 * от центра, вихрь — закрутку, рейки — сдвиг полосами. Переход тут данные, а не алгоритм,
 * и добавить пятый вариант значит нарисовать пятую картинку, а не написать пятую ветку.
 *
 * Карты считаются процедурно при монтировании (128×128, четыре штуки — доли миллисекунды),
 * поэтому в поставке нет ни одного лишнего файла.
 *
 * Цикл кадров живёт только во время перехода: доехал — засыпает. В покое на экране
 * неподвижное фото, и пересчитывать в нём нечего.
 */
export function DisplacementSlider({
  slides = ["https://motiva.pages.dev/img/1015-900x700.jpg", "https://motiva.pages.dev/img/1039-900x700.jpg", "https://motiva.pages.dev/img/491-900x700.jpg"],
  labels = ["Фьорд", "Побережье", "Дорога"],
  pattern = "noise",
  strength = 0.35,
  duration = 1.1,
  fromClick = true,
}: DisplacementSliderProps) {
  const host = useRef<HTMLDivElement>(null);
  const [index, setIndex] = useState(0);
  const goto = useRef<((i: number, origin?: [number, number]) => void) | null>(null);
  const cfg = useRef({ pattern, strength, duration, slides, fromClick });
  cfg.current = { pattern, strength, duration, slides, fromClick };

  useEffect(() => {
    const el = host.current;
    if (!el) return;
    const frozen =
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const renderer = new Renderer({ alpha: false, antialias: false, dpr: Math.min(2, window.devicePixelRatio || 1) });
    const gl = renderer.gl;
    Object.assign(gl.canvas.style, { display: "block", width: "100%", height: "100%" });
    el.appendChild(gl.canvas);

    const maps: Record<string, Texture> = {};
    for (const kind of ["noise", "radial", "swirl", "slats", "ripples", "mosaic"] as DisplacementPattern[]) {
      const { px, N } = buildMap(kind);
      maps[kind] = new Texture(gl, {
        image: px,
        width: N,
        height: N,
        generateMipmaps: false,
        minFilter: gl.LINEAR,
        magFilter: gl.LINEAR,
        wrapS: gl.CLAMP_TO_EDGE,
        wrapT: gl.CLAMP_TO_EDGE,
      });
    }

    const blank = new Texture(gl, { generateMipmaps: false });
    const textures = cfg.current.slides.map(() => new Texture(gl, {
      generateMipmaps: false,
      wrapS: gl.CLAMP_TO_EDGE,
      wrapT: gl.CLAMP_TO_EDGE,
    }));
    let imgRes: [number, number] = [1, 1];

    const program = new Program(gl, {
      vertex: VERT,
      fragment: FRAG,
      uniforms: {
        uA: { value: blank },
        uB: { value: blank },
        uDisp: { value: maps[cfg.current.pattern] ?? maps.noise },
        uRes: { value: [1, 1] },
        uImgRes: { value: [1, 1] },
        uProgress: { value: 0 },
        uStrength: { value: cfg.current.strength },
        uOrigin: { value: [0.5, 0.5] },
        uFromClick: { value: 0 },
        uMaxDist: { value: 1 },
        uAspect: { value: 1 },
      },
    });
    const mesh = new Mesh(gl, { geometry: new Triangle(gl), program });

    const resize = () => {
      const { clientWidth: w, clientHeight: h } = el;
      if (!w || !h) return;
      renderer.setSize(w, h);
      program.uniforms.uRes!.value = [w, h];
      program.uniforms.uAspect!.value = w / h;
      draw();
    };
    const draw = () => {
      program.uniforms.uDisp!.value = maps[cfg.current.pattern] ?? maps.noise;
      program.uniforms.uStrength!.value = cfg.current.strength;
      program.uniforms.uImgRes!.value = imgRes;
      renderer.render({ scene: mesh });
    };

    cfg.current.slides.forEach((src, i) => {
      const img = new Image();
      img.crossOrigin = "anonymous";
      img.onload = () => {
        textures[i]!.image = img;
        if (i === 0) {
          imgRes = [img.width, img.height];
          program.uniforms.uA!.value = textures[0]!;
          program.uniforms.uB!.value = textures[0]!;
        }
        draw();
      };
      img.src = src;
    });

    let raf = 0;
    let from = 0;
    let to = 0;
    let t0 = 0;

    const tick = (now: number) => {
      const dur = Math.max(0.15, cfg.current.duration) * 1000;
      const p = Math.min(1, (now - t0) / dur);
      program.uniforms.uProgress!.value = p;
      draw();
      if (p < 1) {
        raf = requestAnimationFrame(tick);
      } else {
        // переход доехал: держим цель обоими кадрами, чтобы дальше рисовать было нечего
        raf = 0;
        from = to;
        program.uniforms.uA!.value = textures[to]!;
        program.uniforms.uB!.value = textures[to]!;
        program.uniforms.uProgress!.value = 0;
        draw();
      }
    };

    goto.current = (i: number, origin?: [number, number]) => {
      const n = textures.length;
      const next = ((i % n) + n) % n;
      if (next === from && !raf) return;
      cancelAnimationFrame(raf);
      to = next;
      program.uniforms.uA!.value = textures[from]!;
      program.uniforms.uB!.value = textures[to]!;
      // режим «из точки клика»: фронт стартует там, куда нажали (точки-навигация
      // и клавиатура дают центр); uMaxDist — до самого дальнего угла в долях высоты,
      // чтобы к концу перехода фронт накрыл кадр при любом положении клика
      if (cfg.current.fromClick) {
        const [ou, ov] = origin ?? [0.5, 0.5];
        const a = (el.clientWidth || 1) / (el.clientHeight || 1);
        const maxDist = Math.max(
          Math.hypot(ou * a, ov),
          Math.hypot((1 - ou) * a, ov),
          Math.hypot(ou * a, 1 - ov),
          Math.hypot((1 - ou) * a, 1 - ov),
        );
        program.uniforms.uOrigin!.value = [ou, ov];
        program.uniforms.uMaxDist!.value = maxDist;
        program.uniforms.uFromClick!.value = 1;
      } else {
        program.uniforms.uFromClick!.value = 0;
      }
      if (frozen) {
        // статичный режим: показываем цель без перехода
        from = to;
        program.uniforms.uA!.value = textures[to]!;
        program.uniforms.uProgress!.value = 0;
        draw();
        return;
      }
      t0 = performance.now();
      program.uniforms.uProgress!.value = 0;
      raf = requestAnimationFrame(tick);
    };

    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(el);
    const onVis = () => document.hidden && cancelAnimationFrame(raf);
    document.addEventListener("visibilitychange", onVis);

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      goto.current = null;
      gl.getExtension("WEBGL_lose_context")?.loseContext();
      gl.canvas.remove();
    };
  }, []);

  const step = (delta: number, origin?: [number, number]) => {
    const n = slides.length;
    const next = ((index + delta) % n + n) % n;
    setIndex(next);
    goto.current?.(next, origin);
  };

  return (
    <div className="dsl" style={{ position: "absolute", inset: 0, overflow: "clip" }}>
      <style>{`
        .dsl-hit { position: absolute; inset: 0; border: 0; padding: 0; background: none;
          cursor: pointer; appearance: none; }
        .dsl-dots { position: absolute; left: 50%; bottom: 16px; translate: -50% 0;
          display: flex; gap: 8px; padding: 7px 10px; border-radius: var(--ds-radius-pill, 999px);
          background: color-mix(in oklab, oklch(0.14 0.02 275) 62%, transparent);
          backdrop-filter: blur(10px); }
        .dsl-dots button { width: 9px; height: 9px; padding: 0; border-radius: 50%;
          border: 1px solid var(--ds-border, oklch(0.6 0.02 275)); background: none;
          cursor: pointer; transition: background var(--ds-fast, 0.18s), scale var(--ds-fast, 0.18s); }
        .dsl-dots button[aria-current="true"] { background: var(--ds-accent, oklch(0.72 0.16 300));
          border-color: transparent; scale: 1.25; }
        .dsl-dots button:focus-visible { outline: 2px solid var(--ds-accent, oklch(0.72 0.16 300));
          outline-offset: 3px; }
      `}</style>

      <div ref={host} style={{ position: "absolute", inset: 0 }} />

      {/* клик по кадру — следующий; настоящая кнопка, поэтому работает и с клавиатуры.
          Координата клика уходит в переход (fromClick); у клавиатурной активации
          detail = 0 и координат нет — фронт пойдёт из центра. */}
      <button
        className="dsl-hit"
        type="button"
        onClick={(e) => {
          const r = e.currentTarget.getBoundingClientRect();
          const hasPoint = e.detail > 0 && r.width > 0;
          step(
            1,
            hasPoint
              ? [(e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height]
              : undefined,
          );
        }}
        aria-label="Следующий кадр"
      />

      <div className="dsl-dots">
        {slides.map((_, i) => (
          <button
            key={i}
            type="button"
            aria-current={i === index}
            aria-label={labels[i] ?? `Кадр ${i + 1}`}
            onClick={() => {
              setIndex(i);
              goto.current?.(i);
            }}
          />
        ))}
      </div>
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идеи-референсы — https://codepen.io/wolfrob35/pen/ZdYmQx и https://codepen.io/sarvarkhujamurodov/pen/jOEmgWP (лицензии не указаны, код не заимствован)v1.0.0