This content originally appeared on Go Make Things and was authored by Go Make Things
I’m currently working on the navigation patterns for Kelp, my UI library for people who love HTML.
The bane of my existence right now is trying to figure out the right way to implement subnav/dropdown menus that work for everyone.
Today, I want to talk about why this is still such a difficult thing to accomplish in 2025. Let’s dig in!
The web is for everyone
If you’re not sure what I mean by dropdown menu (it can mean many things to many people), I’m specifically referring here to a navigation menu (usually in the header) where one item in the list has an expand/collapse section with addition links.
<nav class="navbar">
<a class="logo" href="/">Kelp</a>
<ul>
<li><a href="/about">About</a></li>
<li>
<a href="/services">Services</a>
<ul>
<li><a href="/consulting">Consulting</a></li>
<li><a href="/coaching">Coaching</a></li>
<li><a href="/courses">Courses</a></li>
</ul>
</li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
Here, I would want the list of items below Services
to be hidden by default, with the ability to expand them into a floating container by clicking a button or icon.
I don’t love this pattern, but it’s used all the time by clients, so I feel strongly that Kelp needs to support it.
One of Kelp’s guiding principles is that the web is for everyone.
That means that if JavaScript fails or they’re on a slow connection and it hasn’t loaded it, the menu should still work in some way. But figuring out what that way should be has been… challenging.
Let’s look at our options!
The JavaScript-enhanced experience
Right off the bat, let’s talk about what the “correct” way to handle this in the perfect JavaScript-always-loads-and-we-never-have-to-worry-about-failures-or-slow-connections world looks like.
This is a disclosure pattern.
That means we need a <button>
with an aria-expanded
attribute whose value is true
when the associated content is visible, and false
when it’s not.
I often see folks keep the top-level link and use just an icon for the button, but I personally like and expect the whole thing to be the trigger.
<li>
<button
id="services-trigger"
aria-expanded="false"
aria-controls="services"
>
Services
</button>
<ul
id="services"
aria-labelledby="services-trigger"
hidden
>
<li><a href="/services">Overview</a></li>
<li><a href="/consulting">Consulting</a></li>
<li><a href="/coaching">Coaching</a></li>
<li><a href="/courses">Courses</a></li>
</ul>
</li>
Then, you’d use some JavaScript to expand/collapse the content when the button is clicked, and some CSS to position it.
const navbar = document.querySelector('.navbar');
navbar.addEventListener('click', (event) => {
// Only run if it's a dropdown menu
const controls = event.target.getAttribute('aria-controls');
if (!controls) return;
// Get the associated content
const content = navbar.querySelector(`#${controls}`);
if (!content) return;
// Show or hide the content
const hideContent = event.target.getAttribute('aria-expanded') === 'true';
content.toggleAttribute('hidden', hideContent);
// Update aria-expanded
event.target.setAttribute('aria-expanded', !hideContent);
});
The problem with this approach, of course, is that until the JavaScript loads, the navigation is useless.
If the JS fails—because you messed up the path of there’s a bug in the code or the user is on a slow connection and the file times out or it just hasn’t loaded yet—they can’t access the content.
Let’s look at some ways you could address this.
<details>
and <summary>
The <details>
and <summary>
elements create a disclosure pattern, kind of like the one we just talked about it!
For a hot minute, companies like GitHub where using it to create JavaScript-free dropdown menus, like this…
<li>
<details>
<summary>Services</summary>
<ul>
<li><a href="/services">Overview</a></li>
<li><a href="/consulting">Consulting</a></li>
<li><a href="/coaching">Coaching</a></li>
<li><a href="/courses">Courses</a></li>
</ul>
</details>
</li>
You can use CSS to style the content as a floating dropdown menu, and this works whether JavaScript has loaded or not.
But as Scott O’Hara shares, this behaves like a disclosure pattern, but doesn’t expose the same roles and behaviors. Years ago, Scott told me doing this is “technically accessible” but not “functionally accessible” as a result…
The way the elements are announced in all combinations conveys “click this thing to learn more.” It doesn’t inherently imply, “click this thing to see more navigation items,” which can lead to confusion for some users.
My friend Melanie Sumner ran some tests, and also recommends against this approach for similar reasons…
You can then press TAB (or VO + TAB in Safari) to navigate to the next interactive element, which in this case would be the “Home” link and would be read (in most cases) like, “list with three items. home, link.”
Technically this works, even if it’s a bit confusing to use with a screen-reader. I expected a disclosure widget and now I’m in a list with links?
Deep sigh…
What’s next?
The Popover API
I got really excited when I started checking out the Popover API.
It let’s you create a disclosure pattern with the correct semantics using only HTML. Apply the [popover]
attribute to the content, and [popovertarget]
with the ID of the target as it’s value to the <button>
.
<li>
<button
popovertarget="services"
>
Services
</button>
<ul
id="services"
popover
>
<li><a href="/services">Overview</a></li>
<li><a href="/consulting">Consulting</a></li>
<li><a href="/coaching">Coaching</a></li>
<li><a href="/courses">Courses</a></li>
</ul>
</li>
The Popover API handles a lot of the accessibility needs under-the-hood.
It exposes an [aria-expanded]
state on the <button>
automatically. It links the [popover]
content and <button>
together automatically. It shifts focus into the [popover]
content. If you click outside of the [popover]
content or press the ESC
key, it closes it, and shifts focus back to the button if needed.
Support isn’t quite great yet at 85%, but I figured this would be a great middle-ground of progressive enhancement.
Wrap it in a web component that adds support with JS if needed, but otherwise let’s the browser do it’s thing.
Except…
Styling sucks
The [popover]
attribute adds content to the top layer (like with the <dialog>
element).
By default, the content floats in the middle of the page. If you try to style it relative to it’s trigger, using position: absolute
, it… doesn’t work.
Fixing this currently means using JavaScript to calculate exactly where on the page to position it relative to it’s trigger.
In the future, CSS Anchor Positioning will fix this, but that doesn’t work in Firefox at all, and has just 68% browser support across the board.
What else can we do?
Dedicated subpage with links
Remember this starting HTML?
<li>
<a href="/services">Services</a>
<ul>
<li><a href="/consulting">Consulting</a></li>
<li><a href="/coaching">Coaching</a></li>
<li><a href="/courses">Courses</a></li>
</ul>
</li>
We could hide the nested <ul>
with CSS…
.navbar li ul {
display: none;
}
And enhance it into a proper JavaScript-driven disclosure component when the JS loads.
Without JS, users can still get to the parent /services
page. But, that page needs to have links (probably at the top of the page) to those various subpages so that users without JS functionality can still get to them.
This is how I used to build these types of components a decade ago with jQuery.
What I hate about this approach is that it relies too much on the client remembering to do a thing. If they make changes to the nav or page structure, or a developer who’s still learning makes updates after I leave, there are no guarantees they’ll remember to also update those subnav links on the parent page.
Heck, even I, with my ADHD, am likely to forget to to do that!
Too many chances for human failure with this approach.
Hoist the nav
One other approach I picked up from Brad Frost ages ago is to forgo the subnav entirely as your baseline.
Instead, you create an anchor link for that content, and point to the full list down in the footer of the page…
<!-- Up in the header .navbar -->
<li>
<a href="#services-list">
Services
</a>
</li>
<!-- Down in the footer somewhere -->
<ul id="services-list">
<li><a href="/consulting">Consulting</a></li>
<li><a href="/coaching">Coaching</a></li>
<li><a href="/courses">Courses</a></li>
</ul>
When JavaScript loads, it grabs the anchor content and moves or copies it into the <li>
after the anchor link. Then it transforms that anchor link into a <button>
and sets up a functional disclosure pattern.
This reduces the likelihood that the client will forget to update something somewhere, because there’s just one place where that subnav of items lives, and it automatically renders everywhere.
I like this better than dedicated subpage approach, but I don’t love it.
Should the list get copied or relocated entirely? Where in the footer does it live? How do you make it look good in a no-JS state, without making the footer look empty if you move out later?
What’s the right approach?
The way I see it, there are four possible ways I could handle this in Kelp:
- Use
<details>
and<summary>
as the fallback. Enhance them into a “real” disclosure pattern after JavaScript loads. - Use
<details>
and<summary>
. Accept that it will absolutely work for folks, but might create some unexpected semantics for screen reader users. - Use
[popover]
as the fallback. Enhance it into a “real” disclosure pattern after JavaScript loads. Deal with the awkward menu placement. Maybe someday ditch the JavaScript once browser support and CSS catch up. - Make folks put the content in the footer, and hoist it on page load (with web component options for copying or moving it).
What would your preference be?
I’m currently leaning towards options 1 or 2. Weighing the various considerations and client-updating-a-site-on-their-own scenarios, I think the semantic trade-offs are maybe acceptable as protection against unusable nav elements.
But I’m also not a regular screen reader user. If you are, I’d love to hear from you in particular about how confusing it would be to encounter <details>
and <summary>
as a nav menu.
And if you’re not, I’d still love to hear what you’d like to see implemented in Kelp!
Like this? A Lean Web Club membership is the best way to support my work and help me create more free content.
This content originally appeared on Go Make Things and was authored by Go Make Things