← каталог
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? | number | 0.35 | Насколько далеко карта уводит пиксели, доля кадра (формат «0.35»). |
| duration? | number | 1.1 | Длительность перехода, с (формат «1.1»). |
| fromClick? | boolean | true | Переход расходится волной ИЗ ТОЧКИ КЛИКА, а не по всему кадру разом. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/displacement-slider.jsonзависимости: ogl
Что нужно в проекте до установки
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, 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