Grids of equal-height items work well on the web. As a design pattern they’re clear and easy for users to read, and, thanks in no small part to Google’s Material Design, users are at this point very familiar with card-based grids. Through user-testing and client feedback, it has become clear that a float-based grid with items of unequal height is contrary to expectations, and appears to users to be a bug.
The problem we’ll be unpacking today is a sensible implementation of a card-based grid which meets the following must-haves:
- We must be able to display any number of items in our grid, and we must not depend on knowing the number of items in advance.
- Items must be of an equal height to all other items in their row.
- We must be able to change the number of items in a row at different viewport widths.
- It would be a bonus if we could have the vertical spacing between grid items match the horizontal spacing between grid items.
The CSS Grid spec has being championed by many as a long-overdue step towards a more sane approach to layout on the web. Unfortunately, as any battle-hardened frontender is aware, there are usually a few years at least between seeing a new technology and being able to play with it in production.
CSS Grid currently has solid support amongst modern browsers, with only IE11 and Opera Mini showing a problematic lack of implementation of the spec. While it would be wonderful to only support modern evergreen browsers, the reality of client work is that the perceived success of a project is often dictated by how it looks on the client’s machine. Support for older browsers can be at once difficult to drop completely, and impossible to find a budget for. It’s for this reason that I won’t suggest a solution that relies on a less feature-rich fallback for older browsers.
Flexbox currently shows a more robust cross-browser support, with only some specific and well-documented bugs in IE11’s implementation of the spec. While the Flexbox spec has its idiosyncracies, it has in place all of the tools that we need for equal height card grids. Despite this, Flexbox is not designed with repeating grids of content in mind, so we’ll face some interesting challenges with margins and the last child of the grid. Furthermore, there is a lack of consensus amongst browser vendors about how to implement percentage-based values for margin/padding, which we’ll need to keep in mind.
From the above, Flexbox seems to be the most suitable way forward here.
Let’s begin our grid with some markup:
I’m using the BEM naming convention here, with some modifications to make text selection faster in text editors.
equal_grid is technically a presentational class name which we’d normally avoid, but as the scope is limited to this blog post it seems suitable. We’ve used varying lengths of Sagan Ipsum for the grid item content, representing the unpredictable nature of content your client could use.
Let’s work mobile-first, and begin styling the smallest viewport sizes:
We’re using Stylus as a CSS preprocessor here, as well as the Rupture library to handle breakpoints in a developer-friendly and readable manner. We’ll talk about the benefits of our frontend tooling stack choices in a future article, but for the time being assume that the choices made here are intended to make the code simple for you to understand.
The unordered list has a
flex, and the default values for
justify-content fit our needs, so we won’t explicitly declare them. The variable
$sp--base represents the rendered height of a line of paragraph text with it’s line-height, and we’ve used that as the value of the
margin-top for any list item with another child preceding it (represented by the selector
* + &).
So far, so good. Let’s step up to a larger viewport and make our grid items display two-to-a-row:
You’ll notice that we’ve wrapped
* + & in a
+below(2) media query. While it’s nice to only declare media queries above a certain viewport width, in order to load only the necessary styles for devices with smaller viewports, it would be a mistake to become dogmatic about this, and here it saves us a lot of duplication to keep the scope of this selector’s use narrow.
As our items are only two to a row at this point, we can let Flexbox handle the margins between items in a row automatically using
justify-content space-between. If we could guarantee that our grid would always contain an amount of items perfectly divisible by the amount of items in a row, we could just use this technique and be done with it, but that isn’t the reality of client work; we’re aiming to build robust design systems rather than bespoke designs reliant on certain content.
As you expand the width of the viewport you’ll notice that there’s a margin-top bug at the exact viewport dimension that we go from a single item per row to two items per row; this is due to the somewhat limited implementation of Rupture in CodePen, and is not an issue when using this code in a project.
flex 0 1 49% gives us a margin equal to 2% of the container’s width, without using percentages to declare a margin value. We’ve matched that effective margin with a margin-top of an item with two or more items before it, which is what the selector at line #129 is doing. We’ve pre-empted the fact that we’ll be doing more with nth-child selectors and split the latter part of the selector into its own line.
At Omni Digital we try to avoid picking spacing values arbitrarily, and instead use values relative to other elements of the design. The common convention for using
10px and multiples/divisions of
10px is completely arbitrary, based on the fact that humans find 10 an easy number to work with, but it means nothing to how the end-user percieves the design. You can see that even in this limited example, we’re basing the items’ padding on the rendered height of a line of text, which means that as the
font-size of the
html element changes with media queries, all spacing changes relatively as well.
So where does the
margin-top value of
1.84vw come from? The grid container is 92% of the viewport width. Our margin between grid items is 2% of it’s container’s width. We can’t use a value of
2% for the
margin-top, as different browsers calculate that on a different basis, and that will change until a consensus is reached. So we’re taking
92vw, the width of the grid container, and dividing that by 50, to get the equivalent of the
2% horizontal margin. 92 / 50 = 1.84. Now our grid items’
margin-top matches the margin between grid items at any viewport size.
Now let’s go up to three grid items per row. We’ll now need to explicitly declare a margin between grid items:
Let’s look at what some of these more esoteric-looking selectors are doing.
:nth-child(3n+2) is selecting every third child, starting at the second child. The selector
:nth-child(n+4) is selecting every child apart from the first three children.
We’ve added a
auto to the last child, to ensure that if our last row contains only two items, Flexbox won’t push that last item to the right of the container. This is why we needed to declare an explicit
margin-left to grid items; without it, the last item would tough the item preceding it.
We now have a robust grid, without needing any template logic to output classnames specific to certain items. Let’s finish up by expanding the grid to four items per row on larger viewports:
Here we’ve simply repeated the same work carried out in the last step, but for viewports above 1680px.
Hopefully this is helpful for developers who need this functionality but also need to support browsers which don’t fully support the CSS Grid specification.
Future blog posts will explain our choices for frontend stack and frontend conventions, but feel free to ask any questions in the comments below.