← каталог
Click Spark
Искры от клика на canvas 2D. Массив частиц создаётся один раз при монтировании — клик лишь помечает свободные слоты живыми, поэтому за всё время работы не выделяется ни одного нового объекта. Цикл останавливается сам, когда гаснет последняя искра.
cssclickspringintermediate~2 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| count? | number | 14 | Искр за клик (формат «14»). |
| speed? | number | 260 | Начальная скорость, px/с (формат «260»). |
| length? | number | 14 | Длина искры, px (формат «14»). |
| life? | number | 0.5 | Время жизни, сек (формат «0.5»). |
| hue? | number | 300 | Тон в oklch, 0–360 (формат «300»). |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/click-spark.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";
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>
);
}
/**
* Искры от клика на canvas 2D — vanilla-версия, без React.
*
* Использование:
* ```html
* <div id="spark-zone" style="position: relative; height: 320px;"></div>
* <script type="module">
* import { initClickSpark } from "./click-spark.vanilla.js";
* const destroy = initClickSpark(document.getElementById("spark-zone"), { hue: 300 });
* // при демонтаже: destroy();
* </script>
* ```
*
* Логика зеркальна react-версии (одна математика и тайминги):
* - ПУЛ на 240 слотов создаётся один раз — ни одного нового объекта за всё время работы,
* значит нет и пауз на сборку мусора посреди анимации;
* - цикл кадров СПИТ: стартует первым кликом, останавливается с последней погасшей искрой;
* - искра — отрезок по направлению движения: длина из скорости, быстрая вытянута,
* гаснущая — короткая.
*
* @param {HTMLElement} el контейнер (position: relative/absolute — канвас растянется по нему)
* @param {{count?: number, speed?: number, length?: number, life?: number, hue?: number, gravity?: number}} [options]
* @returns {() => void} destroy — снять слушатели и убрать канвас
*/
export function initClickSpark(el, options = {}) {
const cfg = {
count: 14,
speed: 260,
length: 14,
life: 0.5,
hue: 300,
gravity: 420,
...options,
};
const POOL = 240;
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 () => canvas.remove();
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, py) => {
for (let i = 0; i < cfg.count; i++) {
const s = next;
next = (next + 1) % POOL;
if (!alive[s]) living++;
const a = Math.random() * Math.PI * 2;
const v = cfg.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] = cfg.life * (0.7 + Math.random() * 0.6);
}
};
let raf = 0;
let last = 0;
const frame = (now) => {
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] += cfg.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(cfg.length, (v / cfg.speed) * cfg.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} ${cfg.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) => {
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();
};
// init живёт на ЧУЖОМ DOM — все свои подписки обязан снять в destroy
el.addEventListener("pointerdown", onDown);
return () => {
el.removeEventListener("pointerdown", onDown);
cancelAnimationFrame(raf);
ro.disconnect();
canvas.remove();
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0