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

Cursor Trail

Шлейф частиц за курсором на canvas: рождаются вдоль движения, разлетаются с гравитацией и гаснут; hue плавно вращается.

cssmouse-moveintermediate0reduced-motion ✓

Установка

npx shadcn@latest add https://motiva.pages.dev/r/cursor-trail.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";

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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0