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

Confetti

Конфетти по клику: залп из точки клика, своя физика на canvas — гравитация, трение, вращение. Цикл кадров живёт только пока живы частицы.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
count?number140Частиц на один залп.
power?number1Множитель силы разлёта.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/confetti.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";
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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — Magic UI Confetti (MIT)v1.0.0