Four Utility Classes for Modern Layouts
Few things send developers reaching for a CSS framework faster than the need to spin up a layout. But the calculations that used to justify a full library just for its grid system have largely evaporated — modern CSS gives us the primitives to build layouts directly, without the abstraction layer.
What follows is a practical system of four CSS utility classes (plus a fifth for a specific subgrid case) that cover the layout patterns you'll need across most projects. Two use Flexbox, two use Grid. Within each pair, one class auto-fills available space (a "fluid" layout) and one gives you explicit control over the number of columns and rows (a "repeating" layout).
The classes are built to be configurable through CSS custom properties and organized into Cascade Layers, so they drop into an existing project without fighting your own specificity rules.
Layering the Foundation
Cascade Layers let us control the order in which styles are evaluated, which keeps the utility classes easy to override. We define three layers at the top of the stylesheet:
The reset layer comes first, meaning it has the lowest priority. The theme layer holds CSS variables and sits in the middle. The layout layer comes last and has the highest specificity of the three — so the layout rules take precedence over reset and theme styles when they conflict.
Note that any un-layered styles in your project will be read after all layers, giving them the highest specificity of all. If you already use a reset in your project, you can leave out the reset layer from your own cascade — just be aware of where those styles land in the order.
For the reset layer, a minimal box-sizing rule is all we need:
.box-sizing-reset, .box-sizing-reset *, .box-sizing-reset *::before, .box-sizing-reset *::after {
box-sizing: border-box;
}
The Variables That Configure the System
All four layout classes are driven by just three CSS custom properties, scoped to :root so they're available anywhere in the document. The defaults below are sensible starting points, but the entire point of using variables is that you'll override them per project or per component.
:root {
--layout-fluid-min: 35ch;
--layout-repeat: 3;
--layout-gap: 3vmax;
}
Three variables, three meanings:
--layout-fluid-mindefines the minimum width of auto-sized columns, set to 35 characters.--layout-repeatcontrols the number of columns or rows in the repeating layouts, defaulting to 3.--layout-gapsets the space between items, here 3% of the viewport's largest side.
The layout- prefix is a personal convention to signal these are layout-specific values. Rename them to suit your own naming scheme, which is always a matter of preference.
Where the Utility Classes Live
All of the layout rulesets go inside the final, highest-priority layer. That keeps them neatly separated from your resets and variables while ensuring they win the specificity battle when it matters.
The Repeating Layouts
First up are the "repeating" classes, which give you predictable, evenly sized tracks. We start with the Grid version and then look at how the same goal is achieved with Flexbox.
The Repeat Grid Class
This utility is built around the repeat() function alongside minmax() to handle responsive rows:
The key is that the number of columns is driven by the --layout-repeat variable, while the gap and padding use the global variables you've already defined. This is the pattern you'd apply when you know you want a fixed number of tracks but want the sizing to remain fluid.
The Fluid Layouts
The fluid variants trade the explicit count for a rule based on available space, letting the browser decide how many tracks fit.
The Fluid Grid Class
Here the magic is in the repeat() plus auto-fill combination with a minmax() bound. The result is a grid that fills the container with as many columns as will fit given the minimum width in --layout-fluid-min, wrapping to rows as needed. When container query units are needed for responsive behavior based on the container's size rather than the viewport, that's handled through container query integration.
The Fluid Flexbox Class
Flexbox doesn't have the same auto-placement power as grid, but we get close by using a flex-basis that respects our fluid minimum. When you combine it with a negative margin to handle the gutters on wrapped rows, the effect is visually consistent with what the grid version produces — just with the flow-based behavior of flex.
The Fifth Class: When Subgrid Is the Answer
The grid layouts we've covered do the heavy lifting for top-level page structure. But inner elements of a grid item sometimes need to align their own children with the column tracks defined on the parent grid. That's exactly what CSS Subgrid exists for.
For those container-query-driven scenarios where a nested grid must mirror the parent's column grid, the additional utility class ties into the same variables and gives you full control over both rows and columns from a single place.
When you need to drop a subgrid into your layout, that's the class you want. It gives you a nested grid whose tracks match the parent's columns, and it plays nicely with the same gap variables.
Putting the Classes Together
The result is a small, dependency-free toolkit that handles the majority of layout tasks you'd otherwise outsource. Need three equal columns? Use the repeating grid or flex class. Need a responsive list that fans out across available space without knowing item count in advance? Fluid grid or flex has you covered. The added subgrid class extends the system when the layout demands nested alignment.
Perhaps just as useful as the finished classes is what it takes to get there. All this is plain CSS—no preprocessor mixins, no JavaScript helpers, no build step. The layouts are configurable, restrained, and reside entirely inside Cascade Layers with predictable and controllable specificity. They're ready to adapt to whatever your project throws their way—or to be ignored entirely in favor of your own patterns, now that you've seen how straightforward it is to write what you need directly.
From Fixed Repeats to Fluid, Automatic Layouts
The repeating utilities we've built give explicit control over column counts. But there's a trade-off: we have to know exactly how many repeats we want at any given breakpoint. The fluid versions of these utilities flip that model — instead of telling the browser how many columns to render, we give it sizing hints and let it figure out the optimal number of columns for the available space.
The Fluid Grid Utility
Rather than defining a repeat count, the Fluid Grid utility centers on a minimum column width. Set a column to be at least 400px wide with a 20px gap, and the browser automatically produces a two-column grid when the container exceeds roughly 820px, three columns beyond roughly 1240px, and so on. When the container narrows past the threshold for two columns, the single column simply stretches to fill the full container width.
The utility's full ruleset is compact:
.fluid-grid {
--_fluid-grid-min: var(--fluid-grid-min, var(--layout-fluid-min));
--_fluid-grid-gap: var(--grid-gap, var(--layout-default-gap));
}
Most of the work is concentrated in the grid-template-columns property, which is set to repeat(auto-fit, minmax(min(100%, var(--fluid-grid-min)), 1fr)). The auto-fit keyword tells the browser to fit as many columns as possible into whatever space is available. When there isn't room for more columns, they wrap to the next line and expand to fill it.
But the interesting nesting appears inside minmax(), where a min() function controls the minimum column width. This effectively clamps the minimum width between the declared --fluid-grid-min value and 100%, ensuring that a grid item never collapses below the full container width when there's not enough space for multiple columns. The 1fr maximum half of minmax() guarantees that all columns that do fit share the remaining space equally.
See the Pen [Fluid grid [forked]](https://codepen.io/smashingmag/pen/GRaZzMN) by utilitybend.
The combination of auto-fit with this particular minmax() pattern makes this a strong grid, too. When paired with modern relative units — like ch for character-based sizing — the result is a grid that naturally scales from one column up to multiple columns based on the actual content size, without a single hand-tuned breakpoint.
One thing worth noting about this implicit sizing approach is that for a minimum width of 400px, a container needs to be about 820px wide to hold two columns — that's two 400px columns plus the 20px gap between them. This is the browser doing its own math, and it scales cleanly with whatever minimum you choose.
Fluid Flex Reutilizes the Earlier Pattern
The Fluid Flex utility reuses nearly everything we wrote for Repeating Flex, with one key difference: instead of calculating flex-basis from a repeat count, we set it from each column's minimum width:
.fluid-flex {
--_fluid-flex-min: var(--fluid-flex-min, var(--layout-fluid-min));
--_fluid-flex-gap: var(--flex-gap, var(--layout-default-gap));
display: flex;
flex-wrap: wrap;
gap: var(--_fluid-flex-gap);
> * {
flex: 1 1 var(--_fluid-flex-min);
}
}
In this version, the private --_fluid-flex-min variable is applied directly as the flex-basis value, wrapped in the same min() logic we used in the grid variant. This allows each flex item's default size to never shrink below its minimum while still permitting it to fill extra space when available.
The helper variables used in the Repeating Flex version — both the gap counter and the gap repeater calculation — don't appear here because they're only needed when computing sizes based on a fixed number of columns. With a fluid layout, the browser handles the column arithmetic automatically.
The Value of Variable Abstraction in Container Design
Taken together, these four utilities each build on the same foundation: a global theme variable defines a default, a utility class's private variable references that global default, and each utility allows for further overrides via semantic classes. An HTML structure using these utilities remains simple and self-explanatory — a class like repeating-flex that's been customized with a more contextual footer-usps class immediately conveys what the layout is and what purpose it serves:
<section class="repeating-flex footer-usps">
<div></div>
<div></div>
<div></div>
</section>
The corresponding CSS for that semantic override is correspondingly thin — just a few variable updates:
.footer-usps {
--flex-repeat: 3;
--flex-gap: 2rem;
}
All this variable indirection might seem like extra work at first. Why not just reference the global theme variable directly? But the layering pays off in two significant ways:
- The HTML immediately communicates the layout type —
.repeating-gridor.repeating-flex— without digging into the stylesheet. - Styles stay organized in a layer structure that prevents specificity conflicts, keeping the separation of concerns intact.
No single approach fits every case, and you could absolutely skip some intermediate variables if you're after the leanest CSS possible. The utilities discussed so far cover the canonical cases — repeating equal-width columns for things like product grids, and flexible wrapping rows for lists of items with variable content lengths.
A Subgrid Utility for Nested Layouts
Subgrid gives grid items the ability to become grid containers that share the parent’s track sizing, keeping nested content aligned without manually redefining tracks. With full browser support, it strengthens any layout system. To make it reusable, we can package it as a utility that works alongside the Repeating Grid and Fluid Grid classes when children need to act as containers for their own content.
.subgrid-rows {
> * {
display: grid;
gap: var(--subgrid-gap, 0);
grid-row: auto / span var(--subgrid-rows, 4);
grid-template-rows: subgrid;
}
}
This introduces two variables:
--subgrid-gapcontrols the vertical gap between grid items.--subgrid-rowssets the number of grid rows, defaulting to4.
The main challenge is controlling the row count of subgrid items themselves. There are two practical ways to handle this.
Inline Styles for Direct Control
Since the subgrid already relies on a CSS variable, that same variable can be applied directly in the HTML using an inline style, informing the subgrid how far it can grow:
<section class="fluid-grid subgrid-rows" style="--subgrid-rows: 4;">
<!-- items -->
</section>
Automating Rows with :has()
Alternatively, the :has() pseudo-class lets the layout adjust itself without touching the markup. The CSS becomes more verbose, but it automates row assignment for nearly any content structure. Consider this approach:
.subgrid-rows {
&:has(> :nth-child(1):last-child) { --subgrid-rows: 1; }
&:has(> :nth-child(2):last-child) { --subgrid-rows: 2; }
&:has(> :nth-child(3):last-child) { --subgrid-rows: 3; }
&:has(> :nth-child(4):last-child) { --subgrid-rows: 4; }
&:has(> :nth-child(5):last-child) { --subgrid-rows: 5; }
/* etc. */
> * {
display: grid;
gap: var(--subgrid-gap, 0);
grid-row: auto / span var(--subgrid-rows, 5);
grid-template-rows: subgrid;
}
}
Each declaration checks whether a particular subgrid row is also the last item in its container. For instance, the second rule:
&:has(> :nth-child(2):last-child) { --subgrid-rows: 2; }
reads as: “If this is the second subgrid item and it is the last in the container, set the number of rows to 2.”
It’s a heavier-handed technique, but it demonstrates the power of handling this purely in CSS.
The remaining piece is making grid children queryable. Giving them a generic class, .grid-item, lets us declare each as a container. This allows the layout to respond to the container’s size rather than the viewport width in a media query:
:is(.fluid-grid:not(.subgrid-rows),
.repeating-grid:not(.subgrid-rows),
.repeating-flex, .fluid-flex) {
> * {
container: var(--grid-item-container, grid-item) / inline-size;
}
}
The selector looks complex, but :is() keeps it compact. It targets the direct children of the other utility classes without bleeding into .subgrid-rows and incorrectly selecting its children.
The container property is a shorthand combining container-name and container-type, separated by a forward slash (/). The name comes from the variable, and the type is always inline-size, meaning the width in horizontal writing modes. container-type applies only to grid containers, not grid items, which is why the subgrid rows — also grid items — had to be excluded with the more explicit selector.
Demo and Usage
The full demo brings everything together.
See the Pen [Grid system playground [forked]](https://codepen.io/smashingmag/pen/mdYPvLR) by utilitybend.
It imports styles from a companion pen containing the complete CSS for all the utilities built in this article. Swapping the .fluid-flex class on the parent container for any of the other utility classes updates the layout automatically, making it easy to compare behavior.
The available classes are:
.repeating-grid,.repeating-flex,.fluid-grid,.fluid-flex.
For grid-based layouts, the optional .subgrid-rows class can be combined with .repeating-grid or .fluid-grid to turn any grid item into a nested grid container.
Write Once, Reuse Everywhere
These utilities represent a significant shift in how layout code is written. They are authored once and can be applied across projects to build a variety of layouts using only modern CSS. This approach can reduce or even remove the need for a CSS framework’s layout system.
The work here draws from a mix of techniques, notably a presentation by Stephanie Eckles at CSS Day 2023. Her clean approach to handcrafted modern CSS solutions stands out in an industry increasingly dependent on complex tooling. Experimenting with subgrid afterward led to a realization about how extensible modern CSS layout is, which is what inspired this utility set.
These utilities aren’t presented as universally perfect or inherently better than any existing framework. But experimenting with them provides a solid grasp of CSS’s capacity to make layout work more convenient and robust than it has ever been. Trying new ideas and seeing what they enable is the best way to appreciate their value.




