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.
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:
| Utility | Occurrences |
|---|---|
lg:grid-cols-3 | 13 |
sm:grid-cols-2 | 12 |
grid-cols-1 | 7 |
md:grid-cols-2 | 4 |
xl:grid-cols-4 | 1 |
sm:grid-cols-3 | 1 |
md:grid-cols-3 | 1 |
lg:grid-cols-4 | 1 |
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:
| Situation | Reach for | Why |
|---|---|---|
| A collection of like items in rows and columns | grid | Items align across rows; the row height is shared |
| A row of unlike items (icon + label, nav bar, button group) | flex | Content-sized, one axis, gap does the spacing |
| Sidebar + content at a fixed ratio | grid-cols-[…] | The ratio is the layout; write it once |
| Anything that needs reordering at a breakpoint | grid + order-* | Plus contents if the source nesting is in the way |
| Centering one thing | flex | Fewer 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
| Symptom | Cause | Fix |
|---|---|---|
| A grid item stretches the whole grid wider than the screen | A long unbreakable string in a 1fr track; the default min-width: auto refuses to shrink | Tailwind's grid-cols-N already ships minmax(0,1fr); on a custom track list write minmax(0,1fr) yourself |
grid-cols-3 does nothing | The element has no grid class — grid-cols-* only sets template columns | Add grid alongside it |
| Columns appear on mobile too | Unprefixed grid-cols-2 applies at every width; Tailwind is mobile-first | Prefix it: sm:grid-cols-2 |
gap seems to apply on one axis only | gap-* sets both; gap-x-* / gap-y-* set one | Check for a more specific utility later in the class list |
Reordering with order-* breaks keyboard navigation | order moves the box, not the DOM node | Only reorder blocks with no focusable content, or change the source order instead |
A wrapper div blocks you from ordering its children | The wrapper is the grid item, not its children | contents on the wrapper, as in TemplateDetail above |
| Arbitrary track list won't compile | Spaces aren't legal in class names | Use 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.
