A bento grid in CSS is a grid container with a fixed number of columns, where each tile spans one or more cells. Building the layout takes about ten lines. The harder part, and the one that is easy to skip, is keeping the order people read and Tab through in step with the order they see.

Below, the grid is built in plain CSS and then in Tailwind CSS v4, followed by reading order, the inside of each tile, and motion. If you would rather start from a working layout, the bento grid generator produces all of the code below and checks the reading order as you go. For the design side (which tile should be big, and why), see the bento grid design guide.

The base grid in plain CSS

Here is a four-column bento grid with a lead tile, a wide tile and a tall tile:

bento.css
.bento {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  grid-auto-rows: minmax(10rem, auto);
  gap: 1rem;
}

.tile {
  min-width: 0;
}

.tile--lead { grid-column: span 2; grid-row: span 2; }
.tile--wide { grid-column: span 2; }
.tile--tall { grid-row: span 2; }

A plain 1fr track means minmax(auto, 1fr): it won't shrink below the minimum content size of what it holds, so one long URL or line of code in a tile is enough to widen its column and push the whole grid out of shape. minmax(0, 1fr) sets that minimum to zero and keeps the columns equal.

Grid items default to min-width: auto, which causes the same problem from the inside. min-width: 0 on the tiles removes it. With both in place, long content wraps or scrolls inside its tile instead of resizing the grid.

grid-auto-rows: minmax(10rem, auto) gives every row a minimum height and lets a row grow when a tile's content needs more room, so text never spills out of a tile at large font sizes.

Making it responsive, mobile first

Start with one column and add columns as the viewport grows. The trap is the spans: on a one-column grid, grid-column: span 2 does not shrink to fit. The browser creates an extra implicit column for it, and the tile hangs off the side of the screen. Spans belong inside the breakpoint where there are enough columns to hold them.

bento.css
.bento {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  grid-auto-rows: minmax(10rem, auto);
  gap: 1rem;
}

