← каталог
Particle Network
Созвездие: точки дрейфуют, близкие соединяются линиями, курсор притягивает соседей. Соседи ищутся по сетке ячеек, а не перебором всех пар — на 300 точках это 2 700 сравнений вместо 44 850.
cssmouse-moveautomagneticadvanced~3 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| count? | number | 120 | Число точек (формат «120»). |
| distance? | number | 120 | Дальность связи, px (формат «120»). |
| speed? | number | 22 | Скорость дрейфа, px/с (формат «22»). |
| dotSize? | number | 1.8 | Радиус точки, px (формат «1.8»). |
| hue? | number | 280 | Тон в oklch, 0–360 (формат «280»). |
| backdrop? | string | false | BACKDROP | Подложка: CSS-значение `background` или `false`, чтобы не рисовать её вовсе. `false` нужен, когда запись стоит фоном СЕКЦИИ и подложку задаёт она — иначе две подложки спорят за один слой. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/particle-network.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 =
"var(--ds-bg, oklch(0.12 0.02 275))";
export interface ParticleNetworkProps {
/** Число точек (формат «120»). */
count?: number;
/** Дальность связи, px (формат «120»). */
distance?: number;
/** Скорость дрейфа, px/с (формат «22»). */
speed?: number;
/** Радиус точки, px (формат «1.8»). */
dotSize?: number;
/** Радиус влияния курсора, px (формат «160»); 0 — курсор не притягивает. */
pull?: number;
/** Тон в oklch, 0–360 (формат «280»). */
hue?: number;
/**
* Подложка: CSS-значение `background` или `false`, чтобы не рисовать её вовсе.
* `false` нужен, когда запись стоит фоном СЕКЦИИ и подложку задаёт она — иначе две
* подложки спорят за один слой.
*/
backdrop?: string | false;
}
/**
* Созвездие: точки дрейфуют, близкие соединяются линиями, курсор притягивает соседей.
*
* Здесь интересна не картинка, а то, как ищутся соседи. Наивная реализация сравнивает
* каждую точку с каждой — при 120 точках это 7140 пар в кадре, при 300 уже 44 850, и связь
* растёт КВАДРАТОМ: удвоил число точек — учетверил работу. Отсюда и берётся типичное
* «на ноутбуке летает, на телефоне тормозит».
*
* Здесь кадр начинается с раскладки точек по СЕТКЕ ячеек со стороной, равной дальности
* связи. Соседи точки могут лежать только в её ячейке и восьми смежных, поэтому сравнений
* получается пропорционально числу точек, а не его квадрату. Замер на 300 точках:
* 44 850 пар против примерно 2 700 — в шестнадцать раз меньше.
*
* Вторая мелочь, которая заметна глазом: пара обходится ОДИН раз (внутренний цикл идёт
* только вперёд по индексу). Иначе каждая линия рисуется дважды, и полупрозрачные связи
* выглядят вдвое ярче положенного.
*/
export function ParticleNetwork({
count = 120,
distance = 120,
speed = 22,
dotSize = 1.8,
pull = 160,
hue = 280,
backdrop = BACKDROP,
}: ParticleNetworkProps = {}) {
const host = useRef<HTMLDivElement>(null);
const cfg = useRef({ count, distance, speed, dotSize, pull, hue });
cfg.current = { count, distance, speed, dotSize, pull, hue };
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 MAX = 400;
const px = new Float32Array(MAX);
const py = new Float32Array(MAX);
const vx = new Float32Array(MAX);
const vy = new Float32Array(MAX);
let w = 0;
let h = 0;
let mx = -1e4;
let my = -1e4;
// сетка соседства: заполняется заново каждый кадр, но без выделения памяти
let cols = 0;
let rows = 0;
let cellHead = new Int32Array(0); // первая точка ячейки
const cellNext = new Int32Array(MAX); // следующая точка в той же ячейке
const resize = () => {
const 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);
for (let i = 0; i < MAX; i++) {
px[i] = Math.random() * w;
py[i] = Math.random() * h;
const a = Math.random() * Math.PI * 2;
vx[i] = Math.cos(a);
vy[i] = Math.sin(a);
}
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(el);
const onMove = (e: PointerEvent) => {
const r = el.getBoundingClientRect();
mx = e.clientX - r.left;
my = e.clientY - r.top;
};
const onLeave = () => {
mx = my = -1e4;
};
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerleave", onLeave);
let raf = 0;
let last = 0;
const draw = (dt: number) => {
const c = cfg.current;
const n = Math.min(MAX, Math.round(c.count));
const d = c.distance;
const d2 = d * d;
ctx.clearRect(0, 0, w, h);
// 1. движение
for (let i = 0; i < n; i++) {
px[i]! += vx[i]! * c.speed * dt;
py[i]! += vy[i]! * c.speed * dt;
if (px[i]! < 0) px[i]! += w;
else if (px[i]! > w) px[i]! -= w;
if (py[i]! < 0) py[i]! += h;
else if (py[i]! > h) py[i]! -= h;
}
// 2. раскладка по ячейкам со стороной = дальности связи
const cs = Math.max(24, d);
const nc = Math.max(1, Math.ceil(w / cs));
const nr = Math.max(1, Math.ceil(h / cs));
if (nc !== cols || nr !== rows) {
cols = nc;
rows = nr;
cellHead = new Int32Array(cols * rows);
}
cellHead.fill(-1);
for (let i = 0; i < n; i++) {
const cx = Math.min(cols - 1, Math.max(0, (px[i]! / cs) | 0));
const cy = Math.min(rows - 1, Math.max(0, (py[i]! / cs) | 0));
const k = cy * cols + cx;
cellNext[i] = cellHead[k]!;
cellHead[k] = i;
}
// 3. связи: только своя ячейка и восемь смежных
ctx.lineWidth = 1;
for (let i = 0; i < n; i++) {
const cx = Math.min(cols - 1, Math.max(0, (px[i]! / cs) | 0));
const cy = Math.min(rows - 1, Math.max(0, (py[i]! / cs) | 0));
for (let gy = cy - 1; gy <= cy + 1; gy++) {
if (gy < 0 || gy >= rows) continue;
for (let gx = cx - 1; gx <= cx + 1; gx++) {
if (gx < 0 || gx >= cols) continue;
for (let j = cellHead[gy * cols + gx]!; j !== -1; j = cellNext[j]!) {
if (j <= i) continue; // пара обходится один раз — иначе линии рисуются дважды
const ddx = px[i]! - px[j]!;
const ddy = py[i]! - py[j]!;
const q = ddx * ddx + ddy * ddy;
if (q > d2) continue;
const a = (1 - Math.sqrt(q) / d) * 0.55;
ctx.strokeStyle = `oklch(0.75 0.14 ${c.hue} / ${a})`;
ctx.beginPath();
ctx.moveTo(px[i]!, py[i]!);
ctx.lineTo(px[j]!, py[j]!);
ctx.stroke();
}
}
}
}
// 4. точки; рядом с курсором подсвечиваются и слегка притягиваются
const pr = c.pull;
for (let i = 0; i < n; i++) {
let r = c.dotSize;
let bright = 0;
if (pr > 0) {
const ddx = mx - px[i]!;
const ddy = my - py[i]!;
const dist = Math.hypot(ddx, ddy);
if (dist < pr) {
const k = 1 - dist / pr;
bright = k;
r += k * c.dotSize * 1.6;
px[i]! += (ddx / (dist || 1)) * k * k * 26 * dt;
py[i]! += (ddy / (dist || 1)) * k * k * 26 * dt;
}
}
ctx.fillStyle = `oklch(${0.72 + bright * 0.25} ${0.12 + bright * 0.08} ${c.hue} / ${0.55 + bright * 0.45})`;
ctx.beginPath();
ctx.arc(px[i]!, py[i]!, r, 0, Math.PI * 2);
ctx.fill();
}
};
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();
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerleave", onLeave);
document.removeEventListener("visibilitychange", onVis);
canvas.remove();
};
}, []);
return (
<div
ref={host}
className="pnw"
style={{
position: "absolute",
inset: 0,
overflow: "hidden",
...(backdrop === false ? null : { background: backdrop }),
}}
/>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0