Process diagram: One DOM, five constraint sets. 5 stages, left to right: Name it then Size it then Reflow it then Subgrid then Layer it. Current stage: Reflow it.

CSS Grid as a constraint system you declare and the browser solves

One content dashboard, evolved through the whole toolkit: named areas, the intrinsic-sizing functions, auto-fit reflow, subgrid, and a layered hero. Declare the constraints, and let the layout engine do the solving.

The one idea: you declare constraints, the browser solves them#

Most CSS Grid tutorials hand you a bag of properties. You learn fr, then minmax, then auto-fit, as if they were unrelated tricks. However, they are one system with one job. You describe a set of constraints, and the layout engine solves for a concrete arrangement.

That reframe matters, because it changes how you debug. When a layout looks wrong, you stop nudging pixels. Instead you ask which constraint you stated badly. Therefore CSS Grid as a constraint system is not a slogan. It is the mental model that makes every function below click into place.

The MDN CSS Grid layout guide lists the properties well. Yet a reference cannot give you the frame. So this post supplies the frame, then proves it on one real layout you can copy.

The layout we build once and evolve five times#

First, pick one artifact and carry it the whole way. Ours is a content dashboard, the kind every product ships. It has a sidebar, a header, a main region, a grid of widget cards, and a layered hero.

Below is the plain semantic markup. Notice there is no layout class yet, only meaning. This exact markup never changes again. Every stage rewrites the CSS around it, which is the entire point of treating CSS Grid as a constraint system.

dashboard.html · html
<!-- The whole post evolves THIS markup. It never changes again.
     Semantic regions, in reading order, with no layout classes yet. -->
<div class="dashboard">
  <header class="db-header">Account overview</header>
  <nav class="db-sidebar">Primary navigation</nav>
  <main class="db-main">
    <section class="db-widgets"><!-- widget cards live here --></section>
  </main>
  <footer class="db-footer">Sync status</footer>
</div>

Read the DOM as reading order, not as layout. The sidebar comes first in the source, yet it can sit anywhere on screen. Because the area map decides position, the markup stays honest for screen readers while the grid arranges the pixels.

Stage 1. Name the layout: grid-template-areas as an ASCII blueprint#

Start by naming the frame. Named areas let you draw the layout as ASCII art, so the CSS reads like a picture of the page. This is the self-documenting trick that references mention but rarely build on.

The template is the documentation#

Here is Stage 1 on our dashboard. Each region gets a name, then the template paints the frame. You can read the arrangement at a glance, with no comment needed.

stage-1-areas.css · css
/* Name each region once, then draw the frame as ASCII art. The template
   IS the documentation: you can read the layout without a comment. */
.dashboard {
  display: grid;
  grid-template-columns: 200px 1fr;
  grid-template-rows: auto 1fr auto;
  grid-template-areas:
    "sidebar header"
    "sidebar main"
    "sidebar footer";
  min-height: 100vh;
}

/* Each region claims its name. The order in the DOM no longer decides
   the position; the area map does. */
.db-sidebar { grid-area: sidebar; }
.db-header  { grid-area: header; }
.db-main    { grid-area: main; }
.db-footer  { grid-area: footer; }

Look at the grid-template-areas block. The word sidebar repeats down the first column, so the sidebar spans all three rows. Because the names carry the intent, you write far fewer brittle selectors. That keeps your stylesheet clear of the same specificity discipline that trips people up with :focus-visible.

Re-arrange by redefining areas, not by moving DOM#

Now the payoff. A layout built from named areas re-arranges when you redefine the map, not when you reorder the document. The demo below proves it. Flip the breakpoint and watch the same four regions snap to a new position.

Named areas: one skeleton drawn three ways
One skeleton, three area maps: rearrange by redefining, not by moving DOM

Desktop template

grid-template-columns: 200px 1fr;
grid-template-areas:
  "sidebar header"
  "sidebar main"
  "sidebar footer";

A full-height sidebar beside a stacked header, main, and footer.

What the browser draws

Tablet template

grid-template-columns: 160px 1fr;
grid-template-areas:
  "header  header"
  "sidebar main"
  "footer  footer";

The header spans the top, the sidebar tucks beside main, the footer spans the base.

What the browser draws

Mobile template

grid-template-columns: 1fr;
grid-template-areas:
  "header"
  "sidebar"
  "main"
  "footer";

One column. Every region stacks in reading order.

What the browser draws

Flip the breakpoint and watch the same four regions snap to a new arrangement. The four region tiles are identical markup in every panel. Only the grid-template-areas declaration changes. With JavaScript off, all three panels render stacked.
Switch between desktop, tablet, and mobile. The four region tiles are byte-identical markup in every panel; only the grid-template-areas declaration changes. With JavaScript off, all three panels render stacked so the lesson still lands.

