Créer une grille bento React avec spotlight qui suit la souris
Une grille bento avec spotlight en React positionne un seul overlay en dégradé radial en absolu sur le conteneur de grille, puis écrit les coordonnées du curseur dans des propriétés CSS custom via useMotionValue de Framer Motion. Chaque carte en dessous reçoit l'effet d'illumination sans aucun état par carte.
- Stack : React 18 + Framer Motion 11 + Tailwind v4 + Lucide React, ~100 lignes.
- Technique overlay unique : une seule motion.div pilote le spotlight pour toute la grille, pas une par carte.
- Les propriétés CSS custom (--mouse-x / --mouse-y) sont liées directement aux motion values, évitant les re-renders à chaque mouvement de souris.
- Accessible : le contenu est lisible sans l'overlay ; le spotlight est purement décoratif et pointer-events:none.
- Attention mobile : pas de pointeur sur écran tactile, donc le spotlight reste invisible et la grille s'affiche proprement sans lui.
Bento Spotlight Sweep est une section de grille de fonctionnalités où un unique spotlight radial suit le curseur sur un layout multi-colonnes. Quand la souris balaie la grille, chaque carte qu'elle survole s'illumine d'un léger éclat accent, rendant la section vivante. L'effet repose sur un seul élément overlay, pas d'état par carte, pas de listeners scroll, juste des motion values Framer Motion liées à des propriétés CSS custom.
Anatomie
Le composant comporte trois couches. D'abord, un bloc header optionnel (badge, titre, sous-titre) qui apparaît en fondu avec une simple animation whileInView. Ensuite, un conteneur de grille relative qui capture les événements mousemove et détient une ref pour le calcul des coordonnées. Enfin, à l'intérieur de ce conteneur se trouve une unique motion.div absolue, l'overlay spotlight, dimensionnée pour couvrir toute la grille et pilotée par les propriétés custom --mouse-x/--mouse-y. Les cartes elles-mêmes sont des motion.div standard qui apparaissent en décalé au scroll ; elles ne nécessitent aucune logique spotlight particulière.
Comment ça marche
L'astuce est de lier des instances useMotionValue de Framer Motion à des propriétés CSS custom sur l'élément overlay. À chaque mousemove, le handler lit la position du pointeur relativement au conteneur de grille via getBoundingClientRect, puis appelle mouseX.set() et mouseY.set(). La motion.div overlay lit --mouse-x et --mouse-y dans une chaîne de fond radial-gradient. Comme Framer Motion écrit les motion values directement dans le style DOM sans déclencher de re-render React, la mise à jour du spotlight est entièrement hors du cycle React, fluide à 60fps même avec beaucoup de cartes.
Comment le coder en React
Configurer le conteneur de grille avec une ref et un handler mousemove
Crée un div en position:relative, attache-lui une ref et branche un handler onMouseMove. Dans le handler, soustrais l'origine getBoundingClientRect() du conteneur aux clientX/Y bruts pour obtenir des coordonnées relatives au conteneur.
const containerRef = React.useRef<HTMLDivElement>(null); const mouseX = useMotionValue(0); const mouseY = useMotionValue(0); function handleMouseMove(e: React.MouseEvent) { const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; mouseX.set(e.clientX - rect.left); mouseY.set(e.clientY - rect.top); }Placer l'overlay spotlight à l'intérieur du conteneur
Ajoute une motion.div comme premier enfant du conteneur en position:absolute, inset-0, z-index au-dessus des cartes, et pointer-events:none. Lie les motion values à --mouse-x et --mouse-y via la prop style (Framer Motion accepte les motion values comme propriétés CSS custom).
<motion.div className="pointer-events-none absolute inset-0 z-10 rounded-2xl opacity-60" style={{ background: `radial-gradient(400px circle at var(--mouse-x) var(--mouse-y), color-mix(in srgb, var(--color-accent) 8%, transparent), transparent 60%)`, // @ts-expect-error CSS custom properties "--mouse-x": mouseX, "--mouse-y": mouseY, }} />Afficher les cartes bento avec un stagger whileInView
Parcours ton tableau d'items et affiche chacun comme une motion.div avec un état initial opacity:0 / y:24, en animant vers visible au scroll. Utilise un petit multiplicateur de délai (i * 0.08) pour la cascade. L'overlay spotlight se positionne au-dessus des cartes via z-index et les illumine uniquement grâce au dégradé radial.
items.map((item, i) => ( <motion.div key={item.id} initial={{ opacity: 0, y: 24 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: "-40px" }} transition={{ delay: i * 0.08, duration: 0.5 }} className="relative rounded-2xl border p-6" /> ))Thémiser la couleur du spotlight avec un token CSS
Remplace toute couleur codée en dur par var(--color-accent) dans la chaîne radial-gradient. Ajuste l'alpha via color-mix (8% fonctionne bien sur les thèmes clairs et sombres). Pour élargir ou rétrécir le faisceau, modifie la valeur de rayon 400px, des valeurs plus grandes créent un éclat plus doux et ambiant.
Quand l'utiliser
À utiliser sur les pages de fonctionnalités SaaS et les portfolios d'agences où tu veux que la grille de fonctionnalités paraisse interactive plutôt que statique. Fonctionne mieux sur les thèmes sombres ou audacieux où l'éclat accent crée un contraste visible. À éviter sur les pages très textuelles où la lumière en mouvement entre en concurrence avec la lecture, et toujours vérifier que la grille est lisible sans le spotlight sur mobile, le layout doit se suffire à lui-même.
Utilisé par
- Vercel, Utilise des highlights radiaux réactifs au curseur sur ses sections de fonctionnalités et d'infrastructure.
- Linear, Les effets de spotlight sur les grilles de fonctionnalités donnent au site produit son aspect soigné distinctif.
- Resend, Layout de fonctionnalités en style bento avec effets de lumière ambiante soulignant les bords des cartes au survol.
FAQ
Pourquoi utiliser des propriétés CSS custom plutôt qu'un style inline avec une template string ?
Framer Motion peut écrire les motion values directement dans des propriétés CSS custom sur un élément DOM, en contournant entièrement le réconcilieur de React. Cela signifie que la chaîne radial-gradient n'est jamais réévaluée par React à chaque frame, le navigateur lit simplement les valeurs --mouse-x/--mouse-y mises à jour, gardant l'animation fluide et sans accroc.
Le spotlight fonctionne-t-il quand la grille défile hors de l'écran ?
Le listener mousemove est sur le conteneur de grille lui-même, donc il ne se déclenche que quand le curseur est dans les limites de la grille. Quand l'utilisateur fait défiler ailleurs, aucun événement ne se déclenche et le spotlight reste à sa dernière position, invisible car le conteneur est hors du viewport.
Comment ajuster le rayon et l'intensité du spotlight ?
Change le 400px dans la chaîne radial-gradient pour agrandir ou réduire le cercle. Monte ou baisse le pourcentage color-mix (défaut 8%) pour augmenter ou diminuer l'intensité du glow. Une valeur entre 6% et 15% fonctionne bien ; au-delà de 20% l'effet commence à paraître agressif plutôt qu'atmosphérique.
Peut-on ajouter un highlight de bordure par carte en plus du spotlight global ?
Oui. Suis la position du pointeur relativement à chaque carte individuellement (un second mousemove sur chaque div de carte ou un seul listener qui calcule les offsets par carte), puis définis une propriété CSS custom sur cette carte pour piloter un border-image ou un box-shadow. Cela se marie bien avec l'overlay global pour créer un effet de profondeur en couches.