How to build a magnetic 3D tilt bento grid in React
A magnetic bento grid in React uses Framer Motion's useMotionValue and useSpring to map pointer position within each card to rotateX/rotateY values, producing a 3D tilt effect. The hovered card scales up slightly and dimming the rest to 0.6 opacity creates a focus spotlight across the whole grid.
- Stack: React 18 + Framer Motion 11 + Lucide React, ~147 lines, zero other dependencies.
- Core API: useMotionValue, useSpring, useTransform, tilt angle ±8 degrees, spring stiffness 220 / damping 28.
- Grid layout: CSS grid-template-areas with 3 columns and 3 rows, 6 cells across 5 named areas (a, b, c, d, e, f).
- Five cell variants: stat (big number + delta badge), feature (icon + text), quote, visual (animated gradient blob), CTA (button).
- Mobile caveat: the 3D tilt is pointer-only. On touch devices the cards display correctly but without the magnetic effect.
Bento Magnetic Cards is an asymmetric grid section where each cell physically tilts toward the cursor, like a card responding to a magnet. It combines a CSS grid-template-areas layout with per-card Framer Motion springs to give product pages a tangible, interactive feel, useful for surfacing diverse content (metrics, features, quotes, CTAs) in a single visual block.
Anatomy
The section wraps a centered header (badge, h2, subtitle) and a 3×3 CSS grid. Six MagneticCell components occupy grid areas a through f, two cells span two columns each, giving the layout its asymmetry. Each cell carries one of five content variants (stat, feature, quote, visual, cta) rendered by a stateless CellContent component. A single hoveredId state at the section level drives the dimming of non-active cards.
How it works
Inside each MagneticCell, two motion values track the normalized cursor offset within the card: mx and my range from -0.5 to 0.5. useTransform maps them to rotateX/rotateY (±8 degrees), and useSpring smooths both with the same spring config (stiffness 220, damping 28, mass 0.6). A separate spring on scale goes from 1 to 1.025 on enter. On mouse leave, all values reset to 0 and the card snaps back through the same spring. The visual cell adds a secondary animation: its gradient blob scales to 1.15 and rotates 15 degrees on hover, using Framer Motion's animate prop with the custom EASE curve.
How to build it in React
Set up the asymmetric grid
Define the layout with grid-template-areas so cell sizes vary without media queries. Assign each BentoCell an area string that matches the template. Three rows and three columns are enough for six cells with two spanning two columns.
style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gridTemplateAreas: `"a a b" "c d d" "e e f"`, gap: "0.875rem", }}Track normalized cursor position inside each card
On mousemove, compute the cursor's X and Y offset relative to the card with getBoundingClientRect, then divide by width/height to get a value between 0 and 1. Subtract 0.5 so the center maps to 0, the edges to ±0.5. Write these into two useMotionValue instances.
const mx = useMotionValue(0); const my = useMotionValue(0); 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); }Map cursor offset to rotateX/Y with springs
Pass mx and my through useTransform to produce rotation angles, then wrap each in a useSpring for smooth inertia. Apply rotateX and rotateY on the motion.div together with perspective:'900px' and transformStyle:'preserve-3d' so the 3D effect renders correctly.
const SPRING = { stiffness: 220, damping: 28, mass: 0.6 }; 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); // on enter scale.set(1.025); // on leave mx.set(0); my.set(0); scale.set(1);Add the grid-level hover spotlight
Lift a hoveredId string state to the parent section. Each MagneticCell calls onHover(cell.id) on enter and onHover(null) on leave. Cards where hoveredId is set but does not match their own id animate to opacity 0.6 via Framer Motion's animate prop, creating a focus effect across the whole grid without any CSS class juggling.
When to use it
Reach for this section when you want to present a product's key facts (a metric, a feature, a testimonial, a CTA) together in one visual statement, SaaS landing pages, agency showcases, AI tool launches. The mixed cell types keep scanning interesting. Skip it for content-heavy editorial pages or dashboards where information density matters more than craft. Because the tilt is pointer-driven, plan a clean static fallback for mobile visitors.
Used by
- Vercel, Uses asymmetric bento-style grid layouts to display product capabilities, metrics, and social proof on its homepage.
- Linear, Combines mixed-size feature cards with subtle hover depth effects to surface product capabilities across its marketing pages.
- Stripe, Uses grid-based multi-cell layouts mixing stats, feature highlights, and CTAs within a single section on product and feature pages.
- Loom, Employs bento-style feature grids with varied card sizes and interactive hover states to communicate product value on landing pages.
FAQ
Does the magnetic tilt work on touch screens?
No. The effect relies on mousemove events, which touch devices do not fire. The cards render and look correct on mobile, they just stay flat. Plan a static layout as your mobile experience and gate the motion values behind a pointer media query or a window.matchMedia('(pointer: fine)') check.
How do I change the tilt intensity?
Adjust the output range in the useTransform calls. The default maps ±0.5 offset to ±8 degrees. Passing [-12, 12] for a more dramatic tilt or [-4, 4] for a subtle one is the only change needed. Pair it with a higher damping value (e.g. 35) if the card feels too bouncy at higher angles.
Can I add more cells or change the grid layout?
Yes. The grid is driven entirely by CSS grid-template-areas and the area field on each BentoCell. Redefine the template string and update the area values in your data array to get any layout without touching the component logic. Stick to areas that fill all columns on each row to avoid empty gaps.
Why does each card need its own motion values instead of one shared set?
The tilt is relative to the card's own bounding box, not the page. Each MagneticCell normalizes pointer coordinates against its own ref, so the effect centers correctly regardless of where the card sits in the grid. Sharing a single set of values would make every card react to the same absolute cursor position, breaking the per-card centering.