Expanding Grid Cards with View Transitions

A friend showed me something of a design spec they were charged with pulling off:

The requirements:

… when I click on any of them it expands at the top to full width and the rest of the cards re-arrange themselves below it.

Like anything web design & development, there are a boatload of ways you could do this. Different layout strategies, different combinations of native technologies, different libraries to punt to, etc. I lean native first, so this was how my brain scripted the problem:

  1. Six cards on a fluid, flex-column CSS Grid. Let cards auto flow onto the grid.
  2. When you click a card (instant 😬), donk it at the top by order: -1; (another 😬)
  3. Make it full width regardless of columns via grid-column: 1 / -1;
  4. I suppose we could animate this “for free” FLIP-style with the View Transition API.

And so:

Video here. Note that only Chrome supports View Transitions. This video shows them, but the rest of it should work regardless of browser.

The “alternate look” shows off how a little :has() selector can go a long way in interactive fun!

The Grid

Pretty easy these days, so I’ll make it complicated just for fun:

.grid {
  display: grid;
  grid-template-columns: repeat(
    /* fluid columns, but max out at 3 */
    minmax(min(100%, max(10rem, 100%/4)), 1fr)
  gap: 2.5rem;
}Code language: SCSS (scss)

This incantation of grid-template-columns says:

  1. This grid is is fluid (the columns are of a fluid width)
  2. The number of columns is fluid, starting at just 1, which shrinks as narrow as it needs to (it’s not limited by some fixed minimum width)
  3. Only go up to 3 columns, not unlimited as you normally see
  4. Break into two columns at a reasonable middle ground

A bit complex, but a useful bit of code there that can be expressed without media queries. I’m sure I’ll come back to this blog post for that over and over.

The Move

To make sure the clicked card becomes the first one, I’ll toggle a class:

.card {
  &.featured {
    order: -1;
    grid-column: 1 / -1;
    font-size: 133%;
}Code language: SCSS (scss)
const cards = document.querySelectorAll(".card");

cards.forEach((card, i) => {
  card.addEventListener("click", () => {

function activateCard(card) {
  cards.forEach((card) => {
}Code language: JavaScript (javascript)

Animate It

This is essentially a tweening (sometimes called FLIP) animation where you don’t really care about the starting or ending state per se you just say: animate between them please.

The trick there is the View Transitions API, which means you just wrap any DOM manipulation in there and it’ll automatically animate. Here I’ll ensure the DOM changes even if the browser doesn’t have that API yet.

if (!document.startViewTransition) {
document.startViewTransition(() => {
});Code language: JavaScript (javascript)

That’s ridiculously simple. I love it.

I popped a close button on each card also which just de-features all cards, and calls the same API.

The Accessibility Situation

I’ve attached a click handler to a damn <div> here. That’s not right. A user using a screen reader won’t be able to activate the animation because the <div> is not interactive itself.

But… maybe that’s OK?

This is what you might call a “fidget spinner animation”. It doesn’t do anything important. No information is revealed. It’s just something you can click on for poops and giggles.

The second we reveal some additional information… then we’re in trouble here. Then we need to attach the activation to an interactive element, probably a <button>. And as soon as we do that then the fact that we’re changing the tabbing order by messing with order becomes problematic. Tabbing order is no longer intuitive when the 4th item can appear at the top yet send focus to the 5th item just because that’s where it is in the DOM. Perhaps you could fight that by setting tabindex values ourselves, but that seems like a can of worms. Honestly, if this was revealing extra information, I’d probably just not take this approach at all and find some other more suitable way.

Text selection is a minor concern. It’s a little annoying how you can select text and un-clicking after dragging activates the click and the text goes flying away from you. I say minor because the text remains selected so it’s not a 100% blocker.

Another form of accessibility is not making people nauseous, so might as well honor the preference for reduced motion. Easy as CSS:

@media (prefers-reduced-motion: reduce) {
  ::view-transition-new(*) {
    animation-duration: 0s;
}Code language: CSS (css)

Another little View Transitions Thing

A hamburger interaction where the lines of the hamburger fly out to become the borders of the navigation drawer. This one not only uses View Transitions but native CSS nesting too just for fun.


I work on CodePen! I'd highly suggest you have a PRO account on CodePen, as it buys you private Pens, media uploads, realtime collaboration, and more.

Get CodePen Pro

12 responses to “Expanding Grid Cards with View Transitions”

  1. Bruce B says:

    “A user using a screen reader won’t be able to activate the animation because the <div> is not interactive itself.”

    I am able to activate the animation using NVDA. When you navigate by heading, each heading reads as “[Heading text] clickable” and then hitting the Enter key does indeed visually shift that card to the top.

    I think you might be running into a WCAG SC 1.3.2 Meaningful Sequence issue here. Not all people who use screen readers are completely blind.

    • Chris Coyier says:

      Well that’s nice, in a way. Do you think it knows because there is a click handler on the parent div of the header?? that’d be wild.

      But it’s also a bummer in another way because it makes that tabindex issue a problem β€” right?

      • Bruce B says:

        Just verified that these are also clickable with JAWS, although JAWS does not announce them as “clickable”. And you can “click” on them when browsing any of the content in the card (not just the heading). tabindex doesn’t come into play here because there are no focusable elements. This is happening in browse mode.

        I’m assuming the screen reader sees that there is a click handler on the parent div that isn’t focusable and thus is fixing this potential mistake by allowing the user to click on it anyway.

  2. Ben says:

    It took some scrutiny to figure out that the reason it is limited to 3 columns when the calculation says “100% / 4”, implying four columns, is because of the gap. If you remove the gap, then there will be four columns.

    In case anyone else is confused.

  3. Curtis Wilcox says:

    You could use clamp() instead of max() inside of min(), right?

    grid-template-columns: repeat(
    // fluid columns, but max out at 3
    minmax(clamp(10rem, 100%, 100%/4), 1fr)

  4. John says:

    Any simple way to move to the top when clicking a lower card on narrow viewports?

    • Chris Coyier says:

      Could look into putting and ID on each of the cards and turning the buttons into anchor links that point to those IDs.

      Or keep the buttons and have JavaScript move the focus.

      I think moving focus is probably part of the solution, but if you for some reason only care about the scrolling, a scrollTo situation with behavior: smooth could be nice (and used in conjunction with focus movement probably)

    • Mike says:

      function activateCard(card) {
      cards.forEach((card) => {

      • Chris Coyier says:

        Could work as long as this component is at the top of the page! Otherwise linking to the ID of the newly-featured card I’d think would be better.

  5. Shane Anderson says:

    I would change it to have the expanded item go to the top of the row it is in rather than the top of all rows as it forces the user to have to scroll back up to see it.

    • Chris Coyier says:

      If I was building this “for real” with real content and real users and I knew exactly what the point was, I’d make all sorts of decisions to support that. With this context-free example, I was just focusing on what could be done with native tech.

Leave a Reply

Your email address will not be published. Required fields are marked *

Back to Top ⬆️