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.
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.
Spans, not a masonry engine
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:
- 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>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:
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>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 — instead,
onPointerMove writes straight to the DOM:
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`);
}
};--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:
- Lift
- translate -4px, 300ms cubic-bezier(0.3,0.7,0.4,1)
- Spotlight
- radial-gradient at --x/--y, opacity 0→1
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.
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.
The result
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.
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.