Notice what did not happen. No region moved in the DOM. No JavaScript reshuffled nodes. Instead each breakpoint stated a different area map, and the browser solved the rest. That is declarative layout: you change the description, and the engine changes the result.

Stage 2. Stop guessing widths: the intrinsic-sizing toolkit#

In practice, fixed pixel widths are the fragile part of most layouts. They break the moment content or the viewport disagrees with your guess. So Stage 2 replaces every fixed width with a function that states a constraint instead. Then the browser solves the widths.

minmax, fr, and the fraction that does the solving#

Here is the same dashboard with its fixed columns gone. The 200px sidebar becomes a stated range. The flexible column becomes a fraction of the free space.

stage-2-sizing.css · css
/* Stage 2: delete every fixed pixel width. State a RANGE and a fraction,
   then let the browser solve the columns. */
.dashboard {
  display: grid;
  /* The sidebar was 200px. Now it is "never below 12rem, never above 15rem". */
  grid-template-columns: minmax(12rem, 15rem) minmax(0, 1fr);
  grid-template-rows: auto minmax(0, 1fr) auto;
  /* The gap itself flexes with the viewport. */
  gap: clamp(0.75rem, 2vw, 1.5rem);
}

Read minmax(12rem, 15rem) aloud. It says the sidebar is never below 12rem and never above 15rem. Meanwhile minmax(0, 1fr) says the main column takes whatever is left, down to nothing. The fr unit is the fraction that does the solving, because it distributes free space after the fixed and ranged tracks are placed. The MDN minmax reference covers the edge cases.

Each function replaces a brittle hack: the reasoning table#

In practice, these intrinsic functions are scattered across the docs, so they feel unrelated. In truth each one replaces a specific fragile hack. Read the table as one reasoning system, not six tricks.

The intrinsic-sizing toolkit, mapped to the brittle width it retires
FunctionThe brittle hack it replacesThe constraint it states
minmax(a, b)A fixed width plus a media query to shrink itNever below a, never above b.
1frPercent widths that drift out of sync with the gapTake one share of the free space left over.
min-contentA hardcoded narrow column for a labelBe as narrow as the longest unbreakable word.
max-contentA fixed width picked to avoid wrappingBe as wide as the content needs with no wrapping.
fit-content(a)A width that is min of content and a manual capGrow with content, but never past the cap a.
clamp(a, v, b)Three media queries for small, medium, and largeScale with v, held between a floor a and ceiling b.

Every row states a constraint instead of a guess. Therefore the browser can solve the width at any container size. That is the whole shift from fighting the layout to describing it.

Stage 3. Reflow without a single media query: auto-fit + minmax#

This is the heart of the post. The widget grid inside main should add and drop columns as it resizes. Most guides show the one-liner but skip the model. So here is both, then the live proof, then the honest exception.

The RAM one-liner, read as a sentence#

One declaration makes the widget grid responsive with no breakpoint. It is often called the RAM pattern, for repeat, auto-fit, minmax.

stage-3-ram.css · css
/* Stage 3: the widget grid inside main. ONE declaration, no breakpoints. */
.db-widgets {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 16px;
}

Read it as plain English. It says: as many equal columns as fit, each at least 200px wide, sharing the leftover space. Because the constraint is complete, the browser adds a column when the container grows and drops one when it shrinks. This is the responsive performance work that goes beyond media-query breakpoints, which our 2026 site optimization guide treats in depth. The MDN repeat reference documents the keywords.

auto-fit vs auto-fill: the empty-track difference, with real numbers#

Now the one distinction most guides get thin. auto-fit and auto-fill count tracks the same way. They differ only in what they do with an empty track. Rather than describe it, drag the numbers and watch it.

auto-fit vs auto-fill: watch the empty track collapse
Repeat keyword (the one word that changes everything)
Card 1A widget in the dashboard main region.322.67px
Card 2A widget in the dashboard main region.322.67px
Card 3A widget in the dashboard main region.322.67px

Tracks the browser reserves (4 fit at this width):

Track 1filled
Track 2filled
Track 3filled
Track 4empty (phantom)
Tracks that fit4
Columns shown3
Each card width322.67px

floor((1000 + 16) / (200 + 16)) = 4 tracks. auto-fit collapsed the 1 empty track to zero, so the 3 cards stretch to 322.67px each and fill the row. Switch to auto-fill to reserve the empty space instead.

