DEV Community

Cover image for Nearly Accessible* ANIMATED Accordion: in pure CSS??? No way! 😱
GrahamTheDev
GrahamTheDev

Posted on • Updated on

Nearly Accessible* ANIMATED Accordion: in pure CSS??? No way! 😱

A few days ago I designed an accordion in 1 minute, 5 minutes and 10 minutes that was far more accessible than most! (perfect? not quite, but certainly usable by assistive tech users and keyboard only users).

But the thing that annoyed me was that I HAD to use JavaScript in order to make the animation work.

And we all know how much people love CSS only things (don't get me started on people who build their sites using React or Vue and then want CSS only stuff...)

Update

Please note, I made a couple of major blunders here, which, as someone who calls themselves an accessibility expert are embarrassing. Hence why the title now says "nearly accessible"...I was close, but failed!

However, I am happy to hold my hands up when I am wrong.

A few things to consider:

  1. This does not work in Firefox due to the use of :has. I am looking at refactoring the code so it does not rely on :has, but for now this means it is not fit for production without a polyfill (which kind of defeats the purpose as this is meant to be no JS!).
  2. Due to the use of links the behaviour may not be as expected when using a screen reader. I personally was aware, but I certainly did not put enough emphasis or thought into it. I am going to update the CodePen to account for this as much as possible.
  3. This pattern will update the browser history every time you open a section due to the use of fragments. This is not good UX! Thanks to @starkraving for pointing that out.

Because of this, I need to upgrade the advice I originally had (a last resort pattern) to a "do not use this pattern in production!".

Instead use this as a learning resource on CSS techniques you may find useful in some scenarios.

My apologies for missing some obvious things that I should have caught, I will double my efforts to ensure I do not mark something as accessible when I should know better.

Special thanks to @merri for setting me straight and taking the time to point out my blunders, it is appreciated!

I will update this article further as I rethink and improve things, I may be able to reclaim it as "accessible"...but I will certainly make sure I got it right this time if I do! 💗

Accordion Demo

Try it with your keyboard! Tab between sections, use up and down arrow keys for scrolling the scrollable sections (or on mobile just drag!) and you can even open all the sections with the checkbox!

Above all, have a poke around the CSS and HTML and see what you notice!

It may looks simple at first, but there is a lot going on here!

Here are the most interesting parts:

  • using the :target attribute to ensure only one item is open at a time.
  • using a CSS grid "hack" to allow us to have animated sections that can be any height (this is something that used to be difficult!)
  • Another hack (this time a real hack) to allow us to display scroll bars only if the content requires it, while not showing up as the section expands.
  • The use of prefers-reduced-motion to remove the animations using a simple CSS var pattern.
  • A simple @media print media query that this pattern supports and other accordion patterns don't to open all the panels for printing (and allow them to expand without height restriction so everything is printable)!
  • A checkbox to open and close all accordion sections if desired.

So, let's look at each of those parts!

The :target attribute

This attribute is an interesting one, in fact, it is the whole reason this accordion works at all!

It allows us to select something with CSS as long as it has the same ID as the hash at the end of the URL.

OK, so that might not make sense immediately, so let me explain.

Let's say we have a div with id="blue".

We can create a CSS selector as follows:

div{
  background-color: red;
}


div:target{
  background-color: blue;
}
Enter fullscreen mode Exit fullscreen mode

So when we load our page to it's default URL (e.g. example-site.com) that div will be red.

But, if we update our URL to be example-site.com*#blue* then that div will turn blue!

And there is a simple way to update the URL, we can use an anchor <a> tag and set the href equal to that ID (<a href="#blue">).

I have put that all together in a simple CodePen. Note that the JS is just to show the URL of the CodePen as you cannot see it. Pay attention to the end of the URL as you click on the links.

We can take advantage of this to make our accordion exclusive!

By assigning an ID to each of the accordion "sections", and then using an anchor (<a>) tag with that href, we now have a way of selecting just one section in CSS and not the others.

Then, if we click on a link that belongs to another section, the selector will target that section instead.

The relevant code from the demo (simplified) is as follows:

HTML

<a href="#acc1">Section 1 "Heading"</a>
<section class="content" id="acc1">
  <p> The content </p>
</section>

<!-- the `href` matches the `id` of the section below it. -->  
<a href="#acc2">Section 2 "Heading"</a>
<section class="content" id="acc2">
  <p> The content </p>
</section>
Enter fullscreen mode Exit fullscreen mode

CSS

.content{
  display: none;
}

.content:target{
  display: block   
}
Enter fullscreen mode Exit fullscreen mode

And putting that all together you should now hopefully understand the :target property behaviour!

One bonus to this!

There is another bonus to using fragment identifiers (the name for hashes at the end of URLs) that we update with an anchor element...it allows us to move the focus on the page!

When we add #acc1 to the URL the page jumps to the section with the same ID. This comes in useful in this instance as we may have a scrollable area that we want people to be able to scroll up and down with the arrow keys.

Moving focus there means that when the accordion opens, they can use the up and down arrow keys right away without having to Tab to the scrollable section.

A nice quick UX win!

CSS grid for height animation

If you have ever tried to animate the height of an item that isn't a fixed height...you will have experienced frustration and pain.

"Can't I just animate the height"...nope, that won't work?

"What about the max-height animation?" - kind of works, but animation times will vary depending on the height of each item and you end up with strange delays (as the whole max-height is animated, even if you only use a small part of it).

No, this used to be a problem that was only solvable properly with fixed heights or JavaScript.

But, grid is a thing now...and grid can finally give us what we need!

thankyou grid rows and visibility: hidden!

So what is the magic sauce for animated opening?

First of all: grid-template-rows: min-content 0fr

Wait...what does that mean?

  • grid-template-rows allows us to define how the rows in a grid should behave. Each space delimited entry corresponds to a row in order.
  • min-content means that this row should be as small as possible to show the content. If we used it on columns on a paragraph (instead of rows) then the longest word would end up being the width of the container and words would wrap accordingly. Think of it as "as small as possible" in the given direction!
  • 0fr is the last part, we are saying that for the second item in the DOM order, make it "zero fractions" (fr="fraction"). 0fr. This will make it as small as possible and only visible if it is visually showing.

And that last part is why we need visibility: hidden on whatever item we want to expand / collapse. That hides the item visually, but it still takes up space in the DOM.

So, as a last step, we have to do an overflow: hidden on it to make sure it collapses and margin: 0 and padding: 0 to make sure that the margins and padding do not take up space and it all works!

Now, before I show you that it works, we need to do one last thing, we need to animate the expansion of the element!

two last steps!

So the last thing we need to do is reverse the hiding and make the revealing item take up it's full height, then we can animate it!

So obviously, visibility: hidden needs to become visibility: visible so we can show the revealing item.

And then we need to change the parent so that we have:

grid-template-rows: min-content 1fr

With the change being 1fr (1 fraction) instead of 0fr. In this case that means "take up the remaining space". As our grid has no set height, this will become the total height of the element we want to reveal.

Now, we finally have the parts to animate it!

You see, even with the 0fr the height of the element was already calculated (which is different to that of max-height), so the browser knows what height to animate to...and this is what all of this jumping around with grids and fractions and template rows is for!

We can now animate grid-template-rows!

So to round us off, we need to apply a transition CSS property. In this example of 1 second.

transition: grid-template-rows 1s;
Enter fullscreen mode Exit fullscreen mode

Putting it all together!

So by combining the visibility-hidden, template-rows and transition we have a working opening section that will work at any height!

Check it out!

There is one nuance here you may have noticed, :has.

This is like saying "if the item inside :has appears inside this element, then select this item"

We need this so we can change the grid-template-rows on the parent easily.

.grid-rows:has(p:target) effectively says "select the element with the class grid-rows if there is a <p> element inside that matches the :target selector (that we discussed earlier in this article).".

This way we can toggle our grid-template-rows property when needed.

phew, that was a lot, let's look at some hacks now to make it more fun!

Scrollbars only showing after animation

As this is a hack, I won't go into as much detail, but I had a problem.

In the final demo, I restricted the height of the content section (so that the next accordion section is visible on screen, even on long content sections, for better UX).

Now the problem is that I am animating the height. So as the section expands, there are parts that will overflow and trigger scroll bars.

This would be fine, except that some sections in the demo accordion do not require scroll bars (as they are shorter than the max height I set).

This results in some horrible UI / UX where the scroll bars momentarily appear while the section expands and then disappear afterwards, as can be seen in the following GIF.

GIF showing a scroll bar appearing momentarily as a section expands and then disappearing once the section has fully expanded

Ewwwww...hate it!

Now, scroll bars are strange. If we start them off invisible (overflow-y: hidden) and then animate them to visible (overflow-y: auto), they disappear again once the animation is over.

So, I hacked it!

I set a very long animation duration and made them visible very early in the animation.

animation-duration: 1000s;
animation-name: show-scroll; 

@keyframes show-scroll {
    0% {
        overflow-y: hidden;
    }
    0.2% {
        overflow-y: auto;
    }
    to {
        overflow-y: auto;
    }
}

Enter fullscreen mode Exit fullscreen mode

Here we animate over 1000 seconds, but we change to the final state at 0.2% of the animation (2 seconds).

So after 16min 40 seconds, this scroll bar will disappear, but I think that is acceptable here (as acceptable as possible with a hack at least lol).

I am sure there is a way to achieve this, but sometimes you just have to ship to production with something that is 95% good enough!

Now we get scroll bars that are hidden initially, and then appear once the expanding animation has completed.

One final note: if someone did sit with the page open for 17 minutes and the scroll bars disappeared, they would reappear when they expand the section again and the section would still be scrollable. This is why I say this solution is "good enough" as that will be a rare occurrence and it self fixes on interaction!

prefers-reduced-motion the easy way

In my previous article about accordions I covered prefers-reduced-motion as well.

It means that for people who need reduced motion on a page (people who may become dizzy or ill through movement due to vestibular disorders for example) have a way of indicating that they would like less animation to us.

In the previous article I was using JavaScript to check for this media query. But as prefers-reduced-motion is designed for CSS primarily, it is waaaaay easier in CSS.

So what we want to do is this:

  • default to animations
  • if someone has prefers-reduced-motion: reduce set as a preference in their browser, then reduce or remove that animation.

Now here is the "trick" to make this easy.

set your animation durations as CSS properties.

That way, we only need to toggle the animation duration in one place and we can update it in multiple places.

Like this:

/* the ":root" element is essentially the "top level" or "global" place where you can define CSS properties etc.
:root {
    --open-duration: 0.5s;
}

/* we use our CSS var in place of a static duration. 
.some-item-to-animate{
  transition: grid-template-rows var(--open-duration);
}


/* we can use that same duration in multiple places
.some-other-item-to-animate{
  transition: grid-template-rows var(--open-duration);
}

/* we check for `prefers-reduced-motion` with a @media query and update the CSS variable if it matches.
@media (prefers-reduced-motion) {
    :root {
        --open-duration: 0s;
    }
}

Enter fullscreen mode Exit fullscreen mode

By setting the animation to "0s" that effectively switches the animation off!

Side note / tip

By using calc you can actually create a toggle for animations across a whole site from a single CSS variable.

I am not going to explain this fully as it is outside of the scope of this article, but the following CSS may help you understand how it works!

:root {
    --animations-on: 1; /* 1 = yes, 0 = no, we can toggle this with a media query like the previous example */
}

