← каталог
Bubble Rise
Пузырьки, всплывающие снизу вверх. Пузырёк рисуется один раз в закадровый холст, а в кадре только копируется — вместо построения радиального градиента на каждый пузырёк в каждом кадре.
cssautoparallaxintermediate~2 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| count? | number | 60 | Сколько пузырьков в кадре (формат «60»). |
| size? | number | 26 | Максимальный радиус, px (формат «26»). |
| speed? | number | 40 | Скорость всплытия, px/с (формат «40»). |
| wobble? | number | 26 | Размах покачивания вбок, px (формат «26»). |
| hue? | number | 220 | Тон в oklch, 0–360 (формат «220»). |
| opacity? | number | 0.5 | Прозрачность (формат «0.5»). |
| backdrop? | string | false | BACKDROP | Подложка: CSS-значение `background` или `false`, чтобы не рисовать её вовсе. `false` нужен, когда запись стоит фоном СЕКЦИИ и подложку задаёт она — иначе две подложки спорят за один слой. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/bubble-rise.jsonЧто нужно в проекте до установки
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";
/** Подложка по умолчанию — как в витрине. */
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