Cursor Trail
Шлейф частиц за курсором на canvas: рождаются вдоль движения, разлетаются с гравитацией и гаснут; hue плавно вращается.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/cursor-trail.jsonЧто нужно в проекте до установки
Файл
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";
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
life: number;
hue: number;
}
/**
* Шлейф частиц за курсором на canvas: частицы рождаются вдоль движения, разлетаются и гаснут.
* rAF-луп останавливается, когда частиц нет. Уважает reduced-motion / data-shot (без шлейфа).
*/
export function CursorTrail() {
const wrap = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const box = wrap.current;
const canvas = canvasRef.current;
if (!box || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const frozen = document.documentElement.hasAttribute("data-shot") || reduce;
const dpr = Math.min(2, window.devicePixelRatio);
const resize = () => {
const r = box.getBoundingClientRect();
canvas.width = Math.round(r.width * dpr);
canvas.height = Math.round(r.height * dpr);
};
resize();
window.addEventListener("resize", resize);
const particles: Particle[] = [];
let raf = 0;
let running = false;
let hue = 280;
const tick = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i]!;
p.x += p.vx;
p.y += p.vy;
p.vy += 0.05;
p.life -= 0.02;
if (p.life <= 0) {
particles.splice(i, 1);
continue;
}
ctx.globalAlpha = p.life;
ctx.fillStyle = `oklch(0.78 0.16 ${p.hue})`;
ctx.beginPath();
ctx.arc(p.x * dpr, p.y * dpr, p.life * 4 * dpr, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
if (particles.length) {
raf = requestAnimationFrame(tick);
} else {
running = false; // луп спит, пока курсор не двигается
}
};
const onMove = (e: PointerEvent) => {
if (frozen) return;
const r = box.getBoundingClientRect();
hue = (hue + 3) % 360;
for (let i = 0; i < 3; i++) {
particles.push({
x: e.clientX - r.left,
y: e.clientY - r.top,
vx: (Math.random() - 0.5) * 2.4,
vy: (Math.random() - 0.5) * 2.4 - 0.6,
life: 0.7 + Math.random() * 0.3,
hue,
});
}
if (!running) {
running = true;
raf = requestAnimationFrame(tick);
}
};
box.addEventListener("pointermove", onMove);
return () => {
box.removeEventListener("pointermove", onMove);
window.removeEventListener("resize", resize);
cancelAnimationFrame(raf);
};
}, []);
return (
<div ref={wrap} style={{ position: "absolute", inset: 0, cursor: "crosshair", overflow: "hidden" }}>
<canvas ref={canvasRef} style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }} />
<div
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
pointerEvents: "none",
color: "oklch(0.92 0.01 265)",
}}
>
<div style={{ textAlign: "center", opacity: 0.6 }}>
<div style={{ fontSize: "1.5rem", fontWeight: 700 }}>Cursor Trail</div>
<div style={{ marginTop: 6, fontFamily: "ui-monospace, monospace", fontSize: "0.75rem" }}>
двигай курсором — за ним шлейф
</div>
</div>
</div>
</div>
);
}