Anatomy of the Bento Grid
How a modular bento layout gets its shape from plain CSS grid spans, its entrance from one shared spring, and its hover glow from a single pointer-tracked custom property.
↓Scroll to step through it
Spans, not a masonry engine
Cards of different sizes packed with no gaps look like they need a layout
engine. They don't. BentoGrid is display: grid with three
columns, and larger cards claim more of it.
BentoGrid renders a plain grid: grid-cols-3 (or however many columns
you pass) and auto-rows-[minmax(11rem,auto)]. There's no measuring pass and no
packing algorithm. Each BentoCard declares how much room it wants with
colSpan/rowSpan, and the browser's normal row-major auto-flow drops it
into the next cell that fits.
Those lookup objects exist because the Tailwind scanner can't see an
interpolated class like `md:col-span-${n}`. Every valid span has to be a
literal string somewhere in the source for the class to ship.
One stagger, driven by scroll-in
The grid and its cards share one Framer variants pair. The grid is
hidden/visible with staggerChildren: 0.08; every card's itemVariants
resolves opacity 0→1 and y 20→0 on a spring. whileInView fires it once,
the moment the grid crosses 15% into the viewport. Nothing plays until then,
and it never replays on its own.
Because staggerChildren lives on the parent's transition, no card needs to
know its own index or delay. Framer walks the children in DOM order and
staggers them for you.
Why the spotlight is a CSS variable, not React state
Hovering a card reveals a soft radial glow that follows the pointer. Tracking
that in React state would mean a re-render on every pointermove, so
onPointerMove writes straight to the DOM instead.
--x/--y feed a radial-gradient that's already painted and only needs
its center moved. The glow itself only animates opacity on
group-hover, so lift and glow both stay off the main thread once they're
running:
const CARD_BASE =
"hover:-translate-y-1 [transition:translate_300ms_cubic-bezier(0.3,0.7,0.4,1),box-shadow_300ms_ease]";
const CARD_GLOW =
"opacity-0 group-hover:opacity-100 [transition:opacity_400ms_ease] " +
"[background:radial-gradient(280px_circle_at_var(--x,50%)_var(--y,50%),color-mix(in_oklch,var(--primary)_18%,transparent),transparent_70%)]";color-mix(in oklch, var(--primary) 18%, transparent) keeps the glow tinted
to whatever primary color the theme is running, without a second custom
property just for opacity.
The result
Three columns, a couple of colSpan/rowSpan numbers, one shared spring for
the entrance, and a CSS variable for the glow. No layout library required.
- colSpan cell
- spans 2 of 3 grid columns
- rowSpan cell
- auto-flow drops it into the next open column, spanning 2 rows
- default cell
- 1×1, fills whatever's left
const GRID_COLS = { 2: "md:grid-cols-2", 3: "md:grid-cols-3", 4: "md:grid-cols-4" };const COL_SPAN = { 1: "md:col-span-1", 2: "md:col-span-2", 3: "md:col-span-3" };const ROW_SPAN = { 1: "md:row-span-1", 2: "md:row-span-2" };<motion.div className={`grid auto-rows-[minmax(11rem,auto)] grid-cols-1 gap-4 ${GRID_COLS[columns]}`}>{/* each BentoCard: `${COL_SPAN[colSpan]} ${ROW_SPAN[rowSpan]}` */}</motion.div>Anatomy of the Bento Grid
How a modular bento layout gets its shape from plain CSS grid spans, its entrance from one shared spring, and its hover glow from a single pointer-tracked custom property.
- colSpan cell
- spans 2 of 3 grid columns
- rowSpan cell
- auto-flow drops it into the next open column, spanning 2 rows
- default cell
- 1×1, fills whatever's left
const GRID_COLS = { 2: "md:grid-cols-2", 3: "md:grid-cols-3", 4: "md:grid-cols-4" };const COL_SPAN = { 1: "md:col-span-1", 2: "md:col-span-2", 3: "md:col-span-3" };const ROW_SPAN = { 1: "md:row-span-1", 2: "md:row-span-2" };<motion.div className={`grid auto-rows-[minmax(11rem,auto)] grid-cols-1 gap-4 ${GRID_COLS[columns]}`}>{/* each BentoCard: `${COL_SPAN[colSpan]} ${ROW_SPAN[rowSpan]}` */}</motion.div>Spans, not a masonry engine
Cards of different sizes packed with no gaps look like they need a layout
engine. They don't. BentoGrid is display: grid with three
columns, and larger cards claim more of it.
BentoGrid renders a plain grid: grid-cols-3 (or however many columns
you pass) and auto-rows-[minmax(11rem,auto)]. There's no measuring pass and no
packing algorithm. Each BentoCard declares how much room it wants with
colSpan/rowSpan, and the browser's normal row-major auto-flow drops it
into the next cell that fits.
Those lookup objects exist because the Tailwind scanner can't see an
interpolated class like `md:col-span-${n}`. Every valid span has to be a
literal string somewhere in the source for the class to ship.
transition: { type: "spring", stiffness: 320, damping: 32, mass: 0.9 }
- staggerChildren
- 0.08s between each card's start
- Item spring
- stiffness 320 · damping 32 · mass 0.9
const containerVariants: Variants = {hidden: {},visible: { transition: { staggerChildren: 0.08 } },};const itemVariants: Variants = {hidden: { opacity: 0, y: 20 },visible: { opacity: 1, y: 0, transition: { type: "spring", damping: 32, stiffness: 320, mass: 0.9 },},};<motion.div variants={containerVariants} initial="hidden" whileInView="visible"viewport={{ once: true, amount: 0.15 }}>{/* each BentoCard is a motion.div with variants={itemVariants} */}</motion.div>One stagger, driven by scroll-in
The grid and its cards share one Framer variants pair. The grid is
hidden/visible with staggerChildren: 0.08; every card's itemVariants
resolves opacity 0→1 and y 20→0 on a spring. whileInView fires it once,
the moment the grid crosses 15% into the viewport. Nothing plays until then,
and it never replays on its own.
Because staggerChildren lives on the parent's transition, no card needs to
know its own index or delay. Framer walks the children in DOM order and
staggers them for you.
- Lift
- translate -4px, 300ms cubic-bezier(0.3,0.7,0.4,1)
- Spotlight
- radial-gradient at --x/--y, opacity 0→1
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {const rect = ref.current?.getBoundingClientRect();if (rect) { ref.current.style.setProperty("--x", `${e.clientX - rect.left}px`); ref.current.style.setProperty("--y", `${e.clientY - rect.top}px`);}};Why the spotlight is a CSS variable, not React state
Hovering a card reveals a soft radial glow that follows the pointer. Tracking
that in React state would mean a re-render on every pointermove, so
onPointerMove writes straight to the DOM instead.
--x/--y feed a radial-gradient that's already painted and only needs
its center moved. The glow itself only animates opacity on
group-hover, so lift and glow both stay off the main thread once they're
running:
const CARD_BASE =
"hover:-translate-y-1 [transition:translate_300ms_cubic-bezier(0.3,0.7,0.4,1),box-shadow_300ms_ease]";
const CARD_GLOW =
"opacity-0 group-hover:opacity-100 [transition:opacity_400ms_ease] " +
"[background:radial-gradient(280px_circle_at_var(--x,50%)_var(--y,50%),color-mix(in_oklch,var(--primary)_18%,transparent),transparent_70%)]";color-mix(in oklch, var(--primary) 18%, transparent) keeps the glow tinted
to whatever primary color the theme is running, without a second custom
property just for opacity.
Interfaces that feel alive
Spring-driven motion and pixel-tuned details, ready to drop in.
Realtime analytics
Every interaction streamed and charted as it happens.
Enterprise-grade
SOC 2 Type II, SSO, and audit logs out of the box.
120ms p95 globally
Served from 35 edge regions, close to every user.
The result
Three columns, a couple of colSpan/rowSpan numbers, one shared spring for
the entrance, and a CSS variable for the glow. No layout library required.
Accessibility
Both the stagger and the hover lift respect motion-reduce: the card's
[transition:none] variant kills the translate/shadow transition, and its
hover:translate-y-0 cancels the lift outright. The glow still shows (it's
an opacity fade), but nothing on the page moves. Every prop lands on a
plain <div>, so anything you'd do to make card content accessible (headings,
focusable CTAs) works exactly as it would outside the grid.
Motion Score
box-shadowhover-lift shadow; card is overflow-hidden so the shadow can't move to a clipped child without a wrapper restructuretranslatePosition / lift via translateopacityFade / cross-fade