Expandable Sections Within a CSS Grid

Avatar of Kev Bonett
Kev Bonett on (Updated on )

Take your JavaScript to the next level at Frontend Masters.

I love CSS Grid. I love how, with just a few lines of code, we can achieve fully responsive grid layouts, often without any media queries at all. I’m quite comfortable wrangling CSS Grid to produce interesting layouts, while keeping the HTML markup clean and simple.

But recently, I was presented with a unique UI conundrum to solve. Essentially, any given grid cell could have a button that would open up another, larger area that is also part of the grid. But this new larger grid cell needed to be:

  1. right below the cell that opened it, and
  2. full width.

Turns out there is a nice solution to it, and in the spirit of CSS Grid itself, it only involves a couple of lines of code. In this article, I’ll combine three one-line CSS Grid “tricks” to solve this. No JavaScript needed at all.

An explanation of the actual problem I need to solve

Here’s a minimalist UI example of what I needed to do:

This is our actual product card grid, as rendered in our Storybook component library:

A grid of product cards in a three by two layout. Each card has a placeholder gray image, product name, descriptions, price, and small text.

Each product card needed a new “quick view” button added such that, when clicked, it would:

  • dynamically “inject” a new full-width card (containing more detailed product information) immediately below the product card that was clicked,
  • without disrupting the existing card grid (i.e. retain the DOM source order and the visual order of the rendered cards in the browser), and
  • still be fully responsive.

Hmmm… was this even possible with our current CSS Grid implementation?

Surely I would need to resort to JavaScript to re-calculate the card positions, and move them around, especially on browser resize? Right?

Google was not my friend. I couldn’t find anything to help me. Even a search of “quick view” implementations only resulted in examples that used modals or overlays to render the injected card. After all, a modal is usually the only choice in situations like this, as it focuses the user on the new content, without needing to disrupt the rest of the page.

I slept on the problem, and ultimately came to a workable solution by combining some of CSS Grid’s most powerful and useful features.

CSS Grid Trick #1

I was already employing the first trick for our default grid system, and the product card grid is a specific instance of that approach. Here’s some (simplified) code:

.grid {
  display: grid;
  gap: 1rem;
  grid-template-columns: repeat(auto-fit, 20rem);
}

The “secret sauce” in this code is the grid-template-columns: repeat(auto-fit, 20rem); which gives us a grid with columns (20rem wide in this example) that are arranged automatically in the available space, wrapping to the next row when there’s not enough room.

Curious about auto-fit vs auto-fill? Sara Soueidan has written a wonderful explanation of how this works. Sara also explains how you can incorporate minmax() to enable the column widths to “flex” but, for the purposes of this article, I wanted to define fixed column widths for simplicity.

CSS Grid Trick #2

Next, I had to accommodate a new full-width card into the grid:

.fullwidth {
  grid-column: 1 / -1;
}

This code works because grid-template-columns in trick #1 creates an “explicit” grid, so it’s possible to define start and end columns for the .fullwidth card, where 1 / -1 means “start in column 1, and span every column up to the very last one.”

Great. A full-width card injected into the grid. But… now we have gaps above the full-width card.

Two rows of four rectangles. All of the rectangles are light gray and numbered, except one that has a wheat-colored background and another box beneath it containing text, and taking up the full container width.

CSS Grid Trick #3

Filling the gaps — I’ve done this before with a faux-masonry approach:

.grid {
  grid-auto-flow: dense;
}

That’s it! Required layout achieved.

The grid-auto-flow property controls how the CSS Grid auto-placement algorithm works. In this case, the dense packing algorithm tries to fills in holes earlier in the grid.

  • All our grid columns are the same width. Dense packing also works if the column widths are flexible, for example, by using minmax(20rem, 1f).
  • All our grid “cells” are the same height in each row. This is the default CSS Grid behavior. The grid container implicitly has align-items: stretch causing cells to occupy 100% of the available row height.

The result of all this is that the holes in our grid are filled — and the beautiful part is that the original source order is preserved in the rendered output. This is important from an accessibility perspective.

See MDN for a complete explanation of how CSS Grid auto-placement works.

The complete solution

These three combined tricks provide a simple layout solution that requires very little CSS. No media queries, and no JavaScript needed.

But… we do still need JavaScript?

Yes, we do. But not for any layout calculations. It is purely functional for managing the click events, focus state, injected card display, etc.

For demo purposes in the prototype, the full-width cards have been hard-coded in the HTML in their correct locations in the DOM, and the JavaScript simply toggles their display properties.

In a production environment, however, the injected card would probably be fetched with JavaScript and placed in the correct location. Grid layouts for something like products on an eCommerce site tend to have very heavy DOMs, and we want to avoid unnecessarily bloating the page weight further with lots of additional “hidden” content.

Quick views should be considered as a progressive enhancement, so if JavaScript fails to load, the user is simply taken to the appropriate product details page.

Accessibility considerations

I’m passionate about using correct semantic HTML markup, adding aria- properties when absolutely necessary, and ensuring the UI works with just a keyboard as well as in a screen reader.

So, here’s a rundown of the considerations that went into making this pattern as accessible as possible:

  • The product card grid uses a <ul><li> construct because we’re displaying a list of products. Assistive technologies (e.g. screen readers) will therefore understand that there’s a relationship between the cards, and users will be informed how many items are in the list.
  • The product cards themselves are <article> elements, with proper headings, etc.
  • The HTML source order is preserved for the cards when the .fullwidth card is injected, providing a good natural tab order into the injected content, and out again to the next card.
  • The whole card grid is wrapped in an aria-live region so that DOM changes are announced to screen readers.
  • Focus management ensures that the injected card receives keyboard focus, and on closing the card, keyboard focus is returned to the button that originally triggered the card’s visibility.

Although it isn’t demonstrated in the prototype, these additional enhancements could be added to any production implementation:

  • Ensure the injected card, when focused, has an appropriate label. This could be as simple as having a heading as the first element inside the content.
  • Bind the ESC key to close the injected card.
  • Scroll the browser window so that the injected card is fully visible inside the viewport.

Wrapping up

So, what do you think?

This could be a nice alternative to modals for when we want to reveal additional content, but without hijacking the entire viewport in the process. This might be interesting in other situations as well — think photo captions in an image grid, helper text, etc. It might even be an alternative to some cases where we’d normally reach for <details>/<summary> (as we know those are only best used in certain contexts).

Anyway, I’m interested in how you might use this, or even how you might approach it differently. Let me know in the comments!

Updates

Firstly, I’m really glad that this article has proved helpful to other front-end developers. I knew I couldn’t have been the only one to face a similar conundrum.

Secondly, following some constructive feedback, I’ve added a strikethrough to some of the specific accessibility considerations above, and updated my CodePen demo with the following changes:

  • There is no need for the card grid to be wrapped in an aria-live region. Instead, I have made the quick view open and close buttons behave as “toggle” buttons, with appropriate aria-expanded and aria-controls attributes. I do use this pattern for disclosure widgets (show/hide, tabs, accordions) but in this case, I was imagining a behavior more similar to a modal interface, albeit inline rather than an overlay. (Thanks to Adrian for the tip!)
  • I am no longer programatically focusing on the injected card. Instead, I simply add a tabindex="0" so a keyboard user can choose whether or not to move to the injected card, or they can simply close the “toggle” button again.
  • I still believe that using a <ul><li> construct for the grid is a suitable approach for a list of product cards. The afforded semantics indicate an explicit relationship between the cards.