The same declaration reflows with NO media query. Three cards, minmax(200px, 1fr), 16px gap.
Container widthTracks that fitauto-fill resultauto-fit result
1000px43 cards at 238px + 1 empty 238px track3 cards stretch to 322.67px
640px33 cards at 202.67px, no empty track3 cards at 202.67px (identical)
400px11 column at 400px, cards wrap1 column at 400px (identical)
Set the container width, the minmax min-track, the gap, and the card count, then flip the repeat keyword. The preview is a REAL CSS grid, so the browser solves the tracks live. The ruler names every track filled or phantom, and the table below is the accessible source of truth with JavaScript off.

First, work the default numbers by hand to trust the demo. With a 1000px container, a 200px min-track, and a 16px gap, the browser computes floor((1000 + 16) / (200 + 16)), which is floor(1016 / 216), which is four tracks. So four columns fit.

Now place only three cards. With auto-fill, the browser keeps the fourth empty track, so each track is (1000 minus 48) / 4, which is 238px. The three cards render at 238px with a trailing empty 238px column. With auto-fit, the empty track collapses to zero, so the three cards share the full width at (1000 minus 32) / 3, which is 322.67px each. That single collapse is the entire real difference.

The one honest media query: redefine the areas at a breakpoint#

Intellectual honesty matters, so name the exception. There is one place a media query is still the right tool. It is redefining the page skeleton, not a width.

stage-3-skeleton.css · css
/* The ONE honest media query left in the whole layout. It redefines the
   SKELETON, not a width. You are changing WHICH region sits WHERE, and no
   intrinsic function can express that rearrangement. */
@media (max-width: 48rem) {
  .dashboard {
    grid-template-columns: 1fr;
    grid-template-areas:
      "header"
      "sidebar"
      "main"
      "footer";
  }
}

Stage 4. Line up nested cards with subgrid (Baseline in 2026)#

The widget cards have a title, a body, and a footer. Left alone, each card sizes its own rows, so footers drift out of line across the row. Subgrid fixes that by letting the cards share the parent grid's rows. In 2026 you can treat it as production-ready.

Subgrid browser support in 2026#

Card titles and footers that align across the grid#

Next, compare the two approaches directly. The first tab lets each card own its rows, so the footers land at different heights. The second tab shares the parent rows with subgrid, so every title and footer snaps to one baseline.

before-subgrid.css · css
/* Before subgrid: each card owns its own rows. A long title in one card
   pushes its footer down, so footers across the row never line up. The cards
   do not agree on a shared baseline. */
.widget {
  display: grid;
  grid-template-rows: auto 1fr auto; /* title / body / footer, per card */
}

That change is small but load-bearing. You define the three rows once on the parent, then each card inherits them with grid-template-rows: subgrid. Because the tracks are shared, alignment stops being a per-card fight. This is exactly the discipline behind scalable UI components built on a consistent layout contract, where cards must agree on a shared rhythm.

Stage 5. Layer the hero by stacking one grid cell#

The last stage is the one nobody teaches as a first-class pattern. A layered hero puts an image, a scrim, and copy on top of each other. The old way reaches for absolute positioning. The grid way is calmer: place every child in the same cell.

stage-5-layered.css · css
/* Stage 5: the layered hero. Every child shares ONE cell, so they stack
   like layers in a design tool. z-index and place-self order them. No
   absolute positioning, no negative margins. */
.hero {
  display: grid;
  grid-template-areas: "stack";
  min-height: 22rem;
}

/* The load-bearing line: all children occupy the SAME 1 / 1 cell. */
.hero > * {
  grid-area: stack;
}

