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:
- Six cards on a fluid, flex-column CSS Grid. Let cards
auto
flow onto the grid. - When you click a card (instant 😬), donk it at the top by
order: -1;
(another 😬) - Make it full width regardless of columns via
grid-column: 1 / -1;
- 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.
: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(
auto-fit,
/* 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:
- This grid is is fluid (the columns are of a fluid width)
- 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)
- Only go up to 3 columns, not unlimited as you normally see
- 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", () => {
activateCard(card);
});
});
function activateCard(card) {
cards.forEach((card) => {
card.classList.remove("featured");
});
card.classList.add("featured");
}
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) {
activateCard(card);
}
document.startViewTransition(() => {
activateCard(card);
});
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-old(*),
::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.
“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.
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?
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.
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.
Ah yeah the gap makes 3 not fit, keeping the math “easier” I suppose. Good clarification.
You could use
clamp()
instead ofmax()
inside ofmin()
, right?grid-template-columns: repeat(
auto-fit,
// fluid columns, but max out at 3
minmax(clamp(10rem, 100%, 100%/4), 1fr)
);
Any simple way to move to the top when clicking a lower card on narrow viewports?
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 withbehavior: smooth
could be nice (and used in conjunction with focus movement probably)function activateCard(card) {
cards.forEach((card) => {
card.classList.remove("featured");
window.location.href="#top";
});
card.classList.add("featured");
}
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.
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.
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.