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

Metaballs

Шары сливаются и растекаются, как капли ртути. Вся органика держится на плавном минимуме: обычный min даёт излом на стыке, плавный — перемычку, из-за которой шары тянутся друг к другу задолго до касания.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
count?number6Число шаров (формат «6»).
radius?number0.16Радиус шара, доля высоты кадра (формат «0.16»).
smoothing?number0.1Мягкость слияния (формат «0.1»): сопоставимо с радиусом — шары сливаются в одну лужу, заметно меньше — тянутся перемычками и остаются различимы.
speed?number0.35Скорость движения (формат «0.35»).
hue?number0.72Тон в oklch-круге, 0–1 (формат «0.72»).
rim?number0.5Ширина цветного перехода по краю (формат «0.5»).
interactive?booleantrueРеагировать на курсор.
backdrop?string | falseBACKDROPПодложка: CSS-значение `background` или `false`, чтобы не рисовать её вовсе. `false` нужен, когда запись стоит фоном СЕКЦИИ и подложку задаёт она — иначе две подложки спорят за один слой.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/metaballs.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 } 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