Skip to main content
ASoc
Tutorial

Tailwind Grid: 26 Files, and Not One col-span

A 538-page site's whole grid vocabulary is three utilities — plus the display:contents trick that reorders a product page on mobile without duplicating state.

The ASoc Team9 min read

Across src/components and src/app, 19 files reach for CSS Grid and 59 reach for flexbox. Of the grid files, every single one uses the same three-utility vocabulary — grid, gap-*, and grid-cols-N, usually behind a breakpoint prefix. There is not one col-span-*, row-span-*, col-start-*, subgrid, grid-flow-* or auto-cols-* anywhere in those two directories.

That is the practical finding from a 546-page production site: the grid API you actually need is about four utilities wide, and the interesting decisions happen somewhere else entirely.

The three utilities that do 90% of the work

grid-cols-<n> is the core. It compiles to grid-template-columns: repeat(n, minmax(0, 1fr)) — equal columns that are allowed to shrink below their content's intrinsic width, which is the minmax(0, 1fr) part and the reason Tailwind's version doesn't blow out horizontally the way a naive repeat(n, 1fr) does.

Because Tailwind is mobile-first, an unprefixed utility applies at every width and a prefixed one applies from that breakpoint up. So a responsive grid is one class list read left to right as "narrow, then wider":

// src/components/organisms/TemplatesExplorer.tsx
<div className="grid gap-5 sm:grid-cols-2 sm:gap-7.5 lg:grid-cols-3">
  {results.map((p, i) => (
    <TemplateCard
      price={singleTierPrice}
      key={p.slug}
      product={p}
      priority={i === 0}
      frameworksLabel={/* … */}
    />
  ))}
</div>

One column on a phone (no grid-cols-* at all — a grid defaults to a single column), two from sm, three from lg. The gap widens at the same breakpoint the second column appears.

Counting every grid-cols-* occurrence in the codebase shows how narrow the real distribution is:

UtilityOccurrences
lg:grid-cols-313
sm:grid-cols-212
grid-cols-17
md:grid-cols-24
xl:grid-cols-41
sm:grid-cols-31
md:grid-cols-31
lg:grid-cols-41

Forty occurrences, and two patterns account for 25 of them. sm:grid-cols-2 lg:grid-cols-3 is the card grid — the templates listing above, and the sibling rail that closes every product page:

// src/components/organisms/RelatedProducts.tsx
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
  {related.map((sibling) => (
    <RelatedTemplateCard key={sibling.slug} product={sibling} /* … */ />
  ))}
</div>

The md:grid-cols-2 xl:grid-cols-4 variant is the pricing page, where four tier columns have to survive being halved rather than thirded:

// src/components/organisms/PricingTiers.tsx
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
  {pricingTiers.map((t) => (
    <PricingTierCard key={t.id} tier={t} />
  ))}
</div>

Where uniform columns stop working

Exactly one place in the codebase declares an explicit track list instead of N equal columns, using Tailwind's arbitrary-value syntax: md:grid-cols-[1.4fr_1fr], the product page's prose column against its buy column.

The underscore is Tailwind's space. CSS class names can't contain literal spaces, so a track list like 1fr 8rem 8rem is written grid-cols-[1fr_8rem_8rem] and Tailwind converts it back. That's the whole trick, and it's what most "Tailwind grid generator" tools are generating for you — a track list you could have typed. Worth knowing so you can read it; not worth a dependency.

Note what an arbitrary track list expresses that no grid-cols-N can: columns of different sizes. That's the actual dividing line for reaching past the numbered utilities — not complexity, just non-uniformity. One ratio, in 19 grid files, is how often this codebase has needed it.

The one genuinely interesting grid in the codebase

Grid's real power in this project isn't sizing, it's ordering — and it solved an accessibility problem, not a layout one.

The product detail page is two columns from md up: prose on the left, the edition picker and buy buttons on the right. On a phone, that stacks — and stacking put the prose between the screenshot carousel and the "Live preview" button, which is the one control a visitor on a phone most needs. A hover overlay can't help here: a touch screen has no hover, so the blurred group-hover/media overlay the cards use is aria-hidden and out of the tab order by design.

The obvious fix — a second preview button rendered only below md — is the wrong one. Two instances of a picker means two sources of truth for "which edition is selected", and the mobile button can preview the wrong thing.

The fix that shipped uses display: contents:

// src/components/organisms/TemplateDetail.tsx
<div className="mx-auto mt-12 grid max-w-[1060px] gap-10 md:grid-cols-[1.4fr_1fr]">
  <div className="order-2 md:order-none">
    {/* prose + feature list */}
  </div>
  <div className="contents md:block md:space-y-8">
    <div className="order-1 md:order-none">
      <EditionPicker product={product} /* … */ />
    </div>
    <div className="order-3 space-y-3 md:order-none">
      {/* buy / download buttons */}
    </div>
  </div>
</div>

contents makes an element generate no box of its own — its children are promoted to be direct children of the grid. Below md, the buy column's wrapper disappears from the layout and its three blocks become grid items this grid can order individually, so order-1 / order-2 / order-3 interleave them with the prose. At md, md:block restores the wrapper and md:order-none cancels the ordering, and it's a plain stacked column again.

