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

Bubble Rise

Пузырьки, всплывающие снизу вверх. Пузырёк рисуется один раз в закадровый холст, а в кадре только копируется — вместо построения радиального градиента на каждый пузырёк в каждом кадре.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
count?number60Сколько пузырьков в кадре (формат «60»).
size?number26Максимальный радиус, px (формат «26»).
speed?number40Скорость всплытия, px/с (формат «40»).
wobble?number26Размах покачивания вбок, px (формат «26»).
hue?number220Тон в oklch, 0–360 (формат «220»).
opacity?number0.5Прозрачность (формат «0.5»).
backdrop?string | falseBACKDROPПодложка: CSS-значение `background` или `false`, чтобы не рисовать её вовсе. `false` нужен, когда запись стоит фоном СЕКЦИИ и подложку задаёт она — иначе две подложки спорят за один слой.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/bubble-rise.json
Что нужно в проекте до установки
  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";

/** Подложка по умолчанию — как в витрине. */
const BACKDROP =
  "radial-gradient(80% 60% at 50% 100%, var(--ds-accent-soft, oklch(0.6 0.14 220 / 0.18)), transparent 70%), var(--ds-bg, oklch(0.12 0.02 250))";

export interface BubbleRiseProps {
  /** Сколько пузырьков в кадре (формат «60»). */
  count?: number;
  /** Максимальный радиус, px (формат «26»). */
  size?: number;
  /** Скорость всплытия, px/с (формат «40»). */
  speed?: number;
  /** Размах покачивания вбок, px (формат «26»). */
  wobble?: number;
  /** Тон в oklch, 0–360 (формат «220»). */
  hue?: number;
  /** Прозрачность (формат «0.5»). */
  opacity?: number;
  /**
   * Подложка: CSS-значение `background` или `false`, чтобы не рисовать её вовсе.
   * `false` нужен, когда запись стоит фоном СЕКЦИИ и подложку задаёт она — иначе две
   * подложки спорят за один слой.
   */
  backdrop?: string | false;
}

/**
 * Пузырьки, всплывающие снизу вверх, — canvas 2D.
 *
 * Приём, ради которого запись стоит читать: пузырёк рисуется ОДИН РАЗ в закадровый холст,
 * а в кадре только копируется через `drawImage`.
 *
 * Наивная версия на каждый пузырёк в каждом кадре зовёт `createRadialGradient` и `arc`.
 * Градиент в canvas — не бесплатная константа: браузер строит объект и растрирует заливку
 * заново на каждый вызов. Шестьдесят пузырьков по шестьдесят кадров — это три с половиной
 * тысячи растеризаций градиента в секунду вместо одной. `drawImage` же копирует уже готовые
 * пиксели и обычно уходит в ускоренный путь.
 *
 * Спрайт рисуется в максимальном размере и уменьшается при копировании: увеличивать растр
 * нельзя — будет мыло, уменьшать можно почти бесплатно.
 *
 * Цикл спит, когда холст вне зоны видимости или вкладка неактивна: фоновая декорация не
 * должна греть процессор в свёрнутом окне.
 */