@media (min-width: 40rem) {
  .bento { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .tile--lead { grid-column: span 2; grid-row: span 2; }
  .tile--wide { grid-column: span 2; }
}

@media (min-width: 64rem) {
  .bento { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  .tile--tall { grid-row: span 2; }
}

The two breakpoints, 40rem and 64rem, match Tailwind's sm and lg, which makes it easy to move between the CSS and the utility version later.

Naming the layout with grid-template-areas

When the arrangement changes between breakpoints, spans get hard to follow. Template areas let you draw each layout as a small map instead. Every tile gets one area name, and each breakpoint redraws the map:

bento.css
.bento {
  display: grid;
  gap: 1rem;
  grid-auto-rows: minmax(10rem, auto);
  grid-template-columns: minmax(0, 1fr);
  grid-template-areas:
    "lead"
    "speed"
    "integrations"
    "security"
    "analytics";
}

.tile-lead { grid-area: lead; }
.tile-speed { grid-area: speed; }
.tile-integrations { grid-area: integrations; }
.tile-security { grid-area: security; }
.tile-analytics { grid-area: analytics; }

@media (min-width: 64rem) {
  .bento {
    grid-template-columns: repeat(4, minmax(0, 1fr));
    grid-template-areas:
      "lead      lead      speed    integrations"
      "lead      lead      security integrations"
      "analytics analytics .        .";
  }
}

Each area has to be a rectangle, and a dot marks an empty cell. The map makes holes obvious, like the two empty cells in the last row above, which is a good reason to prefer areas whenever a layout needs a review.

The same grid in Tailwind CSS v4

In Tailwind the grid is a handful of utilities. The same rule about spans applies, so the spans carry a breakpoint prefix:

bento.html
<section class="grid grid-cols-1 gap-4 auto-rows-[minmax(10rem,auto)] sm:grid-cols-2 lg:grid-cols-4">
  <article class="min-w-0 rounded-2xl border p-6 sm:col-span-2 sm:row-span-2">Lead</article>
  <article class="min-w-0 rounded-2xl border p-6">Speed</article>
  <article class="min-w-0 rounded-2xl border p-6 lg:row-span-2">Integrations</article>
  <article class="min-w-0 rounded-2xl border p-6">Security</article>
  <article class="min-w-0 rounded-2xl border p-6 sm:col-span-2">Analytics</article>
</section>

Tailwind's grid-cols-4 already compiles to repeat(4, minmax(0, 1fr)), so the column fix comes for free; min-w-0 on the tiles still matters.

When a layout can't come out of auto-placement, place tiles explicitly with start lines, such as lg:col-start-3 lg:row-start-1. Explicit starts carry over to larger breakpoints, so if a wider layout should go back to automatic placement, reset them with xl:col-start-auto xl:row-start-auto. Forgetting that reset is a common source of layouts that look right at one width and wrong at the next.

Where bento grids break reading order

CSS Grid can put any tile anywhere. The HTML keeps its own order, and that is the order a screen reader reads and the Tab key follows. The CSS Grid specification is direct about this: authors must use order and the grid-placement properties only for visual, not logical, reordering of content, and style sheets that do otherwise are non-conforming.

Try it below. In the dense version, a later tile moves into an earlier hole. Click a tile and press Tab to see where focus actually goes.

Source position 1: Overview
Source position 2: Stats
Source position 3: Chart
Source position 4: Team
Source position 5: Inbox
Source position 6: Tasks
Source position 7: Notes

Dense placement pulls Team (fourth in the HTML) into the hole beside Stats. It now appears before Chart, but Tab still visits Chart first. Click a tile and press Tab to follow the focus yourself. The focus order matches the visual order.

A real CSS grid, laid out by your browser. Numbers show the Tab order; a red number means that tile is out of step with what you see.

The mismatch has three usual causes:

  1. grid-auto-flow: dense. MDN puts it plainly: dense packing fills holes earlier in the grid with smaller items that come later, which may cause items to appear out of order.
  2. Explicit placement that moves a tile up. A tile that is last in the HTML but placed at grid-row: 1 on desktop is read last and seen first.
  3. The order property, which changes visual order and nothing else.

For a sighted keyboard user, focus jumps around the screen. For a screen reader user, the lead tile you designed as the opening line arrives at the end. When the order carries meaning, WCAG 2.2 counts this as a failure of success criterion 1.3.2 (Meaningful Sequence), and when the tiles are links or buttons, of 2.4.3 (Focus Order).

Fixing the order: source first, reading-flow on top

The dependable fix is to write the HTML in the order the layout should be read, then let CSS follow it. In practice:

  • Write tiles in the reading order of your widest layout.
  • Keep the narrower layouts as auto-placed flows of that same order. Sparse auto-placement never moves backwards, so it can't scramble the order.
  • Use dense only for items whose order doesn't matter, like a wall of photos.
  • After any explicit placement, check the result at every breakpoint. One HTML order has to serve all of them.

There is now a CSS property aimed at this problem. reading-flow, from CSS Display Level 4, tells the browser to follow the visual layout of a grid or flex container instead of the source order:

bento.css
.bento {
  /* Focus follows the grid row by row. Chromium 137+; ignored elsewhere. */
  reading-flow: grid-rows;
}

Chrome shipped reading-flow in version 137, and its announcement describes it as controlling both keyboard focus order and the order in which elements are exposed to assistive technology. Positive tabindex values on the container's direct children are ignored for ordering. As of October 2026, Can I use lists no support in Safari or Firefox, so treat the property as an enhancement for Chromium browsers on top of a correct source order. Browsers that don't recognize it ignore the declaration, which makes it safe to add today.

Tile internals with container queries

A bento tile changes size more than most components: the same card can sit in a 1×1 cell on one breakpoint and a 2×2 cell on the next. Media queries only know the viewport. Container queries let a tile respond to its own width.

Weekly active

12,480

+8.2% vs last week

The tile's layout changes with the tile's own width, not the window's. The same component can sit in a 1x1 cell and a 2x2 cell of one grid and look right in both.

Drag the slider to resize the tile. Its layout switches at the tile's own breakpoints, whatever the window size.

In plain CSS, make the tile a size container and query it:

tile.css
.tile {
  container-type: inline-size;
}

.tile__body {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

@container (min-width: 24rem) {
  .tile__body {
    flex-direction: row;
    align-items: center;
  }
}

Container queries are part of Tailwind CSS v4's core, so no plugin is needed. Mark the tile with @container, then use container variants on its children. @sm applies from a container width of 24rem:

tile.html
<article class="@container min-w-0 rounded-2xl border p-6">
  <div class="flex flex-col gap-4 @sm:flex-row @sm:items-center">
    <p class="text-3xl font-semibold">12,480</p>
    <svg class="h-14 w-full @sm:ml-auto @sm:w-40" aria-hidden="true"><!-- chart --></svg>
  </div>
  <p class="mt-3 hidden @lg:block">Peak on Thursday.</p>
</article>

Animating tiles in reading order

An entrance animation should follow the same order as everything else. Give each tile its position in the reading order as a custom property and use it for the delay. Put the whole animation inside a reduced-motion query, so it never runs for people who have asked for less motion:

bento.css
@media (prefers-reduced-motion: no-preference) {
  .tile {
    animation: tile-in 0.5s cubic-bezier(0.25, 1, 0.5, 1) both;
    animation-delay: calc(var(--i, 0) * 60ms);
  }
}

@keyframes tile-in {
  from {
    opacity: 0;
    translate: 0 12px;
  }
}
bento.html
<article class="tile tile--lead" style="--i: 0">…</article>
<article class="tile" style="--i: 1">…</article>

The animation only touches opacity and translate, which the browser can run on the compositor without recalculating layout.

Generate it instead

All of this is what the bento grid generator writes for you. Lay out the tiles for phone, tablet and desktop, watch the Tab order drawn over the grid, and copy the CSS with template areas, the Tailwind v4 classes or a React component. It only adds explicit start lines where auto-placement can't produce the layout, and resets them where a wider breakpoint goes back to automatic placement.