/* animation takes 2 seconds (assuming --animations-on = 1) */
.some-element-with-animations{
    --duration: 2s; /* local animation duration */
    transition: [item to transition] 
                calc(var(--duration) * var(--animations-on));
}

/* animation takes 4 seconds (assuming --animations-on = 1) */
.some-element-with-animations{
    --duration: 4s; /* local animation duration */
    transition: [item to transition] 
                calc(var(--duration) * var(--animations-on));
}
Enter fullscreen mode Exit fullscreen mode

Hopefully that little tip can give you some interesting ideas on how to allow user settings etc.

Expand all sections for printing with @media print

Another media query tip / trick...and one that works well with this pattern for an accordion over the <details> and <summary> one I shared in my previous article.

The @media print media query will apply styles only when someone prints the page (this includes "print to PDF" in case you are thinking nobody will ever print your page lol!)

All we need to do is apply the "open state" CSS to every single .content section in our demo

@media print {
    /* our parent item */
    .tota11y-accordion > li { 
        grid-template-rows: min-content 1fr;
    }

    /* the item that expands */
    .tota11y-accordion > li .content { 
        visibility: visible;
        margin: 0.5rem 1rem 2rem 1rem;
        padding: 0.5rem;
    /* in our demo we set a `max-height` on elements so they are scrollable sections. We do not want that in print so we set it back to `none` so the sections can expand indefinetely */
        max-height: none;
    }
}
Enter fullscreen mode Exit fullscreen mode

