Bento grids — those modular, varied-size card layouts popularized by Apple's product pages and quickly adopted by SaaS marketing sites — have become one of the defining UI patterns of the mid-2020s. The name comes from the Japanese bento box: distinct compartments, each the right size for what it holds, arranged into a clean whole. What makes them appealing to build is that CSS Grid was basically designed for this job, and Tailwind wraps that power in classes you can type without ever touching a stylesheet.
This tutorial builds a complete, responsive bento grid from scratch using Tailwind CSS v4. You'll understand why each class is there, not just which classes to copy. By the end you'll have a grid you can drop into any Astro, Next.js, or plain HTML project.
How bento grids differ from masonry
It's worth naming the distinction before writing any code. A masonry layout packs variable-height items into columns so nothing aligns horizontally — items cascade like stones in a wall. A bento grid is built on a fixed grid where every item snaps to the same row and column tracks. Items span multiple tracks to get their larger size, but all cells share the same underlying rhythm. That shared rhythm is what makes bento feel designed rather than packed.
CSS Grid's two-dimensional control — rows and columns simultaneously — makes it the right tool here. Flexbox handles one axis at a time, so it can approximate a bento but requires extra wrappers and math to keep rows aligned. Grid just does it.
The base grid container
Start with a 4-column grid that collapses to 1 column on small screens. Fixed row height using an arbitrary value keeps the vertical rhythm predictable:
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 auto-rows-[200px] gap-4">
<!-- cards go here -->
</div>
Let's unpack each class:
grid— setsdisplay: gridgrid-cols-1/sm:grid-cols-2/lg:grid-cols-4— responsive column counts; one column on mobile, two on small screens, four on largeauto-rows-[200px]— an arbitrary value that sets every implicitly-created row to exactly 200px; this is the baseline unit your spans will multiplygap-4— 1rem gap between all cells in both dimensions
The auto-rows-[200px] syntax is Tailwind v4's arbitrary value form. You can swap any valid CSS value inside the brackets: auto-rows-[minmax(160px,1fr)] gives rows a minimum of 160px but lets them grow to fill available space, which is useful when content heights vary.
Spanning columns and rows
A card that only occupies one cell is just a card. What makes bento interesting is intentional spanning. Tailwind ships col-span-<n> and row-span-<n> utilities that map directly to grid-column: span <n> and grid-row: span <n>.
<!-- Hero card: spans 2 columns and 2 rows on large screens -->
<div class="col-span-1 row-span-1 lg:col-span-2 lg:row-span-2 rounded-2xl bg-neutral-900 p-6">
<h2>The big feature</h2>
<p>This card gets the most visual weight.</p>
</div>
<!-- Wide card: full width on mobile, 2 columns on large -->
<div class="col-span-1 lg:col-span-2 rounded-2xl bg-indigo-600 p-6">
<h3>Secondary feature</h3>
</div>
<!-- Tall card: spans 2 rows -->
<div class="row-span-1 lg:row-span-2 rounded-2xl bg-emerald-700 p-6">
<h3>Tall card</h3>
</div>
<!-- Standard 1×1 cards -->
<div class="rounded-2xl bg-neutral-800 p-6"><h3>Feature A</h3></div>
<div class="rounded-2xl bg-neutral-800 p-6"><h3>Feature B</h3></div>
The responsive prefix pattern is the key: on mobile, every card gets col-span-1 (no spanning — the grid is already single-column so spanning would break the flow). At lg, the hero expands to lg:col-span-2 lg:row-span-2. This means you explicitly design both the mobile and desktop layout rather than hoping auto-placement figures it out.
A complete, copy-pasteable example
Here is a full 8-card bento grid with a deliberate visual hierarchy — a large hero, a full-width band, two medium cards, and several small ones:
<section aria-label="Product features">
<ul class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 auto-rows-[200px] gap-4 list-none p-0 m-0">
<!-- Hero: 2 cols × 2 rows -->
<li class="sm:col-span-2 sm:row-span-2 rounded-2xl bg-neutral-900 text-white p-8 flex flex-col justify-end">
<p class="text-sm font-medium text-indigo-400 mb-2">Core feature</p>
<h2 class="text-2xl font-bold leading-tight">Ship in hours, not days</h2>
<p class="mt-2 text-neutral-400 text-sm">Every template is production-ready out of the box.</p>
</li>
<!-- Wide: 2 cols × 1 row -->
<li class="sm:col-span-2 rounded-2xl bg-indigo-600 text-white p-6 flex items-center gap-4">
<span aria-hidden="true" class="text-4xl">⚡</span>
<div>
<h3 class="font-semibold">Lighthouse 100 performance</h3>
<p class="text-sm text-indigo-200">Static-first Astro output; no runtime JS overhead.</p>
</div>
</li>
<!-- Tall: 1 col × 2 rows -->
<li class="sm:row-span-2 rounded-2xl bg-emerald-800 text-white p-6 flex flex-col justify-between">
<h3 class="font-semibold">Tailwind v4</h3>
<p class="text-sm text-emerald-200 mt-auto">Utility-first styling, no config overhead.</p>
</li>
<!-- Standard 1×1 cards -->
<li class="rounded-2xl bg-neutral-800 text-white p-6">
<h3 class="font-semibold">SEO ready</h3>
<p class="text-sm text-neutral-400 mt-1">Schema markup and meta tags wired up.</p>
</li>
<li class="rounded-2xl bg-neutral-800 text-white p-6">
<h3 class="font-semibold">Dark mode</h3>
<p class="text-sm text-neutral-400 mt-1">System preference detected automatically.</p>
</li>
<li class="rounded-2xl bg-neutral-800 text-white p-6">
<h3 class="font-semibold">One-command deploy</h3>
<p class="text-sm text-neutral-400 mt-1">Push to main → live on Cloudflare Pages.</p>
</li>
<li class="rounded-2xl bg-neutral-800 text-white p-6">
<h3 class="font-semibold">llms.txt included</h3>
<p class="text-sm text-neutral-400 mt-1">AI-ready context file ships in every zip.</p>
</li>
</ul>
</section>
Notice the breakpoint prefix pattern: sm:col-span-2 not lg:col-span-2. For a 4-column grid, some spans activate at sm (2 columns) and others at lg (the full 4). You pick the breakpoint based on when the grid actually has enough columns for the span to make visual sense.
Using grid-flow-dense to fill gaps
When items span different numbers of rows and columns, the browser's auto-placement algorithm sometimes leaves empty cells — a wide card at the end of a row creates a gap if the next item doesn't fit beside it. Adding grid-flow-dense to the container tells the browser to backfill those gaps with later items:
<ul class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 auto-rows-[200px] gap-4 grid-flow-dense list-none p-0 m-0">
<!-- items -->
</ul>
One important caveat: grid-flow-dense can reorder the visual presentation of items relative to their DOM order, because the algorithm moves later items earlier to fill holes. This breaks keyboard tab order for anyone navigating without a mouse. Reserve it for grids where every item is visually equivalent in importance (like an image gallery), and avoid it when the cards have a meaningful sequence — hero → secondary → tertiary. For most bento feature grids, you'll get a cleaner result by manually sizing your grid and card spans so no gaps form in the first place.
Controlling row height with minmax
Fixed row heights (auto-rows-[200px]) work well when your content is short and predictable. If cards may hold varying amounts of text, rigid heights lead to overflow. The minmax() function is the escape hatch:
<!-- Rows are at least 180px tall, grow to fit content -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 auto-rows-[minmax(180px,auto)] gap-4">
This trades pixel-perfect alignment for content safety. In practice, most bento grids on marketing pages use fixed heights because the copy is controlled — it's a designed artifact, not user-generated content. If you're building a bento layout for a CMS-powered feature grid where editors write the card copy, minmax is the safer choice.
Responsive strategy in practice
Tailwind's mobile-first breakpoints — sm (640px), md (768px), lg (1024px), xl (1280px) — apply upward: a class with no prefix applies at all widths, and a prefixed class overrides it at that breakpoint and above.
For a bento grid, a practical three-tier strategy works well:
- Mobile (no prefix): single column, all cards
col-span-1 row-span-1. Every card gets equal real estate. This is the baseline. - Tablet (
sm:): switch to 2 columns. Let the hero span both columns (sm:col-span-2). Keep most cards 1×1. - Desktop (
lg:): switch to 4 columns. Hero goes tolg:col-span-2 lg:row-span-2. Wide cards becomelg:col-span-2. Tall cards becomelg:row-span-2.
This keeps the mobile layout simple (no visual hierarchy needed when space is tight) and gradually introduces the bento composition as the viewport grows.
Accessibility notes
Visual layout and DOM order are two different things, and bento grids expose that tension more than most components. Here are the specific things to check:
- Use semantic markup. A feature grid is a list of features — reach for
<ul>/<li>withlist-noneto remove the bullet styling. This gives screen readers a count ("5 items") and allows list navigation shortcuts. - Add a section label. Wrap the grid in a
<section>witharia-label="Product features"(or similar). This creates a landmark that screen-reader users can jump to directly. - Keep DOM order meaningful. Tab order follows DOM order, not visual order. Put the most important card first in the markup, even if
grid-flow-densewould visually move it. If you need a card to appear second visually but third in the DOM, use explicitcol-start-androw-start-positioning rather than relying on the auto-placement algorithm. - Don't rely on position alone to convey importance. The hero card is visually dominant, but a screen reader user doesn't see its size. Make sure the heading hierarchy (
<h2>for the hero,<h3>for sub-cards) communicates the same priority that visual size communicates sighted users. - Decorative icons get
aria-hidden="true". Any emoji or icon used purely as visual decoration should be hidden from the accessibility tree so it isn't read aloud as "lightning bolt emoji".
When to use arbitrary grid values
Tailwind's predefined grid-cols-1 through grid-cols-12 cover most cases. But a true bento sometimes wants a 10-column base or unequal column widths. Arbitrary value syntax handles both:
<!-- 10 equal columns -->
<div class="grid grid-cols-[repeat(10,minmax(0,1fr))] gap-4">
<!-- Asymmetric: sidebar + main -->
<div class="grid grid-cols-[280px_1fr] gap-6">
In Tailwind v4, you can also define these as CSS custom properties in a @theme block and reference them with the variable shorthand:
/* In your CSS */
@theme {
--grid-bento: repeat(12, minmax(0, 1fr));
}
/* In your HTML */
<div class="grid grid-cols-(--grid-bento) gap-4">
The (--variable) syntax (parentheses, not brackets) is Tailwind v4's shorthand for [var(--variable)]. It's useful when the same grid definition appears in many places and you want a single source of truth.
Putting it together
A bento grid isn't a component you install — it's a layout pattern built from a small set of CSS Grid primitives. The Tailwind classes involved are: grid, grid-cols-<n>, auto-rows-[<value>], gap-<n>, col-span-<n>, row-span-<n>, and their responsive variants. Combine those with semantic HTML and a deliberate heading hierarchy and you have both the visual design and the accessible structure. The power of the pattern comes from the underlying grid math — once you internalize that every card exists on a shared track system, sizing and spanning become intuitive rather than trial-and-error.