One picker instance, two layouts, no duplicated state.

The caveat that makes this safe here: order-* changes visual order, not DOM order, so a keyboard user still tabs through in source order. That's a genuine WCAG 1.3.2 (Meaningful Sequence) and 2.4.3 (Focus Order) hazard whenever the reordered content is focusable. It's safe in this instance for one specific reason — the block that moves past the buttons is prose with no focusable content in it, so the visual order and the tab order can't disagree. Reorder two blocks of links this way and you have shipped a bug that no visual QA will catch.

Grid or flex?

59 files to 19 is not an accident. The useful rule from this codebase:

SituationReach forWhy
A collection of like items in rows and columnsgridItems align across rows; the row height is shared
A row of unlike items (icon + label, nav bar, button group)flexContent-sized, one axis, gap does the spacing
Sidebar + content at a fixed ratiogrid-cols-[…]The ratio is the layout; write it once
Anything that needs reordering at a breakpointgrid + order-*Plus contents if the source nesting is in the way
Centering one thingflexFewer moving parts

The card grids are grid because a three-across row of product cards should have a shared row height. The 59 flex files are overwhelmingly small one-axis clusters — the and its label in a feature list, the footer's social icons, a badge next to a heading.

The grid utility that costs you an LCP

One line in that TemplatesExplorer snippet has nothing to do with layout and everything to do with whether the grid is fast:

priority={i === 0}

The first card in a page-opening grid holds the page's LCP element — its cover image is the largest thing above the fold. Leaving it loading="lazy" along with every other card cost roughly 1.7 seconds of load delay on this site, because the browser won't even start fetching a lazy image until layout tells it the image is near the viewport. The first card gets priority; every other card stays lazy. Both the templates listing and the framework hub pages follow the rule.

That's the grid-adjacent performance trap, and it's why the images inside these grids are pre-built WebP derivatives rather than request-time optimized — the sizing happens at build time, covered in image optimization without next/image.

Mistakes and troubleshooting

SymptomCauseFix
A grid item stretches the whole grid wider than the screenA long unbreakable string in a 1fr track; the default min-width: auto refuses to shrinkTailwind's grid-cols-N already ships minmax(0,1fr); on a custom track list write minmax(0,1fr) yourself
grid-cols-3 does nothingThe element has no grid class — grid-cols-* only sets template columnsAdd grid alongside it
Columns appear on mobile tooUnprefixed grid-cols-2 applies at every width; Tailwind is mobile-firstPrefix it: sm:grid-cols-2
gap seems to apply on one axis onlygap-* sets both; gap-x-* / gap-y-* set oneCheck for a more specific utility later in the class list
Reordering with order-* breaks keyboard navigationorder moves the box, not the DOM nodeOnly reorder blocks with no focusable content, or change the source order instead
A wrapper div blocks you from ordering its childrenThe wrapper is the grid item, not its childrencontents on the wrapper, as in TemplateDetail above
Arbitrary track list won't compileSpaces aren't legal in class namesUse underscores: grid-cols-[1fr_8rem_8rem]

Frequently asked questions

Do I need a Tailwind grid generator? For grid-cols-2 md:grid-cols-4 and friends, no — the utility is shorter than the trip to the tool. A generator earns its place only for a complex named-area track list, and even then what it hands back is a single arbitrary value you paste once. This codebase has exactly one such value across 92 components.

How do I make a grid with different-sized columns in Tailwind? Use an arbitrary track list: grid-cols-[1.4fr_1fr] for a ratio, grid-cols-[1fr_8rem_8rem] to mix fluid and fixed. Underscores stand in for the spaces CSS would use.

Does Tailwind support subgrid? Yes — grid-cols-subgrid and grid-rows-subgrid let a nested grid adopt its parent's tracks. This site uses neither, which is the honest data point: a card grid where each card is self-contained never needs its inner elements aligned to the outer tracks.

Why is my three-column grid one column on mobile? That's the intended default. A grid with no grid-cols-* active at that width is a single column, and lg:grid-cols-3 doesn't apply until 1024px. If you want two columns earlier, add sm:grid-cols-2.

Is display: contents safe to use? For layout, yes, in current browsers. Historically some engines removed the element's semantics along with its box, which mattered for <ul> and <table>; on a plain <div> wrapper like the one above there are no semantics to lose. Pair it with the focus-order caveat and it's a sound tool.

Templates in this post

ASoc Mode is a fashion storefront whose new-arrivals and top-selling product grids — with star ratings and sale pricing — are exactly the card-grid pattern above, alongside a product detail page with size and colour selection and a working cart. ASoc Oak is a furniture store spanning seven categories with trending tabs, bestseller rows and a furnish-a-room bundle, so it exercises several grid densities on one page. ASoc Prism is a seasonal apparel store with Women's, Men's and Kids' collections plus category edits for bags, shoes and accessories.

Browse the full sets: Next.js shop templates, Tailwind shop templates.

Keep reading