EmpireUI
Get Pro
← Blog8 min read#css grid#grid system#responsive

CSS Grid System for Design: 12-Column, Container Queries, Gap Control

Build a real CSS grid system from scratch — 12-column layouts, container queries, gap tokens, and subgrid. No frameworks required, just the spec done right.

CSS grid layout system with column and row structure on monitor

Why You Still Need a Grid System in 2026

Look, Flexbox is great. It solved a decade of float hacks and saved countless hours. But flex is one-dimensional — you're either thinking in rows or columns, never both at once. CSS Grid changed that in 2017, and honestly, most developers are still only using about 20% of what it offers.

A grid system isn't just about columns. It's a shared contract between design and engineering — a spatial language both sides speak. When you skip it, you end up with ad-hoc margin-left: 48px hacks scattered across a dozen components and a Figma file that no longer matches what's in the browser.

That said, the "12-column grid" you see in Bootstrap isn't magic. It's just a convention that divides cleanly into 2, 3, 4, and 6. You can build the same thing in pure CSS with zero dependency weight. Here's the baseline — a 12-column grid using CSS custom properties so every value is overridable per-context:

:root {
  --grid-columns: 12;
  --grid-gap: 1.5rem;
  --grid-max-width: 1280px;
}

.grid {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns), 1fr);
  gap: var(--grid-gap);
  max-width: var(--grid-max-width);
  margin-inline: auto;
  padding-inline: 1.5rem;
}

You'd be surprised how far that gets you. No npm install, no config files, no bundler plugins — just the spec working as intended. If you want something more opinionated out of the box, the Empire UI component library ships grid primitives already wired to its design token system, but there's value in understanding the foundation before reaching for abstractions.

The 12-Column Convention — and When to Break It

The 12-column grid dates back to print design, long before CSS existed. Twelve divides cleanly into halves, thirds, and quarters — which maps naturally to the three-panel layouts you see everywhere: sidebar + main + aside, or a 4-up card grid, or a centered editorial column.

In practice, you don't define every child with explicit column spans. You set a sensible default and override when needed. The pattern below gives you utility classes that work like a lightweight version of what Bootstrap popularized, but with zero specificity tricks:

.col { grid-column: span 12; }
.col-6 { grid-column: span 6; }
.col-4 { grid-column: span 4; }
.col-3 { grid-column: span 3; }
.col-2 { grid-column: span 2; }
.col-1 { grid-column: span 1; }

/* Named areas for structured layouts */
.layout-sidebar {
  grid-template-areas:
    'sidebar main main main main main main main main main aside aside';
}

Worth noting: grid areas with named strings are underused. They let designers hand off layouts that read like English — sidebar main aside — instead of magic numbers. They also make responsive overrides at breakpoints trivially readable.

When should you break the 12-column? When your design genuinely doesn't map to it. A masonry image gallery, an infinite canvas, a data-dense dashboard with dynamic columns — those want a different foundation. The 12-column is a default, not a law.

Gap Control That Doesn't Fight Your Design Tokens

Gap is where grid systems usually fall apart. You'll see codebases with gap: 16px hardcoded in one component, gap: 1rem in another, and margin: 24px sneaking in from a third. That's not a grid system, that's chaos with consistent column counts.

The fix is a gap scale tied to your spacing tokens. If you're already using a spacing system, your grid gaps should pull from the same scale — not invent new values. Here's how to wire that in with CSS custom properties:

:root {
  /* spacing scale — 4px base unit */
  --space-1: 0.25rem;   /* 4px */
  --space-2: 0.5rem;    /* 8px */
  --space-4: 1rem;      /* 16px */
  --space-6: 1.5rem;    /* 24px */
  --space-8: 2rem;      /* 32px */
  --space-12: 3rem;     /* 48px */

  /* grid gaps map directly to scale */
  --gap-tight: var(--space-4);
  --gap-base: var(--space-6);
  --gap-loose: var(--space-8);
  --gap-section: var(--space-12);
}