Read more about [media queries and the print media query on MDN (second item in "description" section)[https://developer.mozilla.org/en-US/docs/Web/CSS/@media#description]

Allow users to open and close all sections with a checkbox

One last "win" for this pattern over <details> and <summary> is that we can open and close all sections using just a checkbox.

We apply the exact same principles as those of out @media print CSS query, but this time attach it to the :checked state on a checkbox.

And one last thing, this time we do not change the max-height as we don't want to remove the scrolling sections this time!

#tota11y-open-close:checked ~ ul>li{
    grid-template-rows: min-content 1fr;
}

#tota11y-open-close:checked ~ ul>li>.content{
    visibility: visible;
}
Enter fullscreen mode Exit fullscreen mode

So we have a checkbox input (<input type="checkbox" id="tota11y-open-close">) with an ID oftota11y-open-close`.

We position it as a sibling (on the same level in the DOM) as the <ul> that contains each of our accordion sections.

This way we can use the tilde ~ operator to do some matching based on the :checked state (which is like a boolean, checked = true, not checked = false).

So we check the state first, then if it is checked, we select the sibling ul, and any li within it to toggle our grid-template-rows (the li are the parent items we discussed earlier in each accordion section).

We also need to update the content section (the child section discussed earlier). But this time notice we use the > operator?

That is so that we only select matches that are directly related to each other in the DOM order (any li must be a direct child of the ul and the .content section must be a direct child of the li).

This is just so that we don't "pollute" the .content section (as if we added a <div class="content"> within our original content section that would also get selected otherwise. It is an edge case, but it just makes it more robust.

One last thing to think about.

I just mentioned "polluting" other sections. What I mean is that many people apply styles in CSS that are not specific enough. Due to how CSS "Cascades" (the "C" part of "CSS"), this can sometimes result in styles "escaping" and affecting elements they should not.

To avoid this (so you can use this accordion in your own projects) I have scoped all of the selectors with .tota11y-accordion.

That means the styles cannot escape the surrounding div with that class.

That is a useful tip to remember, if you create a component, have a class on the outer most element that you include in all selectors. This will keep the styles scoped within that component and will make your CSS easier to maintain (even if it is a little more verbose!).

Oh and just have a quick look at the aria attributes I used in the demo again and have a read around those, it never hurts to level up your accessibility knowledge! 💪🏼💗

Wrapping Up!

There was a lot in that and I hope you learned some new tips and tricks for CSS.

As I said at the beginning, do not use this pattern in production unless you absolutely MUST avoid JavaScript. But also keep the pattern and techniques in mind as they may just save your bacon one day!

If you enjoyed this article, consider leaving a 💗 (or more emojis if you are feeling generous!) as it really helps!

And if you learned something new, then let me know in the comments, that would be amazing!

See you in the next one all of you beautiful people (and fellow monsters)! 💗

Top comments (22)

Collapse
 
thejase profile image
Jason Featheringham

Cool design! But I wouldn't consider this accessible without JS. Accessibility means giving everyone an equal experience, and showing and hiding lists without communicating that state to assistive tech users is not providing an equal experience. You could add JS to include ARIA states, but I don't recommend this.

Instead, please utilize the details/summary elements. They provide the accordion experience, but they are also accessible for assistive tech users.

Collapse
 
merri profile image
Vesa Piittinen

I would say please, remove the word "Accessible" from the title of this article. It is false advertising.

Collapse
 
grahamthedev profile image
GrahamTheDev

I consider this accessible, by the definition I use:

  • Does it pass all SC on WCAG 2.2
  • Can you access all the same information no matter what medium you are using to interact with the page (SR, eye gaze, keyboard, voice control).
  • Is the pattern easy to understand and familiar enough that people will be able to use it easily.

Those are my criteria for "accessible".

I made it very clear at the beginning of this article that there are better patterns, even linking to a better pattern in the very first sentence. But there is a valid edge case to use this pattern.

Thread Thread
 
merri profile image
Vesa Piittinen • Edited

Accessibility - or usability in general - isn't about meeting your criteria and definitions. It is about meeting the needs of others. It has to work for users and be understandable regardless how they experience it.

For some feedback:

It is annoying to use with a keyboard. You can't toggle the link again to hide the content. You can't open the accordion with a space bar which is a common expectation of an accordion.

It is hard to follow with a screen reader. Upon clicking a link you start hearing the target content but get no indication what this content relates to. Also, when going to a link you only hear that it is a link: no indication that it would open anything or if anything is open or closed.

On technical side this does not implement the accordion pattern.

I could be far less constructive about this, because I'm rather angry about what you have done here. I know that you wrote the code AND did not test it with a SR a single time. If you did you would have immediately caught a bug. You didn't even test it with multiple browsers! Is this really the level you want to suggest anything even as "edge case pattern"?

The fact is nobody should ever use this pattern. If JS is blocked you use details + summary. There is no reason to go for this. Nada.

Now, if you instead presented this as a technical exploration then I'd think this is cool. Because this is that: an attempt to push the boundaries, even if it falls short of being truly usable. That would be the right context and it would have given a good lesson for readers about the possiblities of CSS, and how and why it falls short.

Thread Thread
 
grahamthedev profile image
GrahamTheDev • Edited

Thank you for taking the time, it was really appreciated.

I agree with what you have said and have updated the the title a bit and added a large disclaimer.

The only part that I want to respond to is the screen reader part, I did test it there, but decided that the behaviour was expected when dealing with links and any attempt to fix that would only make things worse.

However, you have given me pause for thought on exposing some extra information to expose state. (I am thinking I can use some visually hidden text and have 2 links that I swap display: none on depending on state. Due to the behaviour of the links moving focus I do not think it will cause any issues, but I will test!)

I am happy to hold my hands up and say I failed here, but hopefully I can correct my mistakes. It is hard considering how long I have been at this to have missed so many obvious things (over exuberance combined with complacency on my part), but I hope you appreciate that I will work on improving them and will certainly be redoubling my efforts to not make such silly mistakes.

Thank you, once again, for taking the time, it is humbling sometimes when you miss some obvious stuff! 🙏🏼💗

Thread Thread
 
merri profile image
Vesa Piittinen

Thank you for taking the feedback well! The bug you have is that you have hash # in id. That is why aria-labelledby points nowhere, and does not announce the text in the link.

However even with that fix it still is a bit odd to use due to lacking state. You need a toggle, open/closed state. So using :target for accordions is like trying to use screwdriver on a nail. And this boils down to the basics of links vs buttons: links are for navigation, buttons for interaction. This is why details is better: summary element is a button-like (it removes all semantics inside it just as buttons do).

I guess the "best" use case you can make :target work with is some kind of multiple step form where you use Previous and Next links to move around. Then you would be using links as they are intended to be used: to navigate around. You'd even get the benefit of scrolling the content visible.

Collapse
 
rouilj profile image
John P. Rouillard

Very nice. I like the implementation. Opening all accordions on print and allowing the user to open all accordions is very nice.

One thing to note is that Firefox had limited/incomplete support for :has(). So most of the examples don't work as expected under Firefox 8-(. Hopefully, they will get their act together someday.

I expect this will work for Safari and any Chromium based browsers.

Collapse
 
grahamthedev profile image
GrahamTheDev

Oh wow, I somehow missed :has not working. in FF

Thanks for that, I am sure I can rework it and do it the hard way so it still works in Firefox.

But first I need to go check some production code as I had marked :has as "safe for use" at my side 😱💗

Collapse
 
rouilj profile image
John P. Rouillard

Yeah, it's caused me some issues as well.

connect.mozilla.org/t5/ideas/when-...

seems to indicate that firefox nightly (119) has things working, but it may still be behind a flag. I have the flag enabled: layout.css.has-selector.enabled (IIUC) in about:config with FF 118.0.1.

Also in 2022, :has() was changed to an unforgiving selector. It used to be forgiving up til 2022 or so.

css-tricks.com/has-is-an-unforgivi...

so that might be an issue as well.

Thread Thread
 
grahamthedev profile image
GrahamTheDev

Yeah, that I was aware of, lucking I am only using one selector here within the :has so that certainly won't be an issue.

Grrrr, CSS support has always been the most annoying part of development lol! 🤣💗

Collapse
 
starkraving profile image
Mike Ritchie • Edited

This is a nice dive into the challenges you encountered making a pure-CSS accordion, thanks for the article! I've used :target before for a pure-CSS modal (before <dialog> was introduced) and the only thing I didn't like about it was that it appended your interactions to the browser history. It's a bit of a break in UX when users would likely expect to hit the back button to go to the previous page, and instead the screen just "rewinds" their interactions with the component. I think there is a need for at least some progressive enhancement here, to hook into the History API and replace the current URL instead of append it.

Collapse
 
grahamthedev profile image
GrahamTheDev

Yeah, overall I was over zealous and got too excited with this pattern.

I literally just updated the article to say "do not use" and I am actually going to add an additional bullet point under the "why not" part to account for the browser history part, as that is another valid observation.

Thank you. 🙏🏼💗

Collapse
 
starkraving profile image
Mike Ritchie

Ahh, don’t be too hard on yourself, maybe it’s just a work in progress, so “do not use yet”.

Collapse
 
rajaniraiyn profile image
Rajaniraiyn R

This could have much better if you use details and summary tags. those gives semantics and makes it more accessible with less CSS and functionality

Collapse
 
grahamthedev profile image
GrahamTheDev

Sadly you cannot do the animation part CSS only using details summary and that was what I was trying to achieve in the article.

In the first sentence of the article I link to an accordion using details and summary though if you want to check one out built properly.

Collapse
 
rajaniraiyn profile image
Rajaniraiyn R

Yes, we cannot do animations you done with details and summary tags but we can use some other animations on them. In most of the cases they will be enough.

Collapse
 
z2lai profile image
z2lai

Lifesaver! Hacks are always needed in the real world and this looks to be a great one. I have a bunch of form sections which I need to make collapsible like details/summary HTML element but I dont have access to the markup and I don't think I can use jquery to move each section into a details/summary element because it recreates the section but breaks all the event listeners. Will have to try a CSS only hack like this.

Collapse
 
crystalzenyth profile image
Crystal Scott

Accordion is not WCAG conformant and does not follow accessible best practices.
Accordion buttons are coded as links.
Missing toggling aria-expanded attributes.
Missing aria-controls attributes.
Tabindex has been inappropriately added to non-actionable elements.

Please change the name - it is misleading.

Collapse
 
cezarytomczyk profile image
Cezary Tomczyk

I'd use details and summary HTML elements to get most accessible code.

Collapse
 
grahamthedev profile image
GrahamTheDev

New dev? Experienced Dev? If you learned something in this article, please do let me know, I always love hearing when people stumble across something new, it makes writing the articles worth it! 💪🏼💗

Collapse
 
kkm000 profile image
Cy "kkm" K'Nelson

I'm doing R&D and as far away from UI dev as likely possible, but I never miss a sweet article on the UI in the modern browser: A single .html file is a nice way to send an interactive presentation of your work. matplotlib has CVS output, which is also a plus, it can be embedded very nicely. And I'm simply a fan of aesthetic and functional UI. FWIW, this works is FF 119.0b4, with the flag layout.css.has-selector.enabled — probably, they aren't considering the has() support ready for prime time.

For nitpicking, a click on the only expanded opener arrow doesn't collapse it—I'd expect that, so it's a violation of The Least Surprise Principle in design. Also, such a click causes the whole thing to readjust vertically, scrolling the page and content.

Collapse
 
lexiebkm profile image
Alexander B.K.

Seeing good use of the :has pseudo class, I am annoyed why Firefox does not support it. I also see Firefox does not support CSS Houdini such as Typed OM and defining/declaring custom properties.