← каталог
Confetti
Конфетти по клику: залп из точки клика, своя физика на canvas — гравитация, трение, вращение. Цикл кадров живёт только пока живы частицы.
cssclickbasic~2 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| count? | number | 140 | Частиц на один залп. |
| power? | number | 1 | Множитель силы разлёта. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/confetti.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";
import type { ReactNode } from "react";
export interface ConfettiProps {
/** Частиц на один залп. */
count?: number;
/** Множитель силы разлёта. */
power?: number;
/** Содержимое поверх (кнопка, подпись); клики проходят сквозь него на слой. */
children?: ReactNode;
}
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
rot: number;
vr: number;
color: string;
w: number;
h: number;
round: boolean;
born: number;
}
/** Праздничная палитра — фиксированные oklch, чтобы залп читался и на тёмном, и на светлом. */
const COLORS = [
"oklch(0.75 0.16 300)",
"oklch(0.72 0.15 260)",
"oklch(0.75 0.14 230)",
"oklch(0.78 0.15 150)",
"oklch(0.82 0.15 90)",
"oklch(0.72 0.17 25)",
];
const LIFE = 2.6; // секунд живёт частица (дальше гасится альфой)
/**
* Конфетти: клик — залп из точки клика. Физика своя (~60 строк): радиальный разлёт,
* гравитация, вязкое трение, вращение прямоугольничков. Цикл кадров живёт ТОЛЬКО пока
* живы частицы — доиграл и заснул, ноль rAF в покое. Reduced-motion / data-shot —
* клик не запускает ничего.
*/
export function Confetti({ count = 140, power = 1, children }: ConfettiProps = {}) {
const hostRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const host = hostRef.current;
const canvas = canvasRef.current;
if (!host || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let raf = 0;
let last = 0;
let dpr = 1;
const particles: Particle[] = [];
const resize = () => {
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(host.clientWidth * dpr));
canvas.height = Math.max(1, Math.round(host.clientHeight * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
const ro = new ResizeObserver(resize);
ro.observe(host);
resize();
const frame = (t: number) => {
raf = 0;
const dt = Math.min(0.05, (t - last) / 1000) || 0.016;
last = t;
const w = host.clientWidth;
const h = host.clientHeight;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const now = performance.now() / 1000;
const drag = Math.exp(-2.1 * dt);
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i] as Particle;
p.vy += 1350 * dt;
p.vx *= drag;
p.vy *= drag;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.rot += p.vr * dt;
const age = now - p.born;
if (age > LIFE || p.y > h + 24) {
particles.splice(i, 1);
continue;
}
ctx.globalAlpha = age < LIFE - 0.6 ? 1 : Math.max(0, (LIFE - age) / 0.6);
ctx.fillStyle = p.color;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // сброс поворота предыдущей частицы, dpr сохранён
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
if (p.round) {
ctx.beginPath();
ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2);
ctx.fill();
} else {
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
}
}
ctx.globalAlpha = 1;
// цикл жив, только пока живы частицы: доиграл — заснул
if (particles.length) raf = requestAnimationFrame(frame);
else ctx.clearRect(0, 0, w, h);
};
const burst = (x: number, y: number) => {
const now = performance.now() / 1000;
for (let i = 0; i < count; i++) {
const dir = Math.random() * Math.PI * 2;
const speed = (260 + Math.random() * 620) * power;
particles.push({
x,
y,
vx: Math.cos(dir) * speed,
vy: Math.sin(dir) * speed - 380 * power, // залп смещён вверх
rot: Math.random() * Math.PI,
vr: (Math.random() - 0.5) * 14,
color: COLORS[(Math.random() * COLORS.length) | 0] as string,
w: 5 + Math.random() * 5,
h: 3 + Math.random() * 4,
round: Math.random() < 0.25,
born: now,
});
}
if (!raf) {
last = performance.now();
raf = requestAnimationFrame(frame);
}
};
const onClick = (e: PointerEvent) => {
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (frozen) return;
const r = host.getBoundingClientRect();
burst(e.clientX - r.left, e.clientY - r.top);
};
host.addEventListener("pointerdown", onClick);
return () => {
host.removeEventListener("pointerdown", onClick);
ro.disconnect();
if (raf) cancelAnimationFrame(raf);
};
}, [count, power]);
return (
<div ref={hostRef} style={{ position: "absolute", inset: 0, overflow: "hidden", cursor: "pointer" }}>
<canvas ref={canvasRef} style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }} />
{children && (
<div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center", pointerEvents: "none" }}>
{children}
</div>
)}
</div>
);
}
/**
* Confetti — vanilla-версия (без React).
*
* Клик по контейнеру — залп конфетти из точки клика. Физика своя: радиальный разлёт,
* гравитация, вязкое трение, вращение. Цикл кадров живёт ТОЛЬКО пока живы частицы.
* Reduced-motion / data-shot — клик не запускает ничего.
*
* HTML:
* <div id="party" style="position:relative; height:320px;"></div>
* <script type="module">
* import { initConfetti } from "./confetti.vanilla.js";
* const destroy = initConfetti(document.querySelector("#party"), { count: 140 });
* </script>
*
* @param {HTMLElement} element - контейнер (position: relative/absolute); канвас создаётся внутри
* @param {{ count?: number, power?: number }} [options]
* @returns {() => void} destroy
*/
export function initConfetti(element, options = {}) {
const { count = 140, power = 1 } = options;
const COLORS = [
"oklch(0.75 0.16 300)",
"oklch(0.72 0.15 260)",
"oklch(0.75 0.14 230)",
"oklch(0.78 0.15 150)",
"oklch(0.82 0.15 90)",
"oklch(0.72 0.17 25)",
];
const LIFE = 2.6;
const canvas = document.createElement("canvas");
canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;pointer-events:none;";
element.appendChild(canvas);
const ctx = canvas.getContext("2d");
if (!ctx) return () => canvas.remove();
let raf = 0;
let last = 0;
let dpr = 1;
const particles = [];
const resize = () => {
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(element.clientWidth * dpr));
canvas.height = Math.max(1, Math.round(element.clientHeight * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
const ro = new ResizeObserver(resize);
ro.observe(element);
resize();
const frame = (t) => {
raf = 0;
const dt = Math.min(0.05, (t - last) / 1000) || 0.016;
last = t;
const w = element.clientWidth;
const h = element.clientHeight;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const now = performance.now() / 1000;
const drag = Math.exp(-2.1 * dt);
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.vy += 1350 * dt;
p.vx *= drag;
p.vy *= drag;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.rot += p.vr * dt;
const age = now - p.born;
if (age > LIFE || p.y > h + 24) {
particles.splice(i, 1);
continue;
}
ctx.globalAlpha = age < LIFE - 0.6 ? 1 : Math.max(0, (LIFE - age) / 0.6);
ctx.fillStyle = p.color;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
if (p.round) {
ctx.beginPath();
ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2);
ctx.fill();
} else {
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
}
}
ctx.globalAlpha = 1;
if (particles.length) raf = requestAnimationFrame(frame);
else {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
}
};
const burst = (x, y) => {
const now = performance.now() / 1000;
for (let i = 0; i < count; i++) {
const dir = Math.random() * Math.PI * 2;
const speed = (260 + Math.random() * 620) * power;
particles.push({
x,
y,
vx: Math.cos(dir) * speed,
vy: Math.sin(dir) * speed - 380 * power,
rot: Math.random() * Math.PI,
vr: (Math.random() - 0.5) * 14,
color: COLORS[(Math.random() * COLORS.length) | 0],
w: 5 + Math.random() * 5,
h: 3 + Math.random() * 4,
round: Math.random() < 0.25,
born: now,
});
}
if (!raf) {
last = performance.now();
raf = requestAnimationFrame(frame);
}
};
const onClick = (e) => {
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (frozen) return;
const r = element.getBoundingClientRect();
burst(e.clientX - r.left, e.clientY - r.top);
};
element.addEventListener("pointerdown", onClick);
return () => {
element.removeEventListener("pointerdown", onClick);
ro.disconnect();
if (raf) cancelAnimationFrame(raf);
canvas.remove();
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — Magic UI Confetti (MIT)v1.0.0