Files
CapaKraken/apps/web/src/components/ui/ConfettiBurst.tsx
T
Hartmut f1f1be21c7 feat: Sprint 3 — delight, polish, and responsive sidebar
Celebration micro-interactions:
- SuccessToast: auto-dismissing pill toast (success/info/warning variants)
- ConfettiBurst: pure CSS 20-particle confetti on project creation
- Project wizard: confetti + toast on successful creation
- Vacation approval/rejection: contextual toasts
- Allocation status change: success toast
- Button: active:scale-[0.97] press feedback on all variants

Collapsible sidebar + responsive:
- Desktop: toggle collapse (72px icons-only mode) with localStorage persistence
- NavTooltip: hover labels on collapsed icons
- Mobile: hamburger menu + slide-in overlay with backdrop
- Auto-close sidebar on mobile navigation
- Scroll-to-top on route change (smooth behavior)

Hover polish + accessibility:
- Table rows: animated left-border accent + hover-lift
- Stat cards + widgets: hover elevation + border glow
- Timeline blocks: scale(1.02) + shadow-md on hover
- Smooth scroll globally with prefers-reduced-motion fallback
- Filter chips: framer-motion scale+fade enter/exit
- Dropdowns: scaleY origin-top reveal animation
- Preferences modal: scale+fade entrance
- Link underline: animated ::after width expansion on hover

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-19 01:02:51 +01:00

106 lines
2.7 KiB
TypeScript

"use client";
import { useEffect, useRef, useCallback } from "react";
interface ConfettiBurstProps {
trigger: boolean;
particleCount?: number;
duration?: number;
colors?: string[];
}
const DEFAULT_COLORS = [
"#6366f1", // indigo
"#8b5cf6", // violet
"#ec4899", // pink
"#f59e0b", // amber
"#10b981", // emerald
"#3b82f6", // blue
];
const KEYFRAMES_ID = "confetti-burst-keyframes";
function ensureKeyframes() {
if (typeof document === "undefined") return;
if (document.getElementById(KEYFRAMES_ID)) return;
const style = document.createElement("style");
style.id = KEYFRAMES_ID;
style.textContent = `
@keyframes confetti-burst {
0% {
transform: translate(0, 0) rotate(0deg) scale(1);
opacity: 1;
}
100% {
transform: translate(var(--confetti-x), var(--confetti-y)) rotate(var(--confetti-r)) scale(0.3);
opacity: 0;
}
}
`;
document.head.appendChild(style);
}
export function ConfettiBurst({
trigger,
particleCount = 20,
duration = 800,
colors = DEFAULT_COLORS,
}: ConfettiBurstProps) {
const containerRef = useRef<HTMLDivElement>(null);
const prevTrigger = useRef(false);
const burst = useCallback(() => {
const container = containerRef.current;
if (!container) return;
ensureKeyframes();
const particles: HTMLDivElement[] = [];
for (let i = 0; i < particleCount; i++) {
const el = document.createElement("div");
const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.5;
const distance = 40 + Math.random() * 80;
const x = Math.cos(angle) * distance;
const y = Math.sin(angle) * distance - 20 + Math.random() * 60; // gravity bias
const rotation = Math.random() * 720 - 360;
el.style.cssText = `
position: absolute;
left: 50%;
top: 50%;
width: 6px;
height: 6px;
margin: -3px 0 0 -3px;
border-radius: ${Math.random() > 0.5 ? "50%" : "1px"};
background: ${colors[i % colors.length]};
pointer-events: none;
--confetti-x: ${x}px;
--confetti-y: ${y}px;
--confetti-r: ${rotation}deg;
animation: confetti-burst ${duration}ms cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
`;
container.appendChild(el);
particles.push(el);
}
setTimeout(() => {
particles.forEach((el) => el.remove());
}, duration + 50);
}, [particleCount, duration, colors]);
useEffect(() => {
if (trigger && !prevTrigger.current) {
burst();
}
prevTrigger.current = trigger;
}, [trigger, burst]);
return (
<div
ref={containerRef}
className="pointer-events-none absolute inset-0 overflow-visible"
aria-hidden="true"
/>
);
}