Liquid Glass
Сетка стеклянных плиток лежит поверх снимка и преломляет то, что под ними. Скругление и фаска заданы signed distance function прямо в шейдере, геометрии стекла нет вовсе: нормаль берётся из градиента поля, и содержимое сэмплируется со смещением по ней — середина плитки чистая, кромка уводит картинку и расходится в спектр дисперсией. Сетку можно тянуть мышью: снимок при этом стоит, а стекло по нему скользит, и после броска ещё покачивается, как желе.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/liquid-glass.jsonзависимости: three@^0.185.1 · @types/three@^0.185.0
Что нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useRef } from "react";
import { createLiquidGlass, type LiquidGlass as Engine, type LiquidGlassOptions } from "@/lib/liquid-glass-engine";
export type LiquidGlassProps = Omit<LiquidGlassOptions, "animate">;
/**
* Liquid Glass — сетка стеклянных плиток поверх снимка: кромки преломляют содержимое,
* каналы расходятся дисперсией, плитки тянутся драгом и покачиваются, как желе.
*
* Сцена — `createLiquidGlass` из соседнего файла; компонент держит холст, прокидывает
* крутилки через `set()` и разбирает всё при размонтировании.
* `prefers-reduced-motion` и `data-shot` рисуют один стоячий кадр.
*/
export function LiquidGlass(options: LiquidGlassProps = {}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const engine = useRef<Engine | null>(null);
const first = useRef(options);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const animate = !(
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
engine.current = createLiquidGlass(canvas, { ...first.current, animate });
return () => {
engine.current?.destroy();
engine.current = null;
};
}, []);
const { image, grid, radius, thickness, dispersion, gap, wobble, drift } = options;
useEffect(() => {
engine.current?.set({ image, grid, radius, thickness, dispersion, gap, wobble, drift });
}, [image, grid, radius, thickness, dispersion, gap, wobble, drift]);
return (
<div style={{ position: "absolute", inset: 0, overflow: "hidden", background: "#07070c" }}>
<canvas
ref={canvasRef}
aria-hidden="true"
style={{ display: "block", width: "100%", height: "100%", cursor: "grab", touchAction: "none" }}
/>
</div>
);
}
import * as THREE from "three";
/**
* Liquid Glass — движок на голом three.js (без React). Сетка стеклянных плиток лежит
* поверх снимка и ПРЕЛОМЛЯЕТ то, что под ними: скругление и фаска заданы 2D signed
* distance function прямо в шейдере, нормаль берётся из градиента этого поля, а цвет —
* из содержимого, сэмплированного со смещением по нормали. Геометрии стекла нет вовсе:
* вся сцена — один полноэкранный треугольник.
*
* `createLiquidGlass(canvas, options)` → { set(options), destroy() }.
* Цикл засыпает вне зоны видимости и на скрытой вкладке; `animate: false`
* (reduced-motion / `data-shot`) рисует один стоячий кадр.
*/
export interface LiquidGlassOptions {
/** Снимок под стеклом (тот же домен — читается как текстура). */
image?: string;
/** Плиток по ширине кадра. */
grid?: number;
/** Скругление углов плитки, доля полуширины. */
radius?: number;
/** Толщина стекла: насколько сильно кромка уводит содержимое. */
thickness?: number;
/** Дисперсия: расхождение каналов в преломлении. */
dispersion?: number;
/** Зазор между плитками. */
gap?: number;
/** Желейность: как долго сетка покачивается после броска. */
wobble?: number;
/** Скорость собственного дрейфа сетки; 0 — стоит, пока не потянут. */
drift?: number;
/** false — один стоячий кадр (reduced-motion, скриншоты). */
animate?: boolean;
}
export interface LiquidGlass {
set(next: Partial<LiquidGlassOptions>): void;
destroy(): void;
}
const DEFAULTS: Required<LiquidGlassOptions> = {
image: "https://motiva.pages.dev/img/1069-1200x1200.jpg",
grid: 6.0,
radius: 0.28,
thickness: 0.55,
dispersion: 0.35,
gap: 0.16,
wobble: 0.6,
drift: 0.035,
animate: true,
};
const VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
/**
* Приём целиком здесь.
*
* 1. Кадр делится на клетки; в клетке считается SDF скруглённого прямоугольника.
* Из SDF получается поле высоты плитки: 1 внутри, плавный спад на фаске, 0 в зазоре.
* 2. Нормаль стекла — градиент этого поля. Она ненулевая ТОЛЬКО на фаске, поэтому
* середина плитки остаётся прозрачной, а кромка уводит картинку — так стекло
* и читается глазом.
* 3. Содержимое сэмплируется со смещением по нормали (fake refraction), для каждого
* канала со своим коэффициентом: это дисперсия, из-за неё кромка играет спектром.
* 4. Поверх — френель и rim-light по той же нормали и блик от фиксированной лампы.
*
* Снимок при драге СТОИТ, а стекло по нему скользит — так ведёт себя стекло поверх
* содержимого (и так его узнают по системным интерфейсам 2026 года). В зазорах видно
* тот же снимок, но приглушённый и с тенью от кромки: стекло лежит на нём, а не висит
* отдельной картинкой.
*/
const FRAG = /* glsl */ `
precision highp float;
uniform sampler2D uTex;
uniform vec2 uRes;
uniform vec2 uTexSize;
uniform vec2 uOffset;
uniform vec2 uMouse;
uniform float uGrid;
uniform float uRadius;
uniform float uThickness;
uniform float uDispersion;
uniform float uGap;
uniform float uHover;
uniform float uTime;
varying vec2 vUv;
/** SDF скруглённого прямоугольника: <0 внутри, 0 на кромке, >0 снаружи. */
float roundedBox(vec2 p, vec2 half_, float r) {
vec2 q = abs(p) - half_ + r;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
/** cover-fit: картинка не растягивается под пропорции кадра. */
vec2 cover(vec2 uv) {
float frame = uRes.x / uRes.y;
float img = uTexSize.x / max(uTexSize.y, 1.0);
vec2 s = img > frame ? vec2(frame / img, 1.0) : vec2(1.0, img / frame);
return (uv - 0.5) * s + 0.5;
}
/** Поле высоты плитки в клеточных координатах: 1 внутри, спад на фаске, 0 в зазоре. */
float tileHeight(vec2 f, float half_, float r, float bevel) {
return smoothstep(0.0, -bevel, roundedBox(f, vec2(half_), r));
}
void main() {
float aspect = uRes.x / uRes.y;
// uGrid — плиток ПО ШИРИНЕ кадра; по высоте их получается меньше во столько же раз
vec2 g = vec2(vUv.x, vUv.y / aspect) * uGrid + uOffset;
vec2 f = fract(g) - 0.5;
float half_ = 0.5 - uGap * 0.5;
float r = clamp(uRadius, 0.0, 0.49) * half_ * 2.0;
// фаска: у стекла гнёт только кромка, но она должна быть ВИДНА — при слишком
// узкой фаске и малом зазоре плитки сливаются в одно матовое полотно
float bevel = 0.16;
float d = roundedBox(f, vec2(half_), r);
float h = tileHeight(f, half_, r, bevel);
// нормаль из градиента поля высоты; шаг в клеточных координатах — фаска
// не «худеет» на больших экранах
float e = uGrid / min(uRes.x, uRes.y) * 1.2;
float hx = tileHeight(f + vec2(e, 0.0), half_, r, bevel) - tileHeight(f - vec2(e, 0.0), half_, r, bevel);
float hy = tileHeight(f + vec2(0.0, e), half_, r, bevel) - tileHeight(f - vec2(0.0, e), half_, r, bevel);
vec3 n = normalize(vec3(-hx, -hy, e * 26.0));
// ЛИНЗА: содержимое внутри плитки чуть увеличено относительно её центра —
// без этого стекло читается как плоская наклейка, а не как толстая пластина
vec2 centerUv = vUv - vec2(f.x, f.y * aspect) / uGrid;
vec2 lensUv = mix(vUv, centerUv, 0.06 + 0.12 * uThickness);
vec2 uvTex = cover(lensUv);
// преломление: кромка уводит картинку по нормали, каналы расходятся дисперсией
vec2 bend = n.xy * uThickness * 0.065;
float disp = uDispersion * 0.3;
vec3 col = vec3(
texture2D(uTex, uvTex + bend * (1.0 + disp)).r,
texture2D(uTex, uvTex + bend).g,
texture2D(uTex, uvTex + bend * (1.0 - disp)).b
);
// МАТОВОСТЬ: четыре пробы по кресту, усреднённые с основной. Стекло без этого
// читается как пустая рамка — плита должна слегка размывать то, что под ней
float fr = (0.0016 + 0.0042 * uThickness);
vec3 frost = (
texture2D(uTex, uvTex + vec2(fr, 0.0)).rgb +
texture2D(uTex, uvTex - vec2(fr, 0.0)).rgb +
texture2D(uTex, uvTex + vec2(0.0, fr)).rgb +
texture2D(uTex, uvTex - vec2(0.0, fr)).rgb
) * 0.25;
col = mix(col, frost, 0.22);
// зазор: та же картинка, но приглушённая, плюс мягкая тень под кромкой плитки —
// стекло лежит на снимке, а не висит отдельной картинкой
float shadow = smoothstep(0.3, 0.0, d) * 0.6;
vec3 under = texture2D(uTex, cover(vUv)).rgb * (0.6 - shadow * 0.45);
// френель по кромке + блик от лампы сверху-слева
vec3 viewDir = vec3(0.0, 0.0, 1.0);
float fres = pow(1.0 - max(dot(n, viewDir), 0.0), 3.0);
vec3 lamp = normalize(vec3(-0.45, 0.8, 0.6));
float spec = pow(max(dot(reflect(-lamp, n), viewDir), 0.0), 60.0);
// резкая кромка: тонкая светлая линия точно по краю пластины. Без неё стекло
// выглядит размазанным, а не толстым — именно кромка читается как «толщина»
// двойной контур кромки: светлая линия снаружи и тонкая тёмная внутри —
// так край читается и на светлом фоне, и на тёмном
float rim = exp(-abs(d + 0.004) * 90.0);
float innerLine = exp(-abs(d + 0.022) * 60.0);
// поглощение в толще у фаски: под кромкой стекло темнее, как настоящее
float absorb = smoothstep(0.0, -bevel * 1.6, d) - smoothstep(0.0, -bevel * 0.4, d);
vec3 lampB = normalize(vec3(0.55, -0.5, 0.65));
float specB = pow(max(dot(reflect(-lampB, n), viewDir), 0.0), 28.0);
col *= 1.0 - absorb * 0.22;
col += fres * vec3(0.74, 0.84, 1.0) * 0.4;
col += rim * vec3(0.9, 0.95, 1.0) * 0.85;
col *= 1.0 - innerLine * 0.28;
col += spec * vec3(1.0) * 0.9 + specB * vec3(0.78, 0.86, 1.0) * 0.35;
// плитка под курсором чуть светлее: наведение должно быть видно
col += uHover * exp(-dot(f, f) * 3.0) * 0.05;
col = mix(under, col, h);
col = vec3(1.0) - exp(-col * 1.2); // тональная компрессия вместо clamp
gl_FragColor = vec4(col, 1.0);
}
`;
export function createLiquidGlass(canvas: HTMLCanvasElement, options: LiquidGlassOptions = {}): LiquidGlass {
const cfg: Required<LiquidGlassOptions> = { ...DEFAULTS, ...options };
const animate = cfg.animate;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
const uniforms = {
uTex: { value: null as THREE.Texture | null },
uRes: { value: new THREE.Vector2(1, 1) },
uTexSize: { value: new THREE.Vector2(1, 1) },
uOffset: { value: new THREE.Vector2(0, 0) },
uMouse: { value: new THREE.Vector2(0.5, 0.5) },
uGrid: { value: cfg.grid },
uRadius: { value: cfg.radius },
uThickness: { value: cfg.thickness },
uDispersion: { value: cfg.dispersion },
uGap: { value: cfg.gap },
uHover: { value: 0 },
uTime: { value: 0 },
};
const scene = new THREE.Scene();
const camera = new THREE.Camera();
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, uniforms });
scene.add(new THREE.Mesh(geometry, material));
let texture: THREE.Texture | undefined;
let disposed = false;
const loader = new THREE.TextureLoader();
loader.setCrossOrigin("anonymous");
loader.load(cfg.image, (t) => {
if (disposed) {
t.dispose();
return;
}
t.colorSpace = THREE.SRGBColorSpace;
t.wrapS = t.wrapT = THREE.MirroredRepeatWrapping;
texture = t;
uniforms.uTex.value = t;
const img = t.image as { width: number; height: number };
uniforms.uTexSize.value.set(img.width, img.height);
frame();
});
function resize(): void {
const w = canvas.clientWidth || window.innerWidth;
const h = canvas.clientHeight || window.innerHeight;
renderer.setSize(w, h, false);
uniforms.uRes.value.set(w, h);
}
resize();
/* -------- драг с инерцией и желейным доездом */
const drag = { active: false, lastX: 0, lastY: 0, vx: 0, vy: 0 };
const offset = { x: 0, y: 0 };
const spring = { x: 0, y: 0, vx: 0, vy: 0 }; // покачивание после броска
let hover = 0;
const toGrid = (dxPx: number, dyPx: number) => {
// клетка считается от ШИРИНЫ кадра, поэтому и шаг драга переводим по ширине
const w = canvas.clientWidth || 1;
return { x: (dxPx / w) * cfg.grid, y: (-dyPx / w) * cfg.grid };
};
const onDown = (e: PointerEvent) => {
if (!animate) return;
drag.active = true;
drag.lastX = e.clientX;
drag.lastY = e.clientY;
drag.vx = 0;
drag.vy = 0;
canvas.setPointerCapture?.(e.pointerId);
};
const onMove = (e: PointerEvent) => {
if (!animate) return;
const r = canvas.getBoundingClientRect();
uniforms.uMouse.value.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height);
hover = 1;
if (!drag.active) return;
const step = toGrid(e.clientX - drag.lastX, e.clientY - drag.lastY);
offset.x -= step.x;
offset.y -= step.y;
// бросок раскачивает пружину: чем резче тянули, тем сильнее желе
spring.vx += step.x * 0.35;
spring.vy += step.y * 0.35;
drag.vx = step.x;
drag.vy = step.y;
drag.lastX = e.clientX;
drag.lastY = e.clientY;
};
const onUp = () => {
drag.active = false;
};
const onLeave = () => {
drag.active = false;
hover = 0;
};
canvas.addEventListener("pointerdown", onDown);
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerup", onUp);
canvas.addEventListener("pointercancel", onUp);
canvas.addEventListener("pointerleave", onLeave);
let raf = 0;
let last = 0;
function frame(now = 0): void {
const dt = animate && last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
uniforms.uTime.value += dt;
uniforms.uGrid.value = cfg.grid;
uniforms.uRadius.value = cfg.radius;
uniforms.uThickness.value = cfg.thickness;
uniforms.uDispersion.value = cfg.dispersion;
uniforms.uGap.value = cfg.gap;
if (!drag.active) {
// инерция броска и собственный дрейф по диагонали
offset.x -= drag.vx * 0.92 * (dt * 60);
offset.y -= drag.vy * 0.92 * (dt * 60);
drag.vx *= 0.9;
drag.vy *= 0.9;
offset.x += cfg.drift * dt * cfg.grid * 0.6;
offset.y += cfg.drift * dt * cfg.grid * 0.25;
}
// пружина: чем выше «желейность», тем мягче возврат и дольше покачивание
const stiff = 90 - 55 * Math.min(cfg.wobble, 1);
const damp = 12 - 7 * Math.min(cfg.wobble, 1);
spring.vx += (-spring.x * stiff - spring.vx * damp) * dt;
spring.vy += (-spring.y * stiff - spring.vy * damp) * dt;
spring.x += spring.vx * dt;
spring.y += spring.vy * dt;
uniforms.uHover.value += (hover - uniforms.uHover.value) * 0.08;
uniforms.uOffset.value.set(offset.x + spring.x, offset.y + spring.y);
renderer.render(scene, camera);
}
const loop = (now: number) => {
frame(now);
raf = requestAnimationFrame(loop);
};
const start = () => {
if (raf || !animate) return;
last = 0;
raf = requestAnimationFrame(loop);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const ro = new ResizeObserver(() => {
resize();
frame();
});
ro.observe(canvas);
const io = new IntersectionObserver(([e]) => (e!.isIntersecting && !document.hidden ? start() : stop()));
io.observe(canvas);
const onVis = () => (document.hidden ? stop() : start());
document.addEventListener("visibilitychange", onVis);
const onLost = (e: Event) => {
e.preventDefault();
stop();
};
canvas.addEventListener("webglcontextlost", onLost);
frame();
start();
return {
set(next) {
// undefined в пропсах React — «не задано», а не «сбросить»
const patch = Object.fromEntries(Object.entries(next).filter(([, v]) => v !== undefined)) as Partial<LiquidGlassOptions>;
const reload = patch.image !== undefined && patch.image !== cfg.image;
Object.assign(cfg, patch);
if (reload) {
loader.load(cfg.image, (t) => {
if (disposed) return;
t.colorSpace = THREE.SRGBColorSpace;
t.wrapS = t.wrapT = THREE.MirroredRepeatWrapping;
texture?.dispose();
texture = t;
uniforms.uTex.value = t;
const img = t.image as { width: number; height: number };
uniforms.uTexSize.value.set(img.width, img.height);
frame();
});
}
if (!animate) frame();
},
destroy() {
disposed = true;
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
canvas.removeEventListener("pointerdown", onDown);
canvas.removeEventListener("pointermove", onMove);
canvas.removeEventListener("pointerup", onUp);
canvas.removeEventListener("pointercancel", onUp);
canvas.removeEventListener("pointerleave", onLeave);
canvas.removeEventListener("webglcontextlost", onLost);
geometry.dispose();
material.dispose();
texture?.dispose();
renderer.dispose();
renderer.forceContextLoss();
},
};
}