.grid-tight  { gap: var(--gap-tight); }
.grid-base   { gap: var(--gap-base); }
.grid-loose  { gap: var(--gap-loose); }

One more thing — CSS Grid's gap property supports separate row and column values. gap: 1.5rem 2rem sets row gap to 24px and column gap to 32px. This is incredibly useful for card grids where you want tighter vertical rhythm but more breathing room between columns.

Honestly, the token-to-gap mapping is the single change that most improves cross-team consistency. Once a designer sees that gap-base means 24px everywhere, they stop writing 24 in one frame and 1.5rem in the Figma annotation.

Container Queries Change Everything About Responsive Grids

Here's the old way: you write media queries against the viewport width. A card component changes its internal layout when the screen hits 768px. That works until you put the same card in a two-column sidebar — now it's triggering "mobile" layout at what is effectively a wide context, because the viewport is large but the component's container is narrow.

Container queries, shipping properly since Chrome 105 and now supported across all major browsers as of 2023, solve this. You query the component's own container width, not the viewport. Your grid component can be truly self-contained.

/* Opt the parent into container query context */
.card-wrapper {
  container-type: inline-size;
  container-name: card;
}

/* Card adapts to its own container, not the viewport */
@container card (min-width: 480px) {
  .card {
    display: grid;
    grid-template-columns: 200px 1fr;
    gap: 1.5rem;
  }
}

@container card (min-width: 720px) {
  .card {
    grid-template-columns: 280px 1fr;
  }
}

The container-queries-components deep-dive on this blog covers the nuances in detail, but the short version for grid systems: define your layout components with container-type: inline-size, then write all internal responsive rules as @container queries. Your grid cells become self-healing — drop them anywhere and they figure out their own layout.

Quick aside: container-type: size queries both dimensions, but inline-size is what you want 95% of the time. Querying block size creates circular dependency issues with height-auto elements that will make you question your life choices.

Subgrid — The Missing Piece for Aligned Card Rows

You've seen the problem: a row of cards with variable-length titles. The button at the bottom of each card refuses to align because each card is its own grid context. You throw min-height at it, pick an arbitrary value like 320px, and it works until someone writes a three-line title.

Subgrid is the actual solution. It's been in Firefox since 2019 and landed in Chrome 117. It lets a child element participate in its parent's grid tracks — so four cards in a row can all share the same row track definitions and align their internal content automatically.

.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 1.5rem;
}

.card {
  /* Participate in the parent's row tracks */
  display: grid;
  grid-row: span 3;
  grid-template-rows: subgrid;
  gap: 0;
}

/* Now these rows align across all cards in the row */
.card-image   { /* row 1 */ }
.card-content { /* row 2 — stretches to match tallest */ }
.card-footer  { /* row 3 — always at the bottom */ }

This is exactly the pattern the bento grid layout uses under the hood — cells that span variable tracks but maintain internal alignment. Without subgrid, you're hacking it with JavaScript or accepting misaligned UIs.

Worth noting: subgrid only inherits the tracks from the nearest grid ancestor that has explicit tracks defined. If you nest grids three levels deep, the innermost child can't reach grandparent tracks. Keep your grid hierarchy shallow and explicit.

Auto-Placement, Dense Packing, and the `auto-fill` vs `auto-fit` Debate

CSS Grid's auto-placement algorithm is smarter than most people give it credit for. By default, items flow into the next available cell. Set grid-auto-flow: dense and the algorithm will backfill gaps with smaller items — perfect for photo galleries and bento grid layouts where cards have different sizes.

/* Auto-placement with dense packing */
.masonry-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  grid-auto-flow: dense;
  gap: var(--gap-base);
}

/* Featured items span more columns */
.card-featured {
  grid-column: span 2;
  grid-row: span 2;
}

Now, auto-fill vs auto-fit. This trips people up constantly. Both create as many column tracks as will fit in the container. The difference: auto-fill keeps empty tracks (they take up space), while auto-fit collapses empty tracks to zero width. In a responsive grid where you want items to stretch to fill the row, auto-fit is what you want. For a fixed-column grid where empty cells should remain as placeholders, use auto-fill.

