GodUIGodUI
81

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:

Anatomyone grid, spans do the rest
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>

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:

The motionstaggerChildren 0.08s · spring 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>

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:

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`);
  }
};

--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:

The glowpointer position → --x/--y
Lift
translate -4px, 300ms cubic-bezier(0.3,0.7,0.4,1)
Spotlight
radial-gradient at --x/--y, opacity 0→1
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.

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

Resultthe real component — hover a card

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.

On this page

Built with GodUI

Beautifully crafted motion components for modern interfaces.

Star on GitHub