The 12-Column Grid I Keep, With Named Areas and Container Queries
I rewrote our marketing site grid three times before settling on this: a 12-column CSS Grid with named areas for the hero shape, container queries for the breakpoints, and custom properties for the gutter so designers can change one value.
By @lucasmoreau
January 11, 2026
·
Updated May 20, 2026
1,143 views
15
4.4 (13)
/* The grid I drop into every project. One CSS variable controls the gutter,
the column count is fixed at 12, and minmax(0, 1fr) is the part everyone
forgets which prevents long words from blowing the layout out. */
.layout {
--gutter: 24px;
--max-width: 1200px;
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: var(--gutter);
max-width: var(--max-width);
margin-inline: auto;
padding-inline: var(--gutter);
}
/* Span helpers. The naming matches Bootstrap so designers feel at home. */
.col-span-1 { grid-column: span 1; }
.col-span-2 { grid-column: span 2; }
.col-span-3 { grid-column: span 3; }
.col-span-4 { grid-column: span 4; }
.col-span-5 { grid-column: span 5; }
.col-span-6 { grid-column: span 6; }
.col-span-7 { grid-column: span 7; }
.col-span-8 { grid-column: span 8; }
.col-span-9 { grid-column: span 9; }
.col-span-10 { grid-column: span 10; }
.col-span-11 { grid-column: span 11; }
.col-span-12 { grid-column: span 12; }
/* Start helpers for offset layouts. */
.col-start-2 { grid-column-start: 2; }
.col-start-3 { grid-column-start: 3; }
.col-start-4 { grid-column-start: 4; }
.col-start-7 { grid-column-start: 7; }The whole layout fits on a screen because every decision is exposed as a custom property. --gutter is the single value designers ask to change, and threading it through gap, padding-inline, and any nested grid keeps everything aligned. The minmax(0, 1fr) (rather than just 1fr) is the line I always forget at first; without it, a long unbroken word in a child element forces the column wider than its share, which on a 12-column grid means columns 7-12 silently overflow the viewport. Naming columns col-span-N rather than the more recent grid-column: span var(--span) choice was deliberate: designers grep classes faster than they parse arbitrary properties.
/* For the marketing hero, named areas make the shape readable. The same
12-column track count is reused so the hero aligns with the grid below it. */
.hero {
--gutter: 24px;
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
grid-template-rows: auto auto;
gap: var(--gutter);
grid-template-areas:
"headline headline headline headline headline headline headline headline . art art art"
"subhead subhead subhead subhead subhead subhead . . . art art art";
}
.hero__headline { grid-area: headline; }
.hero__subhead { grid-area: subhead; }
.hero__art { grid-area: art; }
/* The shorthand version of the same template, for sites where names are overkill. */
.hero--minimal {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: var(--gutter);
}
.hero--minimal .hero__headline { grid-column: 1 / span 8; }
.hero--minimal .hero__subhead { grid-column: 1 / span 6; }
.hero--minimal .hero__art { grid-column: 10 / span 3; grid-row: 1 / span 2; }Named areas pay off whenever the shape is non-rectangular. The headline is 8 columns wide, the subhead is 6 columns under it, and the art block sits over both rows on the right with a one-column visual break. With grid-template-areas the shape reads top-to-bottom, left-to-right exactly as it renders, and a designer reviewing a PR can spot the layout intent without running the page. The . cells are the empty columns that act as visual whitespace; they cost nothing because empty grid cells do not generate boxes. I keep the shorthand version next to it because some pages want the spans without the area names.
/* The card has the same 3 layouts at different parent widths. Before container
queries we had to know the page context to style cards correctly; now the
card's parent declares itself a container and the card adapts to its slot. */
.card-grid {
container-type: inline-size;
container-name: cards;
}
.card {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
@container cards (min-width: 480px) {
.card {
grid-template-columns: 120px 1fr;
}
.card__title {
font-size: 1.25rem;
}
}
@container cards (min-width: 800px) {
.card {
grid-template-columns: 160px 1fr auto;
}
.card__cta {
align-self: center;
}
}
/* Fallback for browsers without container queries (Safari < 16).
We approximate with a parent class instead of viewport width. */
@supports not (container-type: inline-size) {
.card-grid--narrow .card { grid-template-columns: 1fr; }
.card-grid--medium .card { grid-template-columns: 120px 1fr; }
.card-grid--wide .card { grid-template-columns: 160px 1fr auto; }
}Container queries (@container) are the breakpoint type I have wanted for a decade. The card no longer needs to know whether it is in a sidebar or a main column; the container reports its inline size and the card responds. The two breakpoints I ship with every component are 480px (icon plus stacked text becomes icon plus inline text) and 800px (room for a CTA on the right). The @supports not (container-type: ...) block is what makes this safe to ship: Safari before 16 and Firefox before 110 fall back to a parent-class scheme that approximates the same three layouts. I have not needed the fallback in two years but I leave it because the cost is one extra @supports block.
