← каталог
Metaballs
Шары сливаются и растекаются, как капли ртути. Вся органика держится на плавном минимуме: обычный min даёт излом на стыке, плавный — перемычку, из-за которой шары тянутся друг к другу задолго до касания.
oglmouse-moveautomorphadvanced~11 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| count? | number | 6 | Число шаров (формат «6»). |
| radius? | number | 0.16 | Радиус шара, доля высоты кадра (формат «0.16»). |
| smoothing? | number | 0.1 | Мягкость слияния (формат «0.1»): сопоставимо с радиусом — шары сливаются в одну лужу, заметно меньше — тянутся перемычками и остаются различимы. |
| speed? | number | 0.35 | Скорость движения (формат «0.35»). |
| hue? | number | 0.72 | Тон в oklch-круге, 0–1 (формат «0.72»). |
| rim? | number | 0.5 | Ширина цветного перехода по краю (формат «0.5»). |
| interactive? | boolean | true | Реагировать на курсор. |
| backdrop? | string | false | BACKDROP | Подложка: CSS-значение `background` или `false`, чтобы не рисовать её вовсе. `false` нужен, когда запись стоит фоном СЕКЦИИ и подложку задаёт она — иначе две подложки спорят за один слой. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/metaballs.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 } from "react";
import { Mesh, Program, Renderer, Triangle } from "ogl";
/** Подложка по умолчанию — как в витрине. */
const BACKDROP =
"var(--ds-bg, oklch(0.11 0.02 275))";
export interface MetaballsProps {
/** Число шаров (формат «6»). */
count?: number;
/** Радиус шара, доля высоты кадра (формат «0.16»). */
radius?: number;
/** Мягкость слияния (формат «0.1»): сопоставимо с радиусом — шары сливаются в одну лужу,
* заметно меньше — тянутся перемычками и остаются различимы. */
smoothing?: number;
/** Скорость движения (формат «0.35»). */
speed?: number;
/** Тон в oklch-круге, 0–1 (формат «0.72»). */
hue?: number;
/** Ширина цветного перехода по краю (формат «0.5»). */
rim?: number;
/** Реагировать на курсор. */
interactive?: boolean;
/**
* Подложка: CSS-значение `background` или `false`, чтобы не рисовать её вовсе.
* `false` нужен, когда запись стоит фоном СЕКЦИИ и подложку задаёт она — иначе две
* подложки спорят за один слой.
*/
backdrop?: string | false;
}
const VERT = /* glsl */ `
attribute vec2 uv;
attribute vec2 position;
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position, 0.0, 1.0); }
`;
/**
* Вся органика держится на ОДНОЙ функции — плавном минимуме (smooth minimum).
*
* Обычный `min` двух расстояний даёт объединение фигур с изломом в месте стыка: два круга
* пересекаются «уголком». Плавный минимум подмешивает к результату слагаемое, зависящее от
* того, насколько близки значения, — и на стыке возникает перемычка. Именно она и создаёт
* ощущение жидкости: шары не пересекаются, а ТЯНУТСЯ друг к другу задолго до касания.
*
* Параметр k управляет длиной этой перемычки. При k → 0 функция вырождается в обычный min
* и жидкость превращается в набор кругов.
*/
const FRAG = /* glsl */ `
precision highp float;
uniform float uTime, uRadius, uK, uHue, uRim;
uniform int uCount;
uniform vec2 uRes, uMouse;
uniform float uInteractive;
varying vec2 vUv;
// плавный минимум: полином, https://iquilezles.org/articles/smin
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);
}
vec3 hsv2rgb(vec3 c) {
vec3 p = abs(fract(c.xxx + vec3(0.0, 2.0 / 3.0, 1.0 / 3.0)) * 6.0 - 3.0);
return c.z * mix(vec3(1.0), clamp(p - 1.0, 0.0, 1.0), c.y);
}
void main() {
float aspect = uRes.x / uRes.y;
vec2 p = (vUv - 0.5) * vec2(aspect, 1.0);
float d = 1e5;
for (int i = 0; i < 10; i++) {
if (i >= uCount) break;
float fi = float(i);
// траектории Лиссажу: несоизмеримые частоты → путь не замыкается на глаз
vec2 c = vec2(
sin(uTime * (0.6 + fi * 0.13) + fi * 1.7) * 0.34 * aspect,
cos(uTime * (0.5 + fi * 0.17) + fi * 2.3) * 0.3
);
float r = uRadius * (0.7 + 0.5 * fract(sin(fi * 12.9898) * 43758.5453));
d = smin(d, length(p - c) - r, uK);
}
if (uInteractive > 0.5) {
vec2 m = (uMouse - 0.5) * vec2(aspect, 1.0);
d = smin(d, length(p - m) - uRadius * 0.9, uK);
}
// край поля — тонкая яркая кайма, тело темнее: так масса читается объёмной,
// а не плоской заливкой
float edge = 1.0 - smoothstep(0.0, 0.014, abs(d));
float halo = 1.0 - smoothstep(0.0, uRadius * uRim, d);
float body = 1.0 - smoothstep(-0.015, 0.005, d);
// тон ведём по ПОЛОЖЕНИЮ, а не по расстоянию: внутри тела d почти постоянна,
// и цвет получался бы однородной плашкой
float hue = fract(uHue + p.x * 0.16 + p.y * 0.1 + uTime * 0.02);
vec3 deep = hsv2rgb(vec3(hue, 0.85, 0.5));
vec3 rim = hsv2rgb(vec3(fract(hue + 0.08), 0.7, 1.0));
vec3 col = deep * body * 0.9 + rim * edge + rim * halo * 0.3;
float a = clamp(body * 0.92 + edge + halo * 0.32, 0.0, 1.0);
gl_FragColor = vec4(col, a);
}
`;
/**
* Метаболы: шары сливаются и растекаются, как капли ртути.
*
* Считается полем расстояний в одном фрагментном шейдере на OGL — ни геометрии, ни маршевых
* кубов. Расстояние до ближайшего шара объединяется ПЛАВНЫМ минимумом, и вся «жидкость»
* берётся из этой одной функции: край, кайма и свечение — производные того же значения.
*
* Курсор — такой же шар в поле, поэтому взаимодействие не требует ни отдельной ветки,
* ни лишнего прохода: он просто участвует в общем объединении.
*
* Сцена засыпает вне кадра и в скрытой вкладке; при `prefers-reduced-motion` рисуется
* один кадр.
*/
export function Metaballs({
count = 6,
radius = 0.16,
smoothing = 0.1,
speed = 0.35,
hue = 0.72,
rim = 0.5,
interactive = true,
backdrop = BACKDROP,
}: MetaballsProps = {}) {
const host = useRef<HTMLDivElement>(null);
const cfg = useRef({ count, radius, smoothing, speed, hue, rim, interactive });
cfg.current = { count, radius, smoothing, speed, hue, rim, interactive };
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: true, antialias: false, dpr: Math.min(2, window.devicePixelRatio || 1) });
const gl = renderer.gl;
gl.clearColor(0, 0, 0, 0);
Object.assign(gl.canvas.style, { display: "block", width: "100%", height: "100%" });
el.appendChild(gl.canvas);
const program = new Program(gl, {
vertex: VERT,
fragment: FRAG,
transparent: true,
uniforms: {
uTime: { value: 0 },
uRes: { value: [1, 1] },
uMouse: { value: [0.5, 0.5] },
uCount: { value: cfg.current.count },
uRadius: { value: cfg.current.radius },
uK: { value: cfg.current.smoothing },
uHue: { value: cfg.current.hue },
uRim: { value: cfg.current.rim },
uInteractive: { value: cfg.current.interactive ? 1 : 0 },
},
});
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];
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(el);
// курсор ведём с задержкой — резкий скачок поля выглядит как рывок жидкости
let tx = 0.5;
let ty = 0.5;
const onMove = (e: PointerEvent) => {
const r = el.getBoundingClientRect();
tx = (e.clientX - r.left) / r.width;
ty = 1 - (e.clientY - r.top) / r.height;
start();
};
el.addEventListener("pointermove", onMove);
let raf = 0;
let last = performance.now();
let time = 0;
const draw = () => {
const c = cfg.current;
const m = program.uniforms.uMouse!.value as [number, number];
m[0] += (tx - m[0]) * 0.08;
m[1] += (ty - m[1]) * 0.08;
program.uniforms.uCount!.value = Math.round(c.count);
program.uniforms.uRadius!.value = c.radius;
program.uniforms.uK!.value = Math.max(0.001, c.smoothing);
program.uniforms.uHue!.value = c.hue;
program.uniforms.uRim!.value = c.rim;
program.uniforms.uInteractive!.value = c.interactive ? 1 : 0;
program.uniforms.uTime!.value = time;
renderer.render({ scene: mesh });
};
const loop = (now: number) => {
time += Math.min(0.05, (now - last) / 1000) * cfg.current.speed;
last = now;
draw();
raf = requestAnimationFrame(loop);
};
function start() {
if (raf || frozen) return;
last = performance.now();
raf = requestAnimationFrame(loop);
}
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
draw();
const io = new IntersectionObserver(([e]) => (e!.isIntersecting && !document.hidden ? start() : stop()));
io.observe(el);
const onVis = () => (document.hidden ? stop() : start());
document.addEventListener("visibilitychange", onVis);
return () => {
stop();
io.disconnect();
ro.disconnect();
el.removeEventListener("pointermove", onMove);
document.removeEventListener("visibilitychange", onVis);
gl.getExtension("WEBGL_lose_context")?.loseContext();
gl.canvas.remove();
};
}, []);
return (
<div
ref={host}
className="mtb"
style={{
position: "absolute",
inset: 0,
...(backdrop === false ? null : { background: backdrop }),
}}
/>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0