Particle Field
Медленно дрейфующее 3D-поле из ~1800 частиц — фон на настоящем WebGL (голый three.js, без React Three Fiber).
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| count? | number | 1800 | Сколько частиц в облаке. |
| color? | string | "#38bdf8" | Цвет точек. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/particle-field.jsonзависимости: three@^0.185.1 · @types/three@^0.185.0
Что нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useRef } from "react";
import * as THREE from "three";
export interface ParticleFieldProps {
/** Сколько частиц в облаке. */
count?: number;
/** Цвет точек. */
color?: string;
/** Скорость вращения облака; 0 — стоп-кадр. */
speed?: number;
}
/**
* Дрейфующее 3D-поле частиц на голом three.js (без React Three Fiber).
*
* Облако точек в кубе 11×11×11 медленно вращается вокруг двух осей сразу — из-за
* перспективы и `sizeAttenuation` ближние точки крупнее, и плоская россыпь читается
* как объём. Цикл засыпает вне зоны видимости и на скрытой вкладке;
* `prefers-reduced-motion` и `data-shot` рисуют один кадр.
*/
export function ParticleField({ count = 1800, color = "#38bdf8", speed = 1 }: ParticleFieldProps = {}) {
const host = useRef<HTMLDivElement>(null);
const cfg = useRef({ speed });
cfg.current = { speed };
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 THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
Object.assign(renderer.domElement.style, { display: "block", width: "100%", height: "100%" });
el.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100);
camera.position.set(0, 0, 6);
const positions = new Float32Array(count * 3);
for (let i = 0; i < count * 3; i++) positions[i] = (Math.random() - 0.5) * 11;
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
size: 0.03,
color: new THREE.Color(color),
transparent: true,
opacity: 0.85,
sizeAttenuation: true,
depthWrite: false,
});
const points = new THREE.Points(geometry, material);
scene.add(points);
const resize = () => {
const { clientWidth: w, clientHeight: h } = el;
if (!w || !h) return;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
renderer.render(scene, camera);
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(el);
let raf = 0;
let last = 0;
let time = 0;
const loop = (now: number) => {
const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
time += dt * cfg.current.speed;
// две оси с разной скоростью: облако не «катится» как жёсткое тело
points.rotation.y = time * 0.04;
points.rotation.x = time * 0.02;
renderer.render(scene, camera);
raf = requestAnimationFrame(loop);
};
const start = () => {
if (raf || frozen) return;
last = 0;
raf = requestAnimationFrame(loop);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
renderer.render(scene, camera);
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);
geometry.dispose();
material.dispose();
renderer.dispose();
renderer.forceContextLoss();
renderer.domElement.remove();
};
}, [count, color]);
return <div ref={host} style={{ position: "absolute", inset: 0 }} />;
}