export function BubbleRise({
  count = 60,
  size = 26,
  speed = 40,
  wobble = 26,
  hue = 220,
  opacity = 0.5,
  backdrop = BACKDROP,
}: BubbleRiseProps = {}) {
  const host = useRef<HTMLDivElement>(null);
  const cfg = useRef({ count, size, speed, wobble, hue, opacity });
  cfg.current = { count, size, speed, wobble, hue, opacity };

  useEffect(() => {
    const el = host.current;
    if (!el) return;
    const canvas = document.createElement("canvas");
    Object.assign(canvas.style, { position: "absolute", inset: "0", width: "100%", height: "100%" });
    el.appendChild(canvas);
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const frozen =
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    // ── закадровый спрайт: строится один раз на каждый тон ──────────────────
    const sprite = document.createElement("canvas");
    const sctx = sprite.getContext("2d")!;
    let spriteHue = NaN;
    const buildSprite = (h: number, maxR: number) => {
      const s = Math.ceil(maxR * 2 + 4);
      sprite.width = s;
      sprite.height = s;
      sctx.clearRect(0, 0, s, s);
      const c = s / 2;
      const g = sctx.createRadialGradient(c - maxR * 0.3, c - maxR * 0.35, maxR * 0.1, c, c, maxR);
      g.addColorStop(0, `oklch(0.95 0.06 ${h} / 0.9)`);
      g.addColorStop(0.45, `oklch(0.7 0.12 ${h} / 0.28)`);
      g.addColorStop(0.85, `oklch(0.62 0.14 ${h} / 0.14)`);
      g.addColorStop(1, `oklch(0.62 0.14 ${h} / 0)`);
      sctx.fillStyle = g;
      sctx.beginPath();
      sctx.arc(c, c, maxR, 0, Math.PI * 2);
      sctx.fill();
      // блик — то, что отличает пузырёк от круга
      sctx.fillStyle = `oklch(1 0 0 / 0.5)`;
      sctx.beginPath();
      sctx.ellipse(c - maxR * 0.32, c - maxR * 0.38, maxR * 0.16, maxR * 0.11, -0.6, 0, Math.PI * 2);
      sctx.fill();
      spriteHue = h;
    };

    const MAX = 160;
    const bx = new Float32Array(MAX);
    const by = new Float32Array(MAX);
    const br = new Float32Array(MAX);
    const bv = new Float32Array(MAX);
    const bp = new Float32Array(MAX);
    const bw = new Float32Array(MAX);

    let w = 0;
    let h = 0;
    let dpr = 1;

    const seed = (i: number, atBottom: boolean) => {
      const c = cfg.current;
      br[i] = c.size * (0.25 + Math.random() * 0.75);
      bx[i] = Math.random() * w;
      by[i] = atBottom ? h + br[i]! + Math.random() * h * 0.4 : Math.random() * h;
      bv[i] = c.speed * (0.5 + Math.random() * 0.9);
      bp[i] = Math.random() * Math.PI * 2;
      bw[i] = 0.4 + Math.random() * 0.9;
    };

    const resize = () => {
      dpr = Math.min(2, window.devicePixelRatio || 1);
      w = el.clientWidth;
      h = el.clientHeight;
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      buildSprite(cfg.current.hue, cfg.current.size);
      for (let i = 0; i < MAX; i++) seed(i, false);
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(el);

    let raf = 0;
    let last = 0;
    let t = 0;

    const draw = (dt: number) => {
      const c = cfg.current;
      if (c.hue !== spriteHue) buildSprite(c.hue, c.size);
      t += dt;
      ctx.clearRect(0, 0, w, h);
      ctx.globalAlpha = c.opacity;
      const n = Math.min(MAX, Math.round(c.count));
      for (let i = 0; i < n; i++) {
        by[i]! -= bv[i]! * dt;
        if (by[i]! < -br[i]!) seed(i, true);
        const x = bx[i]! + Math.sin(t * bw[i]! + bp[i]!) * c.wobble * (br[i]! / c.size);
        const d = br[i]! * 2;
        // только копирование готовых пикселей — ни градиентов, ни путей в кадре
        ctx.drawImage(sprite, x - br[i]!, by[i]! - br[i]!, d, d);
      }
      ctx.globalAlpha = 1;
    };

    const loop = (now: number) => {
      const dt = Math.min(0.05, (now - last) / 1000);
      last = now;
      draw(dt);
      raf = requestAnimationFrame(loop);
    };
    const start = () => {
      if (raf || frozen) return;
      last = performance.now();
      raf = requestAnimationFrame(loop);
    };
    const stop = () => {
      cancelAnimationFrame(raf);
      raf = 0;
    };

    draw(0); // статичный кадр всегда — эффект виден и без движения

    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();
      document.removeEventListener("visibilitychange", onVis);
      canvas.remove();
    };
  }, []);

  return (
    <div
      ref={host}
      className="bbl"
      style={{
        position: "absolute",
        inset: 0,
        overflow: "hidden",
        pointerEvents: "none",
        ...(backdrop === false ? null : { background: backdrop }),
      }}
    />
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0