CSS Grid arrived, and the layout hacks can go

Grid shipped in Chrome 57 on 9 March and Firefox 52 on 7 March, which is the first time in years that a layout feature arrived in two engines within a fortnight. This is what replacing an existing float-based grid system with it looked like, and what still has to be kept for the browsers that do not have it.

The symptom

$ wc -l resources/assets/sass/_grid.scss
412 resources/assets/sass/_grid.scss

$ grep -c 'clearfix|:after|negative|margin: 0 -' resources/assets/sass/_grid.scss
34

Four hundred lines implementing something the browser could not do, of which thirty-four are working around the fact that floats were never a layout mechanism. Nobody on the team had modified that file in two years, and the one person who understood the negative margins had left.

Why it happens

Every grid system since the beginning has been built on floats or inline-block, both of which are text-flow features. The consequences are all the same shape: a floated element is out of the normal flow, so its parent has no height, so a clearfix is needed; columns cannot know about each other, so equal heights need JavaScript or a table display; gutters have to be padding, so the outer edges need cancelling with negative margins on the row.

None of that is a CSS problem to be solved better. It is the absence of a layout system, worked around competently for a decade.

The fix

The page skeleton

/* before: 40 lines of float, clearfix and negative margin */

/* after */
.page {
    display: grid;
    grid-template-columns: 16rem minmax(0, 1fr) 14rem;
    grid-template-rows: auto 1fr auto;
    grid-template-areas:
        "nav  head  head"
        "nav  main  aside"
        "nav  foot  foot";
    gap: 1.5rem;
    min-height: 100vh;
}

.page__nav   { grid-area: nav; }
.page__main  { grid-area: main; }
.page__aside { grid-area: aside; }

The named areas are the documentation — the layout is drawn in the stylesheet, and rearranging it is editing the picture rather than renumbering lines. The minmax(0, 1fr) on the middle column is the one non-obvious piece: 1fr has an automatic minimum, so a wide table or an unbreakable string pushes the column past its share, exactly as it does in flexbox.

The card grid, which loses its breakpoints

/* before: three media queries, chosen by guessing at device widths */
.cards__item { width: 100%; }
@media (min-width: 30em) { .cards__item { width: 50%; } }
@media (min-width: 48em) { .cards__item { width: 33.333%; } }
@media (min-width: 64em) { .cards__item { width: 25%; } }

/* after: no media queries at all */
.cards {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
    gap: 1rem;
}

The browser fits as many 16rem columns as the container allows, which means the breakpoints happen where the content needs them at any container width — including inside a sidebar, which the media-query version got wrong because a media query asks about the viewport rather than about the element.

Note

auto-fit collapses empty tracks so three cards in a four-column space stretch to fill it; auto-fill keeps the empty columns and leaves a gap. Neither is correct in general — auto-fit for a card grid, auto-fill when the columns are a structure the user is meant to perceive.

Keeping the old layout for browsers without it

IE11 has a 2011 draft implementation under a prefix, which is a different specification with different behaviour — treating it as partial support is worse than treating it as none. A feature query keeps both in one file.

/* fallback first, unconditionally */
.cards { display: flex; flex-wrap: wrap; margin: -0.5rem; }
.cards__item { flex: 1 1 16rem; margin: 0.5rem; }

@supports (display: grid) {
    .cards {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
        gap: 1rem;
        margin: 0;              /* undo the fallback */
    }
    .cards__item { flex: none; margin: 0; }
}

Writing the fallback outside the query and the enhancement inside is the order that works, because a browser without @supports never evaluates the second block. The lines people forget are the undo lines: a leftover negative margin from the flex fallback still applies inside the grid and produces spacing nobody can account for.

Verifying it worked

$ wc -l resources/assets/sass/_grid.scss
38 resources/assets/sass/_grid.scss

$ npm run production
 /css/app.css   34.1 kB   [emitted]      # was 41.2 kB

# and the check that matters: the same pages, screenshot compared
$ node bin/visual-diff.js --pages=home,catalogue,product,checkout
4 pages compared, 0 differences above threshold

The screenshot comparison is the assertion for a change like this, because a layout refactor has no unit test and the failure mode is visual. Four pages at three widths, compared against the build before the change.

What it does to the markup

The change that outlasts the stylesheet is in the HTML. A float grid needs wrapper elements — a row to clear, a container to cancel the negative margins, sometimes an inner div for the padding — and every one of those is markup that exists for layout and confuses anything reading the document structure.

<!-- before -->
<div class="container">
  <div class="row">
    <div class="col-md-4"><div class="col-inner">…</div></div>
    <div class="col-md-8"><div class="col-inner">…</div></div>
  </div>
</div>

<!-- after -->
<main class="page">
  <nav>…</nav>
  <article>…</article>
</main>

Placement is decided by the container rather than by classes on the children, so the elements can be the ones the content deserves. A screen reader, a search engine and anyone reading the source all benefit, and none of that shows up in a screenshot comparison — which is the argument for doing the markup cleanup in the same change rather than leaving the wrappers in place.

What this costs

Two layouts exist for as long as the fallback is needed, which is a second implementation to keep correct — and the fallback is the one nobody looks at, so it rots. The mitigation is to keep it deliberately worse: a single column below the grid breakpoint is easy to keep working, and trying to reproduce the grid layout in flexbox is how the maintenance cost becomes real.

The other cost is that subgrid does not exist. Cards containing a title, body and footer will not align those rows across siblings, because each card is its own grid. The workaround — putting the parts on the parent grid — breaks the card as a component, which is a worse trade in most cases. It is a real limitation with a specification and no implementations, and the honest answer this year is to accept the misalignment.