398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
useRef,
|
|
useEffect,
|
|
useState,
|
|
type ReactNode,
|
|
type CSSProperties,
|
|
} from "react";
|
|
import { gsap } from "gsap";
|
|
|
|
const DEFAULT_PARTICLE_COUNT = 12;
|
|
const DEFAULT_SPOTLIGHT_RADIUS = 400;
|
|
const DEFAULT_GLOW_COLOR = "220, 68, 22";
|
|
const MOBILE_BREAKPOINT = 768;
|
|
|
|
/* ---------- types ---------- */
|
|
|
|
type CardData = {
|
|
title: string;
|
|
description: string;
|
|
label: string;
|
|
children?: ReactNode;
|
|
};
|
|
|
|
type MagicBentoSectionProps = {
|
|
eyebrow: string;
|
|
heading: string;
|
|
description: string;
|
|
cards: CardData[];
|
|
glowColor?: string;
|
|
spotlightRadius?: number;
|
|
particleCount?: number;
|
|
enableStars?: boolean;
|
|
enableSpotlight?: boolean;
|
|
enableBorderGlow?: boolean;
|
|
enableTilt?: boolean;
|
|
enableMagnetism?: boolean;
|
|
clickEffect?: boolean;
|
|
textAutoHide?: boolean;
|
|
};
|
|
|
|
/* ---------- helpers ---------- */
|
|
|
|
function useMobileDetection() {
|
|
const [isMobile, setIsMobile] = useState(false);
|
|
useEffect(() => {
|
|
const check = () => setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT);
|
|
check();
|
|
window.addEventListener("resize", check);
|
|
return () => window.removeEventListener("resize", check);
|
|
}, []);
|
|
return isMobile;
|
|
}
|
|
|
|
function calculateSpotlightValues(radius: number) {
|
|
return { proximity: radius * 0.5, fadeDistance: radius * 0.75 };
|
|
}
|
|
|
|
function updateCardGlow(
|
|
card: HTMLElement,
|
|
mx: number,
|
|
my: number,
|
|
glow: number,
|
|
radius: number,
|
|
) {
|
|
const r = card.getBoundingClientRect();
|
|
card.style.setProperty("--glow-x", `${((mx - r.left) / r.width) * 100}%`);
|
|
card.style.setProperty("--glow-y", `${((my - r.top) / r.height) * 100}%`);
|
|
card.style.setProperty("--glow-intensity", glow.toString());
|
|
card.style.setProperty("--glow-radius", `${radius}px`);
|
|
}
|
|
|
|
/* ---------- ParticleCard ---------- */
|
|
|
|
function ParticleCard({
|
|
children,
|
|
className = "",
|
|
style,
|
|
disabled,
|
|
particleCount,
|
|
glowColor,
|
|
enableTilt,
|
|
enableMagnetism,
|
|
clickEffect,
|
|
}: {
|
|
children: ReactNode;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
disabled: boolean;
|
|
particleCount: number;
|
|
glowColor: string;
|
|
enableTilt: boolean;
|
|
enableMagnetism: boolean;
|
|
clickEffect: boolean;
|
|
}) {
|
|
const cardRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (disabled || !cardRef.current) return;
|
|
|
|
const el = cardRef.current;
|
|
const particles: HTMLDivElement[] = [];
|
|
const timeouts: ReturnType<typeof setTimeout>[] = [];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let magnetAnim: any = null;
|
|
let hovered = false;
|
|
|
|
const spawnParticles = () => {
|
|
const { width, height } = el.getBoundingClientRect();
|
|
for (let i = 0; i < particleCount; i++) {
|
|
const tid = setTimeout(() => {
|
|
if (!hovered) return;
|
|
const p = document.createElement("div");
|
|
p.style.cssText = `
|
|
position:absolute;width:4px;height:4px;border-radius:50%;
|
|
background:rgba(${glowColor},0.9);
|
|
box-shadow:0 0 6px rgba(${glowColor},0.5);
|
|
pointer-events:none;z-index:100;
|
|
left:${Math.random() * width}px;top:${Math.random() * height}px;
|
|
`;
|
|
el.appendChild(p);
|
|
particles.push(p);
|
|
gsap.fromTo(p, { scale: 0, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.3, ease: "back.out(1.7)" });
|
|
gsap.to(p, { x: (Math.random() - 0.5) * 100, y: (Math.random() - 0.5) * 100, rotation: Math.random() * 360, duration: 2 + Math.random() * 2, ease: "none", repeat: -1, yoyo: true });
|
|
gsap.to(p, { opacity: 0.3, duration: 1.5, ease: "power2.inOut", repeat: -1, yoyo: true });
|
|
}, i * 100);
|
|
timeouts.push(tid);
|
|
}
|
|
};
|
|
|
|
const clearParticles = () => {
|
|
timeouts.forEach(clearTimeout);
|
|
timeouts.length = 0;
|
|
magnetAnim?.kill();
|
|
particles.forEach((p) =>
|
|
gsap.to(p, { scale: 0, opacity: 0, duration: 0.3, ease: "back.in(1.7)", onComplete: () => p.remove() }),
|
|
);
|
|
particles.length = 0;
|
|
};
|
|
|
|
const onEnter = () => {
|
|
hovered = true;
|
|
spawnParticles();
|
|
if (enableTilt) {
|
|
gsap.to(el, { rotateX: 5, rotateY: 5, duration: 0.3, ease: "power2.out", transformPerspective: 1000 });
|
|
}
|
|
};
|
|
|
|
const onLeave = () => {
|
|
hovered = false;
|
|
clearParticles();
|
|
if (enableTilt) gsap.to(el, { rotateX: 0, rotateY: 0, duration: 0.3, ease: "power2.out" });
|
|
if (enableMagnetism) gsap.to(el, { x: 0, y: 0, duration: 0.3, ease: "power2.out" });
|
|
};
|
|
|
|
const onMove = (e: MouseEvent) => {
|
|
if (!enableTilt && !enableMagnetism) return;
|
|
const rect = el.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
const cx = rect.width / 2;
|
|
const cy = rect.height / 2;
|
|
if (enableTilt) {
|
|
gsap.to(el, { rotateX: ((y - cy) / cy) * -10, rotateY: ((x - cx) / cx) * 10, duration: 0.1, ease: "power2.out", transformPerspective: 1000 });
|
|
}
|
|
if (enableMagnetism) {
|
|
magnetAnim = gsap.to(el, { x: (x - cx) * 0.05, y: (y - cy) * 0.05, duration: 0.3, ease: "power2.out" });
|
|
}
|
|
};
|
|
|
|
const onClick = (e: MouseEvent) => {
|
|
if (!clickEffect) return;
|
|
const rect = el.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
const maxD = Math.max(
|
|
Math.hypot(x, y),
|
|
Math.hypot(x - rect.width, y),
|
|
Math.hypot(x, y - rect.height),
|
|
Math.hypot(x - rect.width, y - rect.height),
|
|
);
|
|
const ripple = document.createElement("div");
|
|
ripple.style.cssText = `
|
|
position:absolute;width:${maxD * 2}px;height:${maxD * 2}px;border-radius:50%;
|
|
background:radial-gradient(circle,rgba(${glowColor},0.4) 0%,rgba(${glowColor},0.2) 30%,transparent 70%);
|
|
left:${x - maxD}px;top:${y - maxD}px;pointer-events:none;z-index:1000;
|
|
`;
|
|
el.appendChild(ripple);
|
|
gsap.fromTo(ripple, { scale: 0, opacity: 1 }, { scale: 1, opacity: 0, duration: 0.8, ease: "power2.out", onComplete: () => ripple.remove() });
|
|
};
|
|
|
|
el.addEventListener("mouseenter", onEnter);
|
|
el.addEventListener("mouseleave", onLeave);
|
|
el.addEventListener("mousemove", onMove);
|
|
el.addEventListener("click", onClick);
|
|
|
|
return () => {
|
|
hovered = false;
|
|
el.removeEventListener("mouseenter", onEnter);
|
|
el.removeEventListener("mouseleave", onLeave);
|
|
el.removeEventListener("mousemove", onMove);
|
|
el.removeEventListener("click", onClick);
|
|
clearParticles();
|
|
};
|
|
}, [disabled, particleCount, glowColor, enableTilt, enableMagnetism, clickEffect]);
|
|
|
|
return (
|
|
<div ref={cardRef} className={className} style={{ ...style, position: "relative", overflow: "hidden" }}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---------- GlobalSpotlight ---------- */
|
|
|
|
function GlobalSpotlight({
|
|
gridRef,
|
|
disabled,
|
|
spotlightRadius,
|
|
glowColor,
|
|
}: {
|
|
gridRef: React.RefObject<HTMLDivElement | null>;
|
|
disabled: boolean;
|
|
spotlightRadius: number;
|
|
glowColor: string;
|
|
}) {
|
|
useEffect(() => {
|
|
if (disabled || !gridRef.current) return;
|
|
|
|
const spotlight = document.createElement("div");
|
|
spotlight.style.cssText = `
|
|
position:fixed;width:800px;height:800px;border-radius:50%;pointer-events:none;
|
|
background:radial-gradient(circle,
|
|
rgba(${glowColor},0.15) 0%,rgba(${glowColor},0.08) 15%,
|
|
rgba(${glowColor},0.04) 25%,rgba(${glowColor},0.02) 40%,
|
|
rgba(${glowColor},0.01) 65%,transparent 70%);
|
|
z-index:200;opacity:0;transform:translate(-50%,-50%);
|
|
mix-blend-mode:screen;will-change:transform,opacity;
|
|
`;
|
|
document.body.appendChild(spotlight);
|
|
|
|
const grid = gridRef.current;
|
|
|
|
const onMove = (e: MouseEvent) => {
|
|
const rect = grid.getBoundingClientRect();
|
|
const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
|
|
const cards = grid.querySelectorAll<HTMLElement>(".mb-card");
|
|
|
|
if (!inside) {
|
|
gsap.to(spotlight, { opacity: 0, duration: 0.3, ease: "power2.out" });
|
|
cards.forEach((c) => c.style.setProperty("--glow-intensity", "0"));
|
|
return;
|
|
}
|
|
|
|
const { proximity, fadeDistance } = calculateSpotlightValues(spotlightRadius);
|
|
let minDist = Infinity;
|
|
|
|
cards.forEach((card) => {
|
|
const cr = card.getBoundingClientRect();
|
|
const cx = cr.left + cr.width / 2;
|
|
const cy = cr.top + cr.height / 2;
|
|
const dist = Math.max(0, Math.hypot(e.clientX - cx, e.clientY - cy) - Math.max(cr.width, cr.height) / 2);
|
|
minDist = Math.min(minDist, dist);
|
|
const intensity = dist <= proximity ? 1 : dist <= fadeDistance ? (fadeDistance - dist) / (fadeDistance - proximity) : 0;
|
|
updateCardGlow(card, e.clientX, e.clientY, intensity, spotlightRadius);
|
|
});
|
|
|
|
gsap.to(spotlight, { left: e.clientX, top: e.clientY, duration: 0.1, ease: "power2.out" });
|
|
const opacity = minDist <= proximity ? 0.8 : minDist <= fadeDistance ? ((fadeDistance - minDist) / (fadeDistance - proximity)) * 0.8 : 0;
|
|
gsap.to(spotlight, { opacity, duration: opacity > 0 ? 0.2 : 0.5, ease: "power2.out" });
|
|
};
|
|
|
|
const onLeave = () => {
|
|
grid.querySelectorAll<HTMLElement>(".mb-card").forEach((c) => c.style.setProperty("--glow-intensity", "0"));
|
|
gsap.to(spotlight, { opacity: 0, duration: 0.3, ease: "power2.out" });
|
|
};
|
|
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseleave", onLeave);
|
|
return () => {
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseleave", onLeave);
|
|
spotlight.remove();
|
|
};
|
|
}, [disabled, gridRef, spotlightRadius, glowColor]);
|
|
|
|
return null;
|
|
}
|
|
|
|
/* ---------- MagicBentoSection ---------- */
|
|
|
|
export function MagicBentoSection({
|
|
eyebrow,
|
|
heading,
|
|
description,
|
|
cards,
|
|
glowColor = DEFAULT_GLOW_COLOR,
|
|
spotlightRadius = DEFAULT_SPOTLIGHT_RADIUS,
|
|
particleCount = DEFAULT_PARTICLE_COUNT,
|
|
enableStars = true,
|
|
enableSpotlight = true,
|
|
enableBorderGlow = true,
|
|
enableTilt = true,
|
|
enableMagnetism = true,
|
|
clickEffect = true,
|
|
textAutoHide = true,
|
|
}: MagicBentoSectionProps) {
|
|
const gridRef = useRef<HTMLDivElement>(null);
|
|
const isMobile = useMobileDetection();
|
|
const disabled = isMobile;
|
|
|
|
return (
|
|
<section className="magic-bento-wrapper space-y-8">
|
|
{/* Section heading — matches project's SectionHeading pattern */}
|
|
<div className="space-y-3">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
|
{eyebrow}
|
|
</p>
|
|
<div className="space-y-2">
|
|
<h2 className="text-2xl font-semibold tracking-[-0.05em] text-foreground sm:text-3xl">
|
|
{heading}
|
|
</h2>
|
|
<p className="max-w-2xl text-sm leading-7 text-muted-foreground">
|
|
{description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{enableSpotlight && (
|
|
<GlobalSpotlight
|
|
gridRef={gridRef}
|
|
disabled={disabled}
|
|
spotlightRadius={spotlightRadius}
|
|
glowColor={glowColor}
|
|
/>
|
|
)}
|
|
|
|
<div ref={gridRef} className="mb-grid" style={{ userSelect: "none" }}>
|
|
{cards.map((card, index) => {
|
|
const cls = [
|
|
"mb-card",
|
|
textAutoHide ? "mb-card--autohide" : "",
|
|
enableBorderGlow ? "mb-card--glow" : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
|
|
const cardStyle: CSSProperties = {
|
|
"--glow-color": glowColor,
|
|
} as CSSProperties;
|
|
|
|
const content = (
|
|
<>
|
|
<div className="mb-card__header">
|
|
<span className="mb-card__label">{card.label}</span>
|
|
</div>
|
|
<div className="mb-card__body">
|
|
<h3 className="mb-card__title">{card.title}</h3>
|
|
{card.description && (
|
|
<p className="mb-card__desc">{card.description}</p>
|
|
)}
|
|
{card.children && (
|
|
<div className="mb-card__extra">{card.children}</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
if (enableStars) {
|
|
return (
|
|
<ParticleCard
|
|
key={index}
|
|
className={cls}
|
|
style={cardStyle}
|
|
disabled={disabled}
|
|
particleCount={particleCount}
|
|
glowColor={glowColor}
|
|
enableTilt={enableTilt}
|
|
enableMagnetism={enableMagnetism}
|
|
clickEffect={clickEffect}
|
|
>
|
|
{content}
|
|
</ParticleCard>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div key={index} className={cls} style={cardStyle}>
|
|
{content}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|