Retour au catalogue
Bento Magnetic Cards
Grille bento asymetrique (grid-template-areas) avec 6 cellules de tailles variees. Chaque cellule reagit au hover avec un tilt magnetique (rotateX/Y ±8deg, useSpring). La cellule survole monte en z-index + scale 1.025, les autres s'estompent a 0.6. Contenu heterogene : stat, feature, quote, blob visuel, CTA.
bentocomplex Both Responsive a11y
boldeleganteditorialsaasagencyuniversalgridasymmetric
Theme
"use client";
import React, { useRef, useState } from "react";
import { motion, useMotionValue, useSpring, useTransform } from "framer-motion";
import * as LucideIcons from "lucide-react";
export type CellVariant = "stat" | "feature" | "quote" | "visual" | "cta";
export interface BentoCell {
id: string; variant: CellVariant; area: string;
statValue?: string; statLabel?: string; statDelta?: string;
featureTitle?: string; featureDesc?: string; featureIconName?: string;
quoteText?: string; quoteAuthor?: string; quoteRole?: string;
visualLabel?: string;
ctaTitle?: string; ctaDesc?: string; ctaLabel?: string;
}
interface Props { badge?: string; title?: string; subtitle?: string; cells: BentoCell[] }
const SPRING = { stiffness: 220, damping: 28, mass: 0.6 };
const EASE = [0.16, 1, 0.3, 1] as const;
const P = "1.75rem";
function Icon({ name }: { name?: string }) {
if (!name) return null;
const C = (LucideIcons as unknown as Record<string, React.ElementType>)[name];
return C ? <C size={20} /> : null;
}
function CellContent({ cell, isHovered }: { cell: BentoCell; isHovered: boolean }) {
if (cell.variant === "stat") return (
<div style={{ padding: P, height: "100%", display: "flex", flexDirection: "column", justifyContent: "space-between" }}>
<span style={{ fontSize: "0.6875rem", fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-foreground-muted)" }}>{cell.statLabel}</span>
<div>
<div style={{ fontSize: "clamp(2.5rem,5vw,4rem)", fontWeight: 700, letterSpacing: "-0.04em", lineHeight: 1, color: "var(--color-foreground)", marginBottom: "0.5rem" }}>{cell.statValue}</div>
{cell.statDelta && <span style={{ display: "inline-flex", fontSize: "0.8125rem", fontWeight: 500, color: "var(--color-accent)", background: "color-mix(in srgb,var(--color-accent) 10%,transparent)", padding: "0.2rem 0.625rem", borderRadius: "var(--radius-full)" }}>{cell.statDelta}</span>}
</div>
</div>
);
if (cell.variant === "feature") return (
<div style={{ padding: P, height: "100%", display: "flex", flexDirection: "column", gap: "1rem" }}>
<div style={{ width: "2.75rem", height: "2.75rem", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-md)", background: "color-mix(in srgb,var(--color-accent) 12%,transparent)", color: "var(--color-accent)", flexShrink: 0 }}>
<Icon name={cell.featureIconName} />
</div>
<div>
<h3 style={{ fontSize: "1.0625rem", fontWeight: 600, color: "var(--color-foreground)", lineHeight: 1.3, marginBottom: "0.5rem" }}>{cell.featureTitle}</h3>
<p style={{ fontSize: "0.875rem", lineHeight: 1.65, color: "var(--color-foreground-muted)" }}>{cell.featureDesc}</p>
</div>
</div>
);
if (cell.variant === "quote") return (
<div style={{ padding: P, height: "100%", display: "flex", flexDirection: "column", justifyContent: "space-between" }}>
<svg width="28" height="20" viewBox="0 0 28 20" fill="none" aria-hidden>
<path d="M0 20V12.667C0 5.556 4.444 1.333 13.333 0L14.667 2.667C10.444 3.778 8.222 5.889 8 9H13.333V20H0ZM14.667 20V12.667C14.667 5.556 19.111 1.333 28 0L29.333 2.667C25.111 3.778 22.889 5.889 22.667 9H28V20H14.667Z" fill="var(--color-accent)" fillOpacity="0.25" />
</svg>
<p style={{ flex: 1, marginTop: "1rem", fontSize: "0.9375rem", lineHeight: 1.7, color: "var(--color-foreground)", fontStyle: "italic" }}>{cell.quoteText}</p>
<div style={{ marginTop: "1.25rem" }}>
<div style={{ fontSize: "0.875rem", fontWeight: 600, color: "var(--color-foreground)" }}>{cell.quoteAuthor}</div>
<div style={{ fontSize: "0.75rem", color: "var(--color-foreground-muted)", marginTop: "0.125rem" }}>{cell.quoteRole}</div>
</div>
</div>
);
if (cell.variant === "visual") return (
<div style={{ position: "relative", height: "100%", minHeight: "200px", overflow: "hidden" }}>
<motion.div
animate={isHovered ? { scale: 1.15, rotate: 15 } : { scale: 1, rotate: 0 }}
transition={{ duration: 0.8, ease: EASE }}
style={{ position: "absolute", inset: 0, filter: "blur(24px)", background: "radial-gradient(ellipse 80% 80% at 40% 50%,color-mix(in srgb,var(--color-accent) 40%,transparent),transparent 70%),radial-gradient(ellipse 50% 60% at 70% 30%,color-mix(in srgb,var(--color-accent) 20%,transparent),transparent 60%)" }}
/>
<motion.div animate={{ opacity: isHovered ? 1 : 0.5 }} transition={{ duration: 0.4 }} style={{ position: "absolute", bottom: "1.5rem", left: P, fontSize: "0.75rem", fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-foreground-muted)" }}>
{cell.visualLabel}
</motion.div>
</div>
);
if (cell.variant === "cta") return (
<div style={{ padding: P, height: "100%", display: "flex", flexDirection: "column", justifyContent: "space-between", background: "linear-gradient(135deg,color-mix(in srgb,var(--color-accent) 8%,var(--color-background-card)),var(--color-background-card))" }}>
<div>
<h3 style={{ fontSize: "1.25rem", fontWeight: 700, letterSpacing: "-0.02em", color: "var(--color-foreground)", lineHeight: 1.25, marginBottom: "0.625rem" }}>{cell.ctaTitle}</h3>
<p style={{ fontSize: "0.875rem", lineHeight: 1.6, color: "var(--color-foreground-muted)" }}>{cell.ctaDesc}</p>
</div>
<motion.button whileHover={{ scale: 1.04 }} whileTap={{ scale: 0.97 }} style={{ marginTop: "1.5rem", alignSelf: "flex-start", display: "inline-flex", alignItems: "center", gap: "0.5rem", padding: "0.625rem 1.25rem", borderRadius: "var(--radius-full)", fontSize: "0.875rem", fontWeight: 600, background: "var(--color-accent)", color: "var(--color-background)", border: "none", cursor: "pointer" }}>
{cell.ctaLabel}
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden><path d="M3 7h8M7 3l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
</motion.button>
</div>
);
return null;
}
function MagneticCell({ cell, index, hoveredId, onHover }: { cell: BentoCell; index: number; hoveredId: string | null; onHover: (id: string | null) => void }) {
const ref = useRef<HTMLDivElement>(null);
const mx = useMotionValue(0);
const my = useMotionValue(0);
const rotateX = useSpring(useTransform(my, [-0.5, 0.5], [8, -8]), SPRING);
const rotateY = useSpring(useTransform(mx, [-0.5, 0.5], [-8, 8]), SPRING);
const scale = useSpring(1, SPRING);
const isHovered = hoveredId === cell.id;
const isDimmed = hoveredId !== null && !isHovered;
function handleMove(e: React.MouseEvent) {
const r = ref.current?.getBoundingClientRect();
if (!r) return;
mx.set((e.clientX - r.left) / r.width - 0.5);
my.set((e.clientY - r.top) / r.height - 0.5);
}
function handleEnter() { scale.set(1.025); onHover(cell.id); }
function handleLeave() { mx.set(0); my.set(0); scale.set(1); onHover(null); }
return (
<motion.div
ref={ref}
onMouseMove={handleMove} onMouseEnter={handleEnter} onMouseLeave={handleLeave}
initial={{ opacity: 0, y: 32, scale: 0.96 }}
whileInView={{ opacity: 1, y: 0, scale: 1 }}
viewport={{ once: true, margin: "-48px" }}
transition={{ delay: index * 0.09, duration: 0.55, ease: EASE }}
animate={{ opacity: isDimmed ? 0.6 : 1 }}
style={{ gridArea: cell.area, perspective: "900px", rotateX, rotateY, scale, zIndex: isHovered ? 10 : 1, transformStyle: "preserve-3d", borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)", backgroundColor: "var(--color-background-card)", overflow: "hidden", cursor: "default", transition: "opacity 0.25s ease", willChange: "transform", minHeight: "160px" }}
>
<CellContent cell={cell} isHovered={isHovered} />
</motion.div>
);
}
export default function BentoMagneticCards({ badge, title, subtitle, cells }: Props) {
const [hoveredId, setHoveredId] = useState<string | null>(null);
return (
<section style={{ background: "var(--color-background)", paddingTop: "var(--section-padding-y,6rem)", paddingBottom: "var(--section-padding-y,6rem)" }}>
<div style={{ maxWidth: "var(--container-max-width,1280px)", margin: "0 auto", padding: "0 var(--container-padding-x,1.5rem)" }}>
<motion.div initial={{ opacity: 0, y: 24 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.55, ease: EASE }} style={{ textAlign: "center", maxWidth: "640px", margin: "0 auto 3.5rem" }}>
{badge && <span style={{ display: "inline-block", fontSize: "0.6875rem", fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-accent)", padding: "0.25rem 0.875rem", borderRadius: "var(--radius-full)", border: "1px solid var(--color-border)", marginBottom: "1.25rem" }}>{badge}</span>}
{title && <h2 style={{ fontSize: "clamp(1.875rem,4vw,3rem)", fontWeight: 700, letterSpacing: "-0.03em", lineHeight: 1.1, color: "var(--color-foreground)", marginBottom: "0.875rem" }}>{title}</h2>}
{subtitle && <p style={{ fontSize: "1.0625rem", lineHeight: 1.65, color: "var(--color-foreground-muted)" }}>{subtitle}</p>}
</motion.div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gridTemplateAreas: `"a a b" "c d d" "e e f"`, gap: "0.875rem" }}>
{cells.map((cell, i) => <MagneticCell key={cell.id} cell={cell} index={i} hoveredId={hoveredId} onHover={setHoveredId} />)}
</div>
</div>
</section>
);
}