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:
- right below the cell that opened it, and
- 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:
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.
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 appropriatearia-expanded
andaria-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.
Holy cow I just finished something exactly like that with grid… in a more some-media-query-with-some-negative-margin—and-some-scss-function kind of way. It works but it’s not that clean. I’ll give it a try…
Good luck! It’s the
auto-fit
part of trick #1 that allows this grid layout to work without media queries.Great article Kev. Funnily enough I was looking at doing something similar on a new PLP – so this is extremely useful!
Thanks… glad I could be of some use! ;)
That’s a neat solution! Thanks a lot.
Regarding the use of
article
elements with proper headings, etc, the spec noted that:Therefore, there might be more suitable elements for marking up the grid.
Thanks Ian.
With regards the
<ul><li>
construct, I’ve updated the article.We use
<article>
elements for all our card components (product/news/etc) as they can be used or syndicated in other contexts. Yes, it’s a shame if the semantics of the card may not be conveyed correctly, but I feel the semantics of the list is more important for this use case.Really good article! Bookmarked for future reference.
Fair play. That’s pretty cool.
The CSS grid layout approach is nifty. The reading and focus order are both critical to keep in mind, and I am very glad you are thinking about that.
However, there are some accessibility bits that need some adjustment:
region
role from the wrapper since it does not expose itself to screen readers without an accessible name;aria-expanded
attribute to convey their state, which also means they can act as the toggle, which also means you do not need the close button, which also means you do not need to manage focus (since your reading order is good).I forked your pen to show all this in action:
I only did quick testing in JAWS 2021 / Chrome 94, because it is late, but will tweak it after I walk through it more (and update it with notes).
Thanks Adrian for your expert feedback. I have now updated the article and Codepen example to use “toggle” buttons and remove the live region.
When I initially prototyped this solution, I was thinking of it as an “inline modal”, so did not consider adopting a “disclosure widget” pattern, which I already use elsewhere in our UI for tabs/accordions/etc.
I’m still keeping the “only show 1 injected card at a time” behaviour… it’s a business requirement. As is the “close” button inside the injected card, which will contain lots of product details and other interactive elements (e.g. share & save buttons).
Kev, you were on the right track but without being a screen reader user / tester it can be hard to know some of the gotchas.
Keeping the close button makes sense in the context you show. I only removed it to demonstrate the utility of the
aria-expanded
control.Still a fan of the grid approach and am looking forward to have (force) a reason to use it. Thanks!
I see
li {list-style-type: none; }
in the CSS, what reportedly strips out such semantics for some ATs. [1]Isn’t it recommended to use explicit
<ul role="list">
in this case?[1] https://www.scottohara.me/blog/2019/01/12/lists-and-safari.html
Thanks for posting that article on list semantics. I wasn’t aware of this specific Voice Over / Safari “bug”.
I must admit I’m reluctant to add
role="list"
to a real list to appease 1 screen reader. ;)IMHO, if a screen reader doesn’t announce a “list of N items”, I still think there are sufficient semantics in the markup to help users navigate the products.
Oh… the “joys” of being a front-end dev!!!
Do not add the
list
role by default. As I am quoted saying in the linked post, the lack of list semantics “may not be a big deal unless user testing says you really need a list.” Often it does not.Wow! Just wow! I built exactly this functionality back in the days with a huge amount of JavaScript. https://yspisfy.com
…next time, I will use this approach. Thank you for thinking for us all!
Thanks… I honestly thought I would have to throw a ton of JS at this to make it work too! This just shows how awesome CSS Grid is!
Thank you for mentioning default properties of the grid. And thanks for illustrating how the grid changes with each alterations of your code. Very easy to understand without having to visualise things in my head.
Thanks. I really have to give credit to the amazing devs who’ve documented in detail how CSS Grid works… both here on CSS Tricks and MDN.
I did this very same kind of display using JS without grid or flexbox, and while it worked and looks nice, what a pain it was to implement.
See:
http://portal2web.biz/webs/a15_sfcb/our-brands/
I had to continuously add or remove classes to the elements when the browser was resized so as to know which items constituted a row, then had to inject an object to hold the content, removing it once closed, etc.
I experimented with CSS Grid but only got so far, even asking about it on Reddit’s /r/web_dev board, without any success from that quarter.
Thank you very much for making this very subtle solution that just works, and thank you for sharing it. Really made my day!
You’re most welcome! Like you, I Google’d but didn’t find an answer. I’m honestly surprised that combining 3 “tricks” solved the problem!
Nice! Just what i need. Is there a way how to stretch grid items to fill the horizontal space? Thank you.
OK, i think i got it ;)
grid-template-columns: repeat(auto-fill, minmax(20em, 1fr));
This is gold thanks
Nice writeup. I’ve had a client’s desiner ask for this exact thing, and I couldn’t think of a nice way to do it (that didn’t involve a lot of layout JS) so we had to go with a totally different approach – a modal. And I like to think I’m pretty good with CSS grid! D’oh. I’ll keep this up my sleeve.
Thanks a lot!!
Awesome and very useful, this article expands my brain :-)
As a confessing JavaScript avoider I always prefer solutions that are completely JS free. And – it is possible without too much invisible overhead. My solution involves a bunch of invisible radio-buttons, some labels with the ‘for’-attribute set and CSS’s sibling selector. Anyone interested?
I also avoid JavaScript where appropriate, reaching for it as a progressive enhancement.
Quick views are normally rendered in modals. JavaScript is required for these.
In this specific “inline” implementation, the ARIA disclosure pattern fits the bill, and requires a tiny amount of JavaScript to toggle
aria-expanded
and managefocus()
.I’ve seen radio buttons used for disclosure widgets (e.g. tabs) before, but I feel that the feedback in a screen reader is not optimal. Having feedback that explicitly states that a button is expanded is better, IMHO.
I recommend against this. Assistive technology (AT) users tend to rely on controls doing certain things. A radio button is for submitting information on a form, not for toggling visibility. This can make for confusing controls for voice and screen reader users.
A disclosure widget conveys its state (expanded or collapsed). A checkbox only identifies if it is checked or not.
JavaScript is necessary for conveying states and is a valid of JS even when you can avoid it everywhere else.
Thanks for the great an article
I am curious where can I find the pen for the product card grid you mentioned
The screenshot of the Storybook product card grid is from a private repo at work.
It’s a “before” screenshot, showing our existing product grid prior to adding the “quick view” functionality.
For the purposes of the Codepen prototype linked in the article, I simplified the “cards” so that the focus would be on the CSS Grid behaviour, and the “toggling” of the injected quick view card.
I know this is primarily about using grid. But since your actual cards (and possibly your quick views) contain images, it would have been interesting to see them included in the examples. Given the visual card layout, where do images go in the source order?
Good question.
We have a number of “card” components in our Storybook library.
Product cards are quite complex, and have this general structure:
Images are first in source order, but they’re NOT links.
The more I learn about grid layout possibilities, the more I get amazed, thanks for your demo.
I noticed in the javascript part that you don’t close brackets for data attribute querySelector (on line 2, 42 and 60).
Is this just a typo or am I unaware of the interest of this ?
Ah, good spot!
Typos… all fixed now. :)
Hello,
Can this approach be used to implement tabbed interface where clicking on a tab opens the relevant content below?
The screen needs to have one of the tabs opened initially.
In my opinion, I don’t think tabbed interfaces are a good match for this CSS Grid implementation.
If JavaScript is disabled or “broken”, a tabbed interface should show all the content that would otherwise be hidden away. I’m not sure how you would handle this if the layout was managed by Grid? Would all the content blocks be rendered one after the other in the grid?
Secondly, on smaller screens, what would happen to the tabs that come after the content that is being displayed? They would be rendered below the content. That is not how tabbed interfaces should work.
This second point leads to another interesting question… how should tabbed interfaces be rendered on small screens?
I adopt the same solution described by Hayden Pickering in his Inclusive Components. I turn the tabs into an accordion.
Thank you and congratulations for this superb article which answers a question I was asking myself.
Question: would it be possible to do without the BUTTON by opening and closing the zoom only by clicking on the LI?
code example …
…
In advance Thank you
Pascal
Thanks. I’m glad you found the article useful.
To answer your question…
I wouldn’t recommend making the
<li>
clickable to open the quick view. You would need to implement all the native browser behaviours and accessibility that the<button>
element has. For example, keyboard focus, binding thespacebar
andenter
key to trigger the action, ARIArole="button"
, etc.There are many articles on the web that explain why using a native
<button>
is always the best approach in situations like this.Also, in my specific implementation, I have a grid of product cards, where each card already contains multiple
<a>
or<button>
elements (which either link to the product page, or save/share the product). Therefore, I had to add an extra<button>
to specifically trigger the quick view behaviour.FYI if you change the display property of a list element to grid/flex many screenreaders will no longer honour the list semantics.
You would need to restore them again eg
role="list"
androle="listitem"
Hi Grace… thanks for your comment. This has already been discussed in previous comments – see https://css-tricks.com/expandable-sections-within-a-css-grid/#comment-1783940
This is amazing. I was searching high and low for a way to combine a responsive grid with an accordion type layout exactly as you have, and finally I got the right combo of search terms to find this. Turns out I was missing just one line – grid-auto-flow:dense. I even asked chatgpt for help and it couldn’t get it right!