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

Click Spark

Искры от клика на canvas 2D. Массив частиц создаётся один раз при монтировании — клик лишь помечает свободные слоты живыми, поэтому за всё время работы не выделяется ни одного нового объекта. Цикл останавливается сам, когда гаснет последняя искра.

cssclickspringintermediate~2 КБreduced-motion ✓
🎛 Настройки — крути и копируй

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
count?number14Искр за клик (формат «14»).
speed?number260Начальная скорость, px/с (формат «260»).
length?number14Длина искры, px (формат «14»).
life?number0.5Время жизни, сек (формат «0.5»).
hue?number300Тон в oklch, 0–360 (формат «300»).

Установка

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

export interface ClickSparkProps {
  /** Искр за клик (формат «14»). */
  count?: number;
  /** Начальная скорость, px/с (формат «260»). */
  speed?: number;
  /** Длина искры, px (формат «14»). */
  length?: number;
  /** Время жизни, сек (формат «0.5»). */
  life?: number;
  /** Тон в oklch, 0–360 (формат «300»). */
  hue?: number;
  /** Притяжение вниз, px/с² (формат «420»); 0 — искры летят прямо. */
  gravity?: number;
}

/** Ёмкость пула: больше искр одновременно на экране просто не нужно. */
const POOL = 240;

/**
 * Искры от клика на canvas 2D.
 *
 * Главное здесь — ПУЛ ОБЪЕКТОВ. Наивная реализация создаёт массив из полутора десятков
 * частиц на каждый клик и выбрасывает его через полсекунды. По кликам в минуту это сотни
 * короткоживущих объектов, то есть регулярные паузы на сборку мусора ровно в тот момент,
 * когда идёт анимация. Здесь массив на 240 слотов создаётся ОДИН раз при монтировании,
 * а клик лишь помечает свободные слоты живыми: за всё время работы не выделяется ни одного
 * нового объекта.
 *
 * Второе — цикл СПИТ. rAF стартует на первом клике и останавливается, как только погасла
 * последняя искра: в покое эффект не стоит ничего. Это не мелочь для декоративной штуки,
 * которая висит на всей странице.
 *
 * Искра рисуется отрезком по направлению движения, а не точкой: длина берётся из скорости,
 * поэтому быстрые искры вытянуты, гаснущие — короткие. Одна строка вместо системы частиц
 * с текстурами.
 */
export function ClickSpark({
  count = 14,
  speed = 260,
  length = 14,
  life = 0.5,
  hue = 300,
  gravity = 420,
}: ClickSparkProps = {}) {
  const host = useRef<HTMLDivElement>(null);
  const cfg = useRef({ count, speed, length, life, hue, gravity });
  cfg.current = { count, speed, length, life, hue, gravity };

  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;

    let dpr = 1;
    const resize = () => {
      dpr = Math.min(2, window.devicePixelRatio || 1);
      canvas.width = Math.round(el.clientWidth * dpr);
      canvas.height = Math.round(el.clientHeight * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(el);

    // пул: ни одного нового объекта после этой строки
    const x = new Float32Array(POOL);
    const y = new Float32Array(POOL);
    const vx = new Float32Array(POOL);
    const vy = new Float32Array(POOL);
    const age = new Float32Array(POOL);
    const max = new Float32Array(POOL);
    const alive = new Uint8Array(POOL);
    let next = 0;
    let living = 0;

    const emit = (px: number, py: number) => {
      const c = cfg.current;
      for (let i = 0; i < c.count; i++) {
        const s = next;
        next = (next + 1) % POOL;
        if (!alive[s]) living++;
        const a = Math.random() * Math.PI * 2;
        const v = c.speed * (0.55 + Math.random() * 0.7);
        alive[s] = 1;
        x[s] = px;
        y[s] = py;
        vx[s] = Math.cos(a) * v;
        vy[s] = Math.sin(a) * v;
        age[s] = 0;
        max[s] = c.life * (0.7 + Math.random() * 0.6);
      }
    };

    let raf = 0;
    let last = 0;

    const frame = (now: number) => {
      const c = cfg.current;
      const dt = Math.min(0.05, (now - last) / 1000);
      last = now;
      ctx.clearRect(0, 0, canvas.width, canvas.height);

      for (let i = 0; i < POOL; i++) {
        if (!alive[i]) continue;
        age[i]! += dt;
        if (age[i]! >= max[i]!) {
          alive[i] = 0;
          living--;
          continue;
        }
        vy[i]! += c.gravity * dt;
        x[i]! += vx[i]! * dt;
        y[i]! += vy[i]! * dt;

        const t = age[i]! / max[i]!;
        const fade = 1 - t * t; // гаснет с ускорением — так читается как вспышка, а не как таймер
        const v = Math.hypot(vx[i]!, vy[i]!);
        // длина отрезка из скорости: быстрая искра вытянута, гаснущая — короткая
        const len = Math.min(c.length, (v / c.speed) * c.length) * fade;
        const nx = vx[i]! / (v || 1);
        const ny = vy[i]! / (v || 1);

        ctx.strokeStyle = `oklch(${0.72 + 0.2 * fade} ${0.18 * fade} ${c.hue + t * 40} / ${fade})`;
        ctx.lineWidth = 1.6 * fade + 0.4;
        ctx.beginPath();
        ctx.moveTo(x[i]!, y[i]!);
        ctx.lineTo(x[i]! - nx * len, y[i]! - ny * len);
        ctx.stroke();
      }

      if (living > 0) raf = requestAnimationFrame(frame);
      else raf = 0; // погасла последняя искра — цикл останавливается сам
    };

    const wake = () => {
      if (raf) return;
      last = performance.now();
      raf = requestAnimationFrame(frame);
    };

    const onDown = (e: PointerEvent) => {
      const r = el.getBoundingClientRect();
      emit(e.clientX - r.left, e.clientY - r.top);
      const frozen =
        document.documentElement.hasAttribute("data-shot") ||
        window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      if (frozen) {
        // без движения — рисуем один кадр, чтобы вспышка всё-таки была видна
        last = performance.now();
        frame(last + 16);
        for (let i = 0; i < POOL; i++) alive[i] = 0;
        living = 0;
        return;
      }
      wake();
    };

    el.addEventListener("pointerdown", onDown);
    return () => {
      el.removeEventListener("pointerdown", onDown);
      cancelAnimationFrame(raf);
      ro.disconnect();
      canvas.remove();
    };
  }, []);

  return (
    <div
      ref={host}
      className="csp"
      style={{
        position: "absolute",
        inset: 0,
        cursor: "crosshair",
        background: "var(--ds-bg, oklch(0.13 0.02 275))",
        display: "grid",
        placeItems: "center",
      }}
    >
      <span
        style={{
          pointerEvents: "none",
          fontFamily: "var(--ds-font-mono, ui-monospace, monospace)",
          fontSize: "var(--ds-text-sm, 0.86rem)",
          color: "var(--ds-muted, oklch(0.72 0.02 265))",
          opacity: 0.6,
        }}
      >
        кликай где угодно
      </span>
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0