How to build an asymmetric bento grid in React
A bento grid in React renders feature cards inside a CSS Grid with mixed column spans. Framer Motion staggered scroll reveals animate each card as it enters the viewport, while a dual card type (text vs visual) controls whether a Lucide icon fills the card background.
- Stack: React 18 + Framer Motion 11 + Lucide React + Tailwind v4, ~90 lines, zero extra dependencies.
- Grid: 3 columns on md+, auto rows of 180px, arbitrary col-span via a span string prop.
- Accessible: cards use semantic h3 headings and the icon placeholder is decorative (opacity-20, no aria label).
- Theming: all colors are CSS custom properties (--color-background, --color-accent, --color-border), no hardcoded values.
- Mobile: collapses to a single column below md, fixed row heights may need adjustment for long text on small screens.
Bento Text Image is an editorial feature grid that alternates large text cards with icon-backed visual cards across an asymmetric 3-column layout. Each card animates into view via a Framer Motion staggered reveal, giving the section rhythm without requiring any custom scroll logic. The result is a premium, magazine-like presentation that works for SaaS features, agency capabilities, or portfolio highlights.
Anatomy
The section wraps a centred header (optional badge, h2 title, subtitle) and a CSS Grid container. Each BentoItem has an id, title, description, a type flag ('text' or 'visual'), an optional Tailwind span class (e.g. 'col-span-2'), and an optional Lucide icon name. Visual-type cards render the icon at 64px with 20% opacity as a decorative background before the text block. Text-type cards render just the title and description aligned to the bottom of the card.
How it works
Every card is a motion.div with initial={{ opacity: 0, y: 24 }} and whileInView={{ opacity: 1, y: 0 }}. The viewport option { once: true, margin: '-40px' } triggers the animation 40px before the card fully enters the screen, preventing a jarring pop-in. A delay of i * 0.1 seconds staggers the cards sequentially without any orchestration overhead. The icon lookup uses a dynamic key into the Lucide namespace (LucideIcons[name]) so any of the 1,400+ icons can be referenced by string from the data layer.
How to build it in React
Define the BentoItem interface and grid container
Create a BentoItem type with id, title, description, type ('text' | 'visual'), an optional span string, and an optional icon name. Render a CSS Grid with 3 columns on medium screens and auto rows of 180px. The span prop maps directly to a Tailwind class on each card.
// types interface BentoItem { id: string; title: string; description: string; type: "text" | "visual"; span?: string; // e.g. "col-span-2 row-span-2" icon?: string; // Lucide icon name } // grid container <div className="grid grid-cols-1 md:grid-cols-3 auto-rows-[180px] gap-5"> {items.map((item, i) => ( <BentoCard key={item.id} item={item} index={i} /> ))} </div>Resolve Lucide icons by string key
Import the entire Lucide namespace and look up the icon component by name at render time. This keeps the data layer free of React imports and allows icons to be driven from a CMS or JSON file.
import * as LucideIcons from "lucide-react"; function getIcon(name?: string) { if (!name) return null; return (LucideIcons as unknown as Record<string, React.ElementType>)[name] || null; } // inside the card const Icon = getIcon(item.icon); {Icon && <Icon className="h-16 w-16" style={{ color: "var(--color-foreground)" }} />}Add staggered scroll reveals with Framer Motion
Wrap each card in a motion.div. Set the delay to i * 0.1 so cards cascade left to right. The margin: '-40px' on viewport ensures the animation starts slightly before the card is fully on screen, giving a flowing feel rather than an abrupt pop.
<motion.div initial={{ opacity: 0, y: 24 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: "-40px" }} transition={{ delay: i * 0.1, duration: 0.5 }} className={`rounded-2xl border p-6 flex flex-col justify-end ${item.span || "col-span-1"}`} >Use CSS tokens for theming
Apply colors exclusively through CSS custom properties so the grid respects any theme preset without code changes. Visual cards use --color-background-alt for a subtle tint; text cards use --color-background-card. The badge and borders follow --color-accent and --color-border.
// visual card style={{ backgroundColor: "var(--color-background-alt, var(--color-background-card))", borderColor: "var(--color-border)", }} // text card style={{ backgroundColor: "var(--color-background-card)", borderColor: "var(--color-border)", }}
When to use it
Reach for this grid when you need to present 3-6 features with varying visual weight, some need a long description, others just a title and a graphic hint. It works well as a 'What we do' block for agencies, a 'Key capabilities' section for SaaS, or a highlights reel in a portfolio. Avoid it when all items are equal in importance (use a uniform card grid instead) and when cards carry interactive controls that need consistent sizing.
Used by
- Linear, Uses asymmetric feature grids with mixed card sizes to showcase product capabilities across its marketing pages.
- Vercel, Deploys bento-style editorial blocks to surface infrastructure features with varied text and visual card densities.
- Stripe, Combines icon-accented visual cards with full-text cards in product feature sections to balance scan-ability with depth.
- Loom, Uses mixed-span feature grids on its homepage to give key differentiators more visual real estate than supporting points.
FAQ
How do I make a card span 2 columns?
Pass a Tailwind span class in the item's span field, for example span: 'col-span-2'. The component applies it directly to the motion.div classname, so any valid Tailwind grid span class works including row-span variants.
Can I replace the Lucide icon with a custom image?
Yes. Swap the getIcon lookup for a map of image URLs and render an img or Next.js Image inside the flex-1 container. Keep the opacity-20 class or remove it depending on whether you want the image to read as decorative or primary.
Do the fixed row heights cause overflow issues with long text?
They can on mobile, where a single column means the full text width is available but the 180px row height may clip descriptions. Either increase the base row height, switch to auto-rows-auto on small screens, or cap descriptions at 120 characters in your data.
How do I disable the scroll animation for users who prefer reduced motion?
Wrap the transition and initial props in a check for the prefers-reduced-motion media query. Read it with a React hook (useReducedMotion from Framer Motion works directly) and pass static values when the preference is active.