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>
  );
}

Avis

Bento Magnetic Cards — React Bento Section — Incubator