Overflowable Navigation
Responsive horizontal navigation solutions can be challenging when different amounts of content might exceed the available horizontal space. Six to ten links might fit fine on a large screen device, but then overflow significantly on small screen devices.
This post covers one approach to solving the problem, where links that would overflow are automatically moved to a dropdown list.
Parts
Overflowable navigation is composed of three important parts:
- the primary navigation list
- a dropdown component of some sort
- the secondary navigation list
A JSX component example might look something like:
function OverflowableNavigation() {
return (
<nav className="Overflowable">
<PrimaryNavigationList />
<Dropdown label="More">
<SecondaryNavigationList />
</Dropdown>
</nav>
);
}
Primary Navigation List
The primary list must include all navigation links, including ones that would overflow and be hidden. As the content is a list, and the order almost always matters with navigation, an <ol> is appropriate, with each link wrapped in its own <li>:
<ol class="PrimaryNavigationList">
<li>
<a href="...">...</a>
...
</li>
</ol>
Dropdown
A dropdown of some sort is needed. For purposes of brevity, I will use the popover API in a declarative web component:
<example-dropdown>
<template shadowrootmode="open">
<style>
:host {
display: flex;
}
[popover] {
inset: 35px 0 auto auto;
inset: anchor(bottom) anchor(right) auto auto;
margin: 0;
max-height: calc(100vh - 35px - 2rem);
max-height: calc(100vh - anchor-size(height) - 2rem);
position: fixed;
}
</style>
<button type="button" popovertarget="dropdown">More</button>
<div id="dropdown" popover>
<slot></slot>
</div>
</template>
</example-dropdown>
Between Declarative Shadow DOM (Baseline 2024), the Popover API (Basline 2024), and Anchor Positioning (not Baseline), this example is not ready to be used in production-facing applications. Building a production-grade dropdown component is outside the scope of this post.
You will need to replace it with one that's more suitable for your needs.
Secondary Navigation List
The secondary navigation list is similar to the primary navigation list in that the content is a list and the items are ordered. Unlike the primary list, the secondary list doesn't need to have all of the navigation links rendered.
The examples in this post will include the complete list of links in both primary and secondary navigation lists.
<ol class="SecondaryNavigationList">
<li>
<a href="...">...</a>
...
</li>
</ol>
Styles
This example uses some simple baseline styles to make the navigation look reasonable.
.Overflowable {
ol {
/* default list styles are removed */
list-style: none;
margin: 0;
padding: 0;
}
a {
/* block links are used to provide larger hit targets */
display: block;
outline-offset: -2px;
padding: 0.5em 1em;
}
}
In order for the overflowable navigation to resize fluidly, the primary navigation needs to be able to grow and shrink in order to accommodate any screen size, while the dropdown must remain anchored to the right side of the navigation.
This can be achieved with either grid or flexbox. Flexbox is used in this example:
.Overflowable {
display: flex;
}
.PrimaryNavigationList {
flex: 1 1 auto;
}
example-dropdown {
flex: 0 0 auto;
}
The items within the primary navigation list must use flexbox to control the alignment and prevent the items from growing or shrinking.
The secondary navigation list can be styled differently as desired, but this example shares the styles for both lists for simplicity:
.Overflowable {
ol {
display: flex;
flex-wrap: nowrap;
}
li {
flex: 0 0 auto;
}
}
The primary navigation list is horizontal, while the secondary list is vertical:
.PrimaryNavigationList {
flex-direction: row;
}
.SecondaryNavigationList {
flex-direction: column;
}
Hiding items that are overflowing
We'll use some JavaScript to determine which items are overflowing. For the JavaScript to work, the primary navigation list needs to hide overflowing content.
.PrimaryNavigationList {
overflow: hidden;
}
Additionally, we'll use the hidden attribute to mark items that are overflowing so that they're not visible. The default [hidden] styles use display: none to hide elements. Normally this is a good choice, but in this case the elements must continue to take up space in the layout.
To make hidden elements not render but continue to take up space, we can use visibility: hidden instead.
.PrimaryNavigationList {
[hidden] {
display: block;
visibility: hidden;
}
}
Hiding Unused Dropdown
If all of the nav items are fully visible, we won't need to render the dropdown at all.
This example uses a complex selector involving the :has() pseudo-class (Baseline 2023) to hide the dropdown. A similar result can be achieved via JavaScript if broader browser support is needed.
.Overflowable:not(:has(.PrimaryNavigationList [hidden])) example-dropdown {
/*
Hide the dropdown when its Overflowable container doesn't
have any hidden items (i.e. all items are visible) in the
primary nav list.
*/
visibility: hidden;
}
Scripts
Note: for brevity this example uses imperative JavaScript with direct DOM access, but the same effect can be achieved with minimal changes to be compatible with modern JavaScript frameworks.
To start we'll be using an IntersectionObserver. The IntersectionObserver will be used on the items in the primary navigation list to determine when they're no longer fully visible. IntersectionObserver is a performant way to determine when elements are intersecting with their containers. This avoids needing to use expensive checks during render loops, and instead an IntersectionObserver can queue up multiple changes into a single callback on the appropriate render frame after a change has occurred.
IntersectionObservers are created with two parameters:
- a callback that's called when the observer is triggered
- an options object to configure when the observer should trigger
const io = new IntersectionObserver(
(entries) => {
// callback
},
{
// options
}
);
IntersectionObserver Options
There are two options that we need to set in order for the IntersectionObserver to work correctly.
The root option is used to specify the element to use as the bounding box for the observer. In our case we need the .PrimaryNavigationList to be the bounding box.
The threshold option determines how much of observed elements must be intersecting with the bounding box to be considered "intersecting". The default value of 0 means that a single pixel is enough to be intersecting. Since we want the entire item to be fully visible, we need to use a value of 1 instead.
const primaryNavigationList = document.querySelector(".PrimaryNavigationList");
const io = new IntersectionObserver(
(entries) => {
// callback
},
{
root: primaryNavigationList,
threshold: 1,
}
);
IntersectionObserver Callback
The IntersectionObserver callback is called any time an item has changed its intersection status. The first parameter to the callback is an array of entries, with one entry for each element that had an intersection change since the last render frame. This allows changes to be batched.
Additionally, any time a new element is observed, an entry for the newly observed element is added to the queue, which allows the callback to automatically initialize the state for all elements.
In our callback, we want to update the hidden attributes on items in the primary and secondary navigation lists. To do this we need two pieces of information:
- a way to uniquely identify items in the list
- whether the item is intersecting or not
This update function looks up the list items by index. When the item is intersecting, the hidden attribute is removed from the primary list item and added to the secondary list item so that the item is visible in the primary list, but not the secondary list.
Note: In this example we will use the index of the items in the list. We could similarly use unique IDs,
data-*attributes, or aWeakMapto associate an identifier with each item in the list.Similarly—rather than updating the DOM directly—you might need to update state in a data store if you're building your navigation in a framework.
const primaryNavigationItems = document.querySelectorAll(
".PrimaryNavigationList > li"
);
const secondaryNavigationItems = document.querySelectorAll(
".SecondaryNavigationList > li"
);
function update(index, isIntersecting) {
const primaryTarget = primaryNavigationItems[index];
primaryTarget.toggleAttribute("hidden", !isIntersecting);
const secondaryTarget = secondaryNavigationItems[index];
secondaryTarget.toggleAttribute("hidden", isIntersecting);
}
In order to use this function, our IntersectionObserver callback will need to find each items' index and intersection states.
This can be done with a for loop, and referencing the target and isIntersecting properties of each entry.
The target property is the element that changed, and we can find its index within its parent element's children.
for (const { target, isIntersecting } of entries) {
const index = Array.from(target.parentElement.children).indexOf(target);
update(index, isIntersecting);
}
Observe Items
Finally, we need to observe each item in the primary navigation list with the intersection observer:
Array.from(primaryNavigationList.children).forEach((item) => io.observe(item));
Complete Example
Putting all of that together gives us:
<nav class="Overflowable">
<ol class="PrimaryNavigationList">
<li><a href="#lorem">Lorem</a></li>
<li><a href="#ipsum">Ipsum</a></li>
<li><a href="#dolor">Dolor</a></li>
<li><a href="#sit">Sit</a></li>
<li><a href="#amet">Amet</a></li>
<li><a href="#consectetur">Consectetur</a></li>
<li><a href="#adipiscing">Adipiscing</a></li>
<li><a href="#elit">Elit</a></li>
<li><a href="#donec">Donec</a></li>
<li><a href="#efficitur">Efficitur</a></li>
<li><a href="#orci">Orci</a></li>
<li><a href="#neque">Neque</a></li>
<li><a href="#elementum">Elementum</a></li>
<li><a href="#quis">Quis</a></li>
</ol>
<example-dropdown>
<template shadowrootmode="open">
<style>
:host {
display: flex;
}
[popover] {
inset: 35px 0 auto auto;
inset: anchor(bottom) anchor(right) auto auto;
margin: 0;
max-height: calc(100vh - 35px - 2rem);
max-height: calc(100vh - anchor-size(height) - 2rem);
position: fixed;
}
</style>
<button type="button" popovertarget="dropdown">More</button>
<div id="dropdown" popover>
<slot></slot>
</div>
</template>
<ol class="SecondaryNavigationList">
<li><a href="#lorem">Lorem</a></li>
<li><a href="#ipsum">Ipsum</a></li>
<li><a href="#dolor">Dolor</a></li>
<li><a href="#sit">Sit</a></li>
<li><a href="#amet">Amet</a></li>
<li><a href="#consectetur">Consectetur</a></li>
<li><a href="#adipiscing">Adipiscing</a></li>
<li><a href="#elit">Elit</a></li>
<li><a href="#donec">Donec</a></li>
<li><a href="#efficitur">Efficitur</a></li>
<li><a href="#orci">Orci</a></li>
<li><a href="#neque">Neque</a></li>
<li><a href="#elementum">Elementum</a></li>
<li><a href="#quis">Quis</a></li>
</ol>
</example-dropdown>
</nav>
.Overflowable {
display: flex;
ol {
display: flex;
flex-wrap: nowrap;
list-style: none;
margin: 0;
padding: 0;
}
li {
flex: 0 0 auto;
}
a {
display: block;
outline-offset: -2px;
padding: 0.5em 1em;
}
&:not(:has(.PrimaryNavigationList [hidden])) example-dropdown {
visibility: hidden;
}
}
.PrimaryNavigationList {
flex: 1 1 auto;
flex-direction: row;
overflow: hidden;
[hidden] {
display: block;
visibility: hidden;
}
}
.SecondaryNavigationList {
flex-direction: column;
}
example-dropdown {
flex: 0 0 auto;
}
const primaryNavigationList = document.querySelector(".PrimaryNavigationList");
const io = new IntersectionObserver(
(entries) => {
for (const { target, isIntersecting } of entries) {
const index = Array.from(target.parentElement.children).indexOf(target);
update(index, isIntersecting);
}
},
{
root: primaryNavigationList,
threshold: 1,
}
);
Array.from(primaryNavigationList.children).forEach((item) => io.observe(item));
const primaryNavigationItems = document.querySelectorAll(
".PrimaryNavigationList > li"
);
const secondaryNavigationItems = document.querySelectorAll(
".SecondaryNavigationList > li"
);
function update(index, isIntersecting) {
const primaryTarget = primaryNavigationItems[index];
primaryTarget.toggleAttribute("hidden", !isIntersecting);
const secondaryTarget = secondaryNavigationItems[index];
secondaryTarget.toggleAttribute("hidden", isIntersecting);
}
