GodUIGodUI
129Follow on X

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.

01/04Spans, not a masonry engine
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
tsx
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>
01

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.

02/04One stagger, driven by scroll-in

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
tsx
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>
02

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.

03/04Why the spotlight is a CSS variable, not React state
Lift
translate -4px, 300ms cubic-bezier(0.3,0.7,0.4,1)
Spotlight
radial-gradient at --x/--y, opacity 0→1
tsx
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`);}};
03

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:

tsx
 
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.

04/04Result

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.

04

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

Bento GridCC: Paint-triggering
Cbox-shadowhover-lift shadow; card is overflow-hidden so the shadow can't move to a clipped child without a wrapper restructure
StranslatePosition / lift via translate
SopacityFade / cross-fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →