Glass Lens
Живая капля стекла тянется за курсором, увеличивает и преломляет снимок под собой, на рывке вытягивается нитью и рвётся на капли, а в покое стягивается обратно в купол. Форма считается каждый кадр: между соседними каплями натянута капсула с переменным радиусом (это нить, она тончает с расстоянием и обрывается), голова вытянута вдоль скорости с сохранением площади, а движение считает пружинная цепочка с инерцией — вязкость задаёт жёсткость пружины и затухание, от воды до мёда. Вторая версия приёма «жидкое стекло»: у liquid-glass форма задана решёткой плиток.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/glass-lens.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 { createGlassLens, type GlassLens as Engine, type GlassLensOptions } from "@/lib/glass-lens-engine";
export type GlassLensProps = Omit<GlassLensOptions, "animate">;
/**
* Glass Lens — живая стеклянная линза ходит за курсором и на броске рвётся на капли:
* их поля сливаются плавным минимумом, поэтому вблизи между каплями тянется перемычка.
* Содержимое под стеклом увеличивается и преломляется по нормали этой формы.
*
* Вторая версия приёма «жидкое стекло»: у `liquid-glass` форма задана решёткой плиток,
* здесь она каждый кадр считается из физики цепочки капель.
*
* Сцена — `createGlassLens` из соседнего файла; компонент держит холст, прокидывает
* крутилки через `set()` и разбирает всё при размонтировании.
* `prefers-reduced-motion` и `data-shot` рисуют один стоячий кадр.
*/
export function GlassLens(options: GlassLensProps = {}) {
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 = createGlassLens(canvas, { ...first.current, animate });
return () => {
engine.current?.destroy();
engine.current = null;
};
}, []);
const { image, radius, thickness, zoom, dispersion, drops, viscosity, frost } = options;
useEffect(() => {
engine.current?.set({ image, radius, thickness, zoom, dispersion, drops, viscosity, frost });
}, [image, radius, thickness, zoom, dispersion, drops, viscosity, frost]);
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: "none", touchAction: "none" }}
/>
</div>
);
}
import * as THREE from "three";
/**
* Glass Lens — движок на голом three.js (без React). Вторая версия приёма «жидкое
* стекло»: вместо неподвижной сетки плиток (`liquid-glass`) здесь ЖИВАЯ КАПЛЯ стекла,
* которая тянется за курсором, вытягивается нитью и рвётся на капли, а в покое
* стягивается обратно в один купол.
*
* `createGlassLens(canvas, options)` → { set(options), destroy() }.
* Цикл засыпает вне зоны видимости и на скрытой вкладке; `animate: false`
* (reduced-motion / `data-shot`) рисует один стоячий кадр.
*/
export interface GlassLensOptions {
/** Снимок под стеклом. */
image?: string;
/** Радиус линзы, доля высоты кадра. */
radius?: number;
/** Толщина стекла: насколько кромка уводит содержимое. */
thickness?: number;
/** Увеличение внутри линзы. */
zoom?: number;
/** Дисперсия по кромке. */
dispersion?: number;
/** Капель в хвосте: 0 — одна линза без хвоста. */
drops?: number;
/** Вязкость: 0 — вода (хвост резко отстаёт и рвётся), 1 — мёд (тянется нитью). */
viscosity?: number;
/** Матовость стекла. */
frost?: number;
/** false — один стоячий кадр (reduced-motion, скриншоты). */
animate?: boolean;
}
export interface GlassLens {
set(next: Partial<GlassLensOptions>): void;
destroy(): void;
}
const DEFAULTS: Required<GlassLensOptions> = {
image: "https://motiva.pages.dev/img/1069-1200x1200.jpg",
radius: 0.3,
thickness: 0.6,
zoom: 1.6,
dispersion: 0.45,
drops: 3,
viscosity: 0.6,
frost: 0.12,
animate: true,
};
/** Капель в шейдере фиксированное число: голова плюс хвост. */
const MAX_BLOBS = 5;
const MAX_LINKS = MAX_BLOBS - 1;
const VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
/**
* Форма стекла считается заново каждый кадр, и три вещи делают её похожей на жидкость,
* а не на набор кругов (первая версия страдала ровно этим — владелец 13.09: «тягучесть
* накладывает артефакты и не чувствуется реальная жидкость»):
*
* 1. МЕЖДУ соседними каплями натянута капсула с переменным радиусом — это нить.
* Её толщина падает с расстоянием и обрывается, когда капли разошлись. Раньше
* перемычка получалась только из плавного минимума кругов и на разрыве схлопывалась
* ступенькой.
* 2. Голова вытянута ВДОЛЬ СКОРОСТИ с сохранением площади: на рывке капля физически
* удлиняется и поджимается поперёк, а не просто съезжает.
* 3. Центр увеличения — взвешенное среднее по всем каплям, а не «ближайшая». У ближайшей
* центр менялся скачком на границе между каплями, и это читалось швом внутри стекла.
*/
const FRAG = /* glsl */ `
precision highp float;
uniform sampler2D uTex;
uniform vec2 uRes;
uniform vec2 uTexSize;
uniform vec3 uBlobs[${MAX_BLOBS}]; // xy — центр, z — радиус (0 — капли нет)
uniform float uLinks[${MAX_LINKS}]; // толщина нити между i и i+1, 0 — нить порвана
uniform vec2 uStretch; // направление вытяжения головы, длина — сила
uniform float uThickness;
uniform float uZoom;
uniform float uDispersion;
uniform float uFrost;
varying vec2 vUv;
float smin(float a, float b, float k) {
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
/** Капсула с переменным радиусом: из неё получается нить между каплями. */
float sdLink(vec2 p, vec2 a, vec2 b, float ra, float rb) {
vec2 pa = p - a;
vec2 ba = b - a;
float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-6), 0.0, 1.0);
return length(pa - ba * h) - mix(ra, rb, h);
}
/** Голова вытянута вдоль скорости; поперёк поджата, поэтому площадь сохраняется. */
float sdHead(vec2 p, vec2 c, float r) {
vec2 d = p - c;
float amount = length(uStretch);
if (amount > 0.001) {
vec2 dir = uStretch / amount;
vec2 side = vec2(-dir.y, dir.x);
float along = dot(d, dir);
float across = dot(d, side);
float k = 1.0 + amount;
d = dir * (along / k) + side * (across * sqrt(k));
}
return length(d) - r;
}
float field(vec2 p) {
float d = sdHead(p, uBlobs[0].xy, uBlobs[0].z);
for (int i = 1; i < ${MAX_BLOBS}; i++) {
float r = uBlobs[i].z;
if (r <= 0.0001) continue;
d = smin(d, length(p - uBlobs[i].xy) - r, 0.05);
}
for (int i = 0; i < ${MAX_LINKS}; i++) {
float w = uLinks[i];
if (w <= 0.001) continue;
float ra = uBlobs[i].z * w;
float rb = uBlobs[i + 1].z * w;
d = smin(d, sdLink(p, uBlobs[i].xy, uBlobs[i + 1].xy, ra, rb), 0.05);
}
return d;
}
/**
* Высота купола линзы: 1 внутри, плавный спад на фаске, 0 снаружи. Нормаль надо
* брать именно отсюда, а НЕ из сырого поля расстояния: у поля градиент единичный
* везде, и стекло выходило почти плоским — преломления не было видно вовсе.
*/
float dome(vec2 p, float bevel) {
return smoothstep(0.0, -bevel, field(p));
}
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;
}
void main() {
float aspect = uRes.x / uRes.y;
vec2 p = vec2(vUv.x * aspect, vUv.y);
float d = field(p);
float bevel = 0.17;
float h = smoothstep(0.0, -bevel, d);
// нормаль из градиента КУПОЛА, с делением на шаг — иначе наклон занижен
float e = max(1.5 / uRes.y, 0.002);
float gx = (dome(p + vec2(e, 0.0), bevel) - dome(p - vec2(e, 0.0), bevel)) / (2.0 * e);
float gy = (dome(p + vec2(0.0, e), bevel) - dome(p - vec2(0.0, e), bevel)) / (2.0 * e);
vec3 n = normalize(vec3(-gx, -gy, 3.2));
// ПЛАВНЫЙ центр увеличения: веса по расстоянию вместо «ближайшей капли»
vec2 acc = vec2(0.0);
float wsum = 0.0;
for (int i = 0; i < ${MAX_BLOBS}; i++) {
float r = uBlobs[i].z;
if (r <= 0.0001) continue;
float di = max(length(p - uBlobs[i].xy) - r, 0.0);
float w = exp(-di * 7.0) * r;
acc += uBlobs[i].xy * w;
wsum += w;
}
vec2 centre = wsum > 0.0001 ? acc / wsum : uBlobs[0].xy;
vec2 lensP = mix(p, centre, 1.0 - 1.0 / max(uZoom, 0.01));
vec2 uvTex = cover(vec2(lensP.x / aspect, lensP.y));
vec2 bend = n.xy * uThickness * 0.13;
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
);
if (uFrost > 0.001) {
float fr = uFrost * 0.02;
vec3 blur = (
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, blur, 0.55);
}
// вокруг линзы кадр заметно приглушён: стекло должно ЧИТАТЬСЯ как стекло,
// а при близкой яркости внутри и снаружи линзы её просто не видно
vec3 under = texture2D(uTex, cover(vUv)).rgb;
float shadow = smoothstep(0.16, 0.0, d) * 0.5;
under *= 0.5 - shadow * 0.35;
float rim = exp(-abs(d) * 150.0);
float fres = pow(1.0 - max(dot(n, vec3(0.0, 0.0, 1.0)), 0.0), 2.5);
vec3 lamp = normalize(vec3(-0.4, 0.8, 0.55));
float spec = pow(max(dot(reflect(-lamp, n), vec3(0.0, 0.0, 1.0)), 0.0), 48.0);
col += fres * vec3(0.74, 0.84, 1.0) * 0.35;
col += rim * vec3(0.92, 0.96, 1.0) * 0.9;
// тонкая тёмная линия сразу под кромкой: кант толщины, без него купол плоский
col *= 1.0 - exp(-abs(d + 0.02) * 90.0) * 0.25;
col += spec * 0.85;
col = mix(under, col, h);
col = vec3(1.0) - exp(-col * 1.25);
gl_FragColor = vec4(col, 1.0);
}
`;
interface Node {
x: number;
y: number;
vx: number;
vy: number;
}
export function createGlassLens(canvas: HTMLCanvasElement, options: GlassLensOptions = {}): GlassLens {
const cfg: Required<GlassLensOptions> = { ...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 blobs: THREE.Vector3[] = Array.from({ length: MAX_BLOBS }, () => new THREE.Vector3(0.5, 0.5, 0));
const links: number[] = new Array(MAX_LINKS).fill(0);
const uniforms = {
uTex: { value: null as THREE.Texture | null },
uRes: { value: new THREE.Vector2(1, 1) },
uTexSize: { value: new THREE.Vector2(1, 1) },
uBlobs: { value: blobs },
uLinks: { value: links },
uStretch: { value: new THREE.Vector2(0, 0) },
uThickness: { value: cfg.thickness },
uZoom: { value: cfg.zoom },
uDispersion: { value: cfg.dispersion },
uFrost: { value: cfg.frost },
};
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");
const load = (src: string) => {
loader.load(src, (t) => {
if (disposed) {
t.dispose();
return;
}
t.colorSpace = THREE.SRGBColorSpace;
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();
});
};
load(cfg.image);
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 aspect = () => (canvas.clientWidth || 1) / (canvas.clientHeight || 1);
const chain: Node[] = Array.from({ length: MAX_BLOBS }, () => ({ x: 0.5, y: 0.5, vx: 0, vy: 0 }));
const target = { x: 0.5, y: 0.55 };
let idle = 0;
const onPointerMove = (e: PointerEvent) => {
if (!animate) return;
const r = canvas.getBoundingClientRect();
target.x = ((e.clientX - r.left) / r.width) * (r.width / r.height);
target.y = 1 - (e.clientY - r.top) / r.height;
idle = 0;
};
const onLeave = () => {
idle = 0;
};
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerleave", onLeave);
let raf = 0;
let last = 0;
let time = 0;
/**
* Шаг физики. Вязкость здесь не «скорость доезда», а ПАРА величин: жёсткость пружины
* и затухание. Низкая вязкость — жёсткая пружина и слабое затухание: капли резко
* догоняют и перелетают (вода). Высокая — мягкая пружина и сильное затухание: хвост
* тянется долгой нитью и медленно собирается (мёд).
*/
function step(dt: number): void {
const v = Math.min(1, Math.max(0, cfg.viscosity));
const kHead = 120 - 70 * v;
const cHead = 14 + 10 * v;
const kTail = 95 - 62 * v;
const cTail = 12 + 14 * v;
const rest = cfg.radius * (0.5 + 0.5 * v);
const head = chain[0]!;
head.vx += ((target.x - head.x) * kHead - head.vx * cHead) * dt;
head.vy += ((target.y - head.y) * kHead - head.vy * cHead) * dt;
head.x += head.vx * dt;
head.y += head.vy * dt;
for (let i = 1; i < MAX_BLOBS; i++) {
const a = chain[i - 1]!;
const b = chain[i]!;
const dx = a.x - b.x;
const dy = a.y - b.y;
const dist = Math.hypot(dx, dy) || 1e-5;
const ux = dx / dist;
const uy = dy / dist;
// пружина тянет к предыдущей капле, но не в неё саму: у нити есть длина покоя
const force = (dist - rest) * kTail;
b.vx += (ux * force - b.vx * cTail) * dt;
b.vy += (uy * force - b.vy * cTail) * dt;
b.x += b.vx * dt;
b.y += b.vy * dt;
// жёсткий предел растяжения: нить не резиновая, дальше капля просто волочится
const maxLen = cfg.radius * 2.4;
const nd = Math.hypot(a.x - b.x, a.y - b.y);
if (nd > maxLen) {
const t2 = (nd - maxLen) / nd;
b.x += (a.x - b.x) * t2;
b.y += (a.y - b.y) * t2;
}
}
}
function frame(now = 0): void {
const dt = animate && last ? Math.min((now - last) / 1000, 0.033) : 0;
last = now;
time += dt;
idle += dt;
uniforms.uThickness.value = cfg.thickness;
uniforms.uZoom.value = cfg.zoom;
uniforms.uDispersion.value = cfg.dispersion;
uniforms.uFrost.value = cfg.frost;
// без курсора линза не стоит мёртвой: медленно обходит кадр по восьмёрке
if (idle > 2.2) {
const a = time * 0.3;
target.x = (0.5 + Math.sin(a) * 0.3) * aspect();
target.y = 0.5 + Math.sin(a * 2) * 0.18;
}
// физику считаем малым фиксированным шагом: на длинном кадре пружина взрывается
if (dt > 0) {
const sub = Math.max(1, Math.ceil(dt / 0.008));
for (let i = 0; i < sub; i++) step(dt / sub);
}
const tail = Math.max(0, Math.min(MAX_BLOBS - 1, Math.round(cfg.drops)));
// ОБЪЁМ СОХРАНЯЕТСЯ: чем сильнее цепочка растянута, тем мельче капли —
// без этого на рывке жидкости словно становится больше
let span = 0;
for (let i = 1; i <= tail; i++) {
span += Math.hypot(chain[i]!.x - chain[i - 1]!.x, chain[i]!.y - chain[i - 1]!.y);
}
// сжатие ограничено снизу: при сильном рывке капли иначе истончаются в волосок
// и стекло перестаёт читаться стеклом (проверено снимком на вязкости 0.9)
const shrink = Math.max(0.78, 1 / Math.sqrt(1 + span * 0.35));
for (let i = 0; i < MAX_BLOBS; i++) {
const c = chain[i]!;
const k = 1 - 0.18 * i;
const r = i <= tail ? cfg.radius * Math.max(0, k) * 0.95 * shrink : 0;
blobs[i]!.set(c.x, c.y, r);
}
// нить между каплями: тонкая при расхождении, рвётся совсем на большом разрыве
for (let i = 0; i < MAX_LINKS; i++) {
const a = blobs[i]!;
const b = blobs[i + 1]!;
if (a.z <= 0.0001 || b.z <= 0.0001) {
links[i] = 0;
continue;
}
const dist = Math.hypot(a.x - b.x, a.y - b.y);
const near = (a.z + b.z) * 0.75;
const far = (a.z + b.z) * 1.9;
const t2 = Math.min(1, Math.max(0, (far - dist) / Math.max(far - near, 1e-4)));
// толщина нити держится вязкостью: мёд тянет нить дольше воды
links[i] = Math.pow(t2, 1.6) * (0.35 + 0.45 * cfg.viscosity);
}
// вытяжение головы вдоль скорости: сжатие-растяжение как у настоящей капли
const head = chain[0]!;
const speed = Math.hypot(head.vx, head.vy);
const amount = Math.min(0.5, speed * (0.12 + 0.12 * cfg.viscosity));
if (speed > 1e-4) uniforms.uStretch.value.set((head.vx / speed) * amount, (head.vy / speed) * amount);
else uniforms.uStretch.value.set(0, 0);
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);
// стоячий кадр показывает ПРИЁМ, а не покой: голова с растянутой нитью
target.x = 0.52 * aspect();
target.y = 0.56;
for (let i = 0; i < MAX_BLOBS; i++) {
chain[i]!.x = animate ? target.x : target.x - i * 0.075;
chain[i]!.y = animate ? target.y : target.y + i * 0.03;
}
frame();
start();
return {
set(next) {
// undefined в пропсах React — «не задано», а не «сбросить»
const patch = Object.fromEntries(Object.entries(next).filter(([, v]) => v !== undefined)) as Partial<GlassLensOptions>;
const reload = patch.image !== undefined && patch.image !== cfg.image;
Object.assign(cfg, patch);
if (reload) load(cfg.image);
if (!animate) frame();
},
destroy() {
disposed = true;
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerleave", onLeave);
canvas.removeEventListener("webglcontextlost", onLost);
geometry.dispose();
material.dispose();
texture?.dispose();
renderer.dispose();
renderer.forceContextLoss();
},
};
}