.hero__image { z-index: 0; inline-size: 100%; block-size: 100%; object-fit: cover; }
.hero__scrim { z-index: 1; background: linear-gradient(transparent, rgba(0, 0, 0, 0.55)); }
.hero__copy  { z-index: 2; place-self: end start; color: #fff; padding: 1.5rem; }

Read the load-bearing line. Every child gets grid-area: stack, so they all occupy the same 1 / 1 cell. Then z-index orders them front to back, and place-self positions the copy in a corner. Because the cell contains them, you never fight offsets or negative margins. In short, overlapping becomes declarative layering.

Grid or flexbox? A 2D-vs-1D decision you can reuse#

The most common question deserves a reusable answer, not a shrug. CSS Grid places items on two axes at once. Flexbox flows content along one. So the choice follows from the shape of the problem, not from taste.

A 2D-versus-1D decision table you can keep beside your editor
The needReach forWhy
Place items on two axes at onceCSS GridGrid positions on rows and columns together; it is the only 2D system.
Align children on both axesCSS GridGrid aligns in two directions, while flexbox aligns along one.
Overlap children in a single cellCSS Gridgrid-area: 1 / 1 stacks children with no absolute positioning.
Lay out a whole-page skeletonCSS GridNamed areas describe the page frame in one readable declaration.
Flow a single row or column of contentFlexboxOne-dimensional content is exactly what flexbox was built for.
Wrap an unknown number of chips or tagsFlexboxflex-wrap fills and wraps without reserving empty tracks.

In short, keep the table close and the rule stays simple. Grid describes a frame, and flexbox flows a line. Often you use both, with grid for the page and flexbox inside a cell.

When NOT to reach for Grid#

There is one more trap to name. Do not overuse named areas for a layout with a single obvious column. A one-column stack needs no template; a plain block flow already reads clearly. In the end, the toolkit is a set of tools, not a ranking to climb.

CSS Grid as a constraint system, in one screen#

Finally, close the loop by stacking the stages back onto one artifact. The dashboard now names its frame, sizes its tracks with intrinsic functions, reflows its widgets with auto-fit, aligns its cards with subgrid, and layers its hero in a single cell. One layout carries the whole toolkit.

dashboard-final.css · css
/* The one dashboard, now carrying all five stages at once. */
.dashboard {
  display: grid;
  grid-template-columns: minmax(12rem, 15rem) minmax(0, 1fr);
  grid-template-areas:
    "sidebar header"
    "sidebar main"
    "sidebar footer";
  gap: clamp(0.75rem, 2vw, 1.5rem);
}

/* Stage 3: the widgets reflow with no media query. */
.db-widgets {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 16px;
}

/* Stage 4: nested card rows align across the row via subgrid. */
.widget { grid-row: span 3; grid-template-rows: subgrid; }

/* Stage 5: the hero layers three children in a single cell. */
.hero { display: grid; grid-template-areas: "stack"; }
.hero > * { grid-area: stack; }

/* Stage 3, the honest exception: redefine the SKELETON at one breakpoint. */
@media (max-width: 48rem) {
  .dashboard {
    grid-template-columns: 1fr;
    grid-template-areas: "header" "sidebar" "main" "footer";
  }
}

Read the final CSS as five constraints, not five hacks. Each block states a rule, and the browser solves the rest across every screen. That is what it means to treat CSS Grid as a constraint system: you declare, and the layout engine does the solving. The CSS Grid Level 2 specification defines every function you used here, subgrid included.

CSS Grid layout questions, answered

What is the difference between auto-fit and auto-fill in CSS Grid?
The difference is what each does with an empty track. Both compute how many columns fit with the same arithmetic. However, auto-fill keeps every track that fits, even the empty ones, so leftover space sits as a phantom column. In contrast, auto-fit collapses the empty tracks to zero, so the present items stretch to fill the row. For example, with three cards, minmax(200px, 1fr), a 16px gap, and a 1000px container, four tracks fit. Therefore auto-fill leaves the three cards at 238px plus one empty 238px track, while auto-fit stretches the three cards to 322.67px each.
Can I build a responsive grid without media queries?
Yes, and it is the most useful pattern in the toolkit. The one-liner repeat(auto-fit, minmax(200px, 1fr)) reads as a plain constraint: as many equal columns as fit, each at least 200px wide. Consequently the browser adds and removes columns as the container resizes, with no breakpoint at all. In practice you still keep one media query for the page skeleton, because rearranging which region sits where is a job named areas do best.
Is CSS subgrid safe to use in production in 2026?
Yes. Subgrid reached Baseline across Chrome, Edge, Firefox, and Safari, so it is widely available today. Therefore you can treat it as production-ready rather than a progressive enhancement in most projects. Still, verify your own analytics for any long-tail legacy browser before you drop a fallback. For the exact support table, check caniuse and the MDN subgrid reference.
When should I use CSS Grid instead of flexbox?
Reach for CSS Grid when you place items on two axes at once, and reach for flexbox when content flows along one. For example, a page skeleton, a card matrix, and an overlapping hero are all two-dimensional, so Grid fits. In contrast, a nav bar, a button row, and a wrapping chip list are one-dimensional, so flexbox is lighter. In short, Grid describes a frame, while flexbox flows a line.
What does minmax(200px, 1fr) actually mean?
It states a track that is never smaller than 200px and never larger than one fraction of the free space. First the browser reserves the 200px floor. Then it distributes the remaining space as 1fr, so the track grows to share what is left. Because the floor and the ceiling are both declared, the browser solves the width for you at every container size.
How do I build a holy grail layout with CSS Grid?
The holy grail layout is a header, a footer, a main column, and two flanking sidebars. With named areas it is a few lines: name the five regions, then draw them in a grid-template-areas block. Next, redefine that same area block inside one media query to stack the regions on small screens. Because only the template changes, the markup stays identical across breakpoints.

Rebuilding a dashboard, a design system, or a responsive layout that keeps sprouting breakpoints? We are happy to talk through the layout model with no pressure and no lock-in.

Talk through a frontend build

Keep reading