MynduiMyndui
0

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

A bento grid looks like it needs a layout engine — cards of different sizes, packed with no gaps. It doesn't. BentoGrid is display: grid with three columns; every card just claims more of it.

BentoGrid renders a plain grid — grid-cols-3 (or however many columns you pass), auto-rows-[minmax(11rem,auto)]. There's no measuring pass, 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 — instead, onPointerMove writes straight to the DOM:

--x/--y feed a radial-gradient that's already painted and just waiting for its center to move — 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 just 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 GridCCPaint-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 →