Honestly, 90% of the time you want auto-fit. The edge case for auto-fill is alignment grids where you need items to stay left-aligned and not stretch — like an icon grid that should never create a lone icon stretching to full width on the last row. Worth having both in your mental toolkit.

For a tailwind dashboard layout, the combination of named areas for the outer shell and auto-placement for the inner content areas is a powerful pattern. The shell stays predictable, the content adapts.

Putting It Together: A Production-Ready Grid System

Here's what a complete, token-aware grid system looks like in a real project. It's not glamorous — it's about 60 lines of CSS — but it covers 90% of what you'll need without reaching for a framework.

/* === GRID SYSTEM === */
:root {
  --grid-columns: 12;
  --gap-tight:  1rem;    /* 16px */
  --gap-base:   1.5rem;  /* 24px */
  --gap-loose:  2rem;    /* 32px */
  --grid-max:   1280px;
  --grid-pad:   1.5rem;
}

/* Base grid */
.grid {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns), 1fr);
  gap: var(--gap-base);
  max-width: var(--grid-max);
  margin-inline: auto;
  padding-inline: var(--grid-pad);
}

/* Span helpers */
.span-1  { grid-column: span 1; }
.span-2  { grid-column: span 2; }
.span-3  { grid-column: span 3; }
.span-4  { grid-column: span 4; }
.span-6  { grid-column: span 6; }
.span-8  { grid-column: span 8; }
.span-12 { grid-column: span 12; }

/* Responsive: collapse to 4-col on tablet, 1-col on mobile */
@media (max-width: 768px) {
  .grid { --grid-columns: 4; }
}
@media (max-width: 480px) {
  .grid { --grid-columns: 1; --gap-base: var(--gap-tight); }
}

/* Auto-responsive grid for card layouts */
.grid-auto {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: var(--gap-base);
}

/* Subgrid-ready card row */
.card-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  grid-auto-rows: auto;
  gap: var(--gap-base);
}
.card-row > * {
  display: grid;
  grid-row: span 3;
  grid-template-rows: subgrid;
}

Drop this into your project's base layer and you have a grid system that scales from a single-column mobile view up to a 1280px constrained desktop layout — with subgrid-aligned cards, auto-responsive flow, and gap values that trace back to a single source of truth.

From here, layering in container queries per-component means your design system components stay portable. A <Card> pulled from Empire UI can live in a full-width hero section or a narrow sidebar and adapt intelligently to both — no viewport-width media queries needed inside the component.

In practice, this approach reduces the number of responsive overrides you need to write by half. Instead of patching each component for each breakpoint, you're writing layout rules once at the container level and letting the grid do the math. That's the real payoff of treating grid as infrastructure rather than an afterthought.

FAQ

What's the difference between CSS Grid and a grid framework like Bootstrap?

Bootstrap's grid is CSS Grid (since v5) plus a pile of utility classes and a 12-column default. Rolling your own gives you zero overhead and full control over your token system — Bootstrap's column classes are just grid-column: span N with extra steps.

Do container queries replace media queries for grid layouts?

Not entirely. Media queries still make sense for page-level breakpoints — changing the outer shell layout, nav behavior, font scaling. Container queries are for components that need to adapt based on the space they're given, not the viewport size.

Is subgrid safe to use in production today?

Yes. As of Chrome 117 (released late 2023) and Firefox 71+, subgrid has broad support. Check caniuse.com for your specific audience, but for most web apps in 2026 it's a safe baseline.

Should grid gaps use rem or px?

Use rem. Gap values tied to the root font size scale correctly when users change their browser font settings — a real accessibility concern. A 24px gap becomes 1.5rem, and if someone's root font is 20px, the gap scales up proportionally rather than staying fixed.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

CSS Spacing System: 8px Grid, rem vs px, and Container QueriesContainer Queries for Components: Component-Driven Responsive DesignMasonry Layout in Tailwind: columns-* and the Pinterest GridTailwind Responsive Design Patterns: sm/md/lg Beyond Simple Show/Hide