Overflowing menus and IntersectionObserver
tl;dr A responsive horizontal menu whose items will not overflow. Instead, overflowing items are dynamically tucked away into a dropdown menu item.
Overview
We designed our secondary navigation so as to dynamically display as many navigation items as can fit horizonally across the page, space permitting. Those that cannot fit are instead neatly tucked away into a “more” dropdown. I’ve noticed that a few other sites now have similar approaches, and thought I’d provide an overview of a possible implemententation.
This all works by using the Intersection Observer API.
Example
Quickly jump to a live demo before diving in.
Implementation
First, a simple structure to house our navigation. You’ll note that the more dropdown item is present, although it is hidden.
<nav>
<ul class="menu">
<li><a href="#">Home</a></li>
<li><a href="#">About</a></li>
<li><a href="#">Products</a></li>
<li><a href="#">Services</a></li>
<li><a href="#">Locations</a></li>
<li><a href="#">Blog</a></li>
<li><a href="#">Contact</a></li>
</ul>
<div class="more pinned invisible">
<button>More</button>
<ul class="dropdown pinned hidden"></ul>
</div>
</nav>
And a few display details to make it run horizontally.
nav {
--more-width: 0;
position: relative;
margin-right: var(--more-width);
}
ul.menu {
display: inline-flex;
flex-direction: row;
flex-wrap: nowrap;
}
div.more {
flex: 0 0 auto;
transform: translate(100%);
}
.pinned {
position: absolute;
top: 0;
right: 0;
}
.invisible {
visibility: hidden;
}
.hidden {
display: none;
}
A few salient details:
- we've set
flex-wrap: nowrapso that the items continue horizontally inline-flexso that theultakes up just as much room as it needs- the
pinnedclass positions both the dropdown and the more elements absolutely on the right edge. We'll show / hide both as needed - a few minor helper classes (
.invisbleand.hidden) for showing hiding things. - you also might be wondering about the
transformon thediv. We'll come to that...
Also, notice how we have both visibility hidden and display none helpers, here. This is important. When we remove an item from the navigation, we want to leave it in the document so that it may continue to trigger the Intersection Observer. We use visibility:hidden for this. Contrast it to display:none, which is used to remove an element (and its children) from the layout completely.
For the JS, we first set up some references to the DOM nodes we’ll need handles to:
const nav = document.querySelector('nav');
const dropdown = document.querySelector('ul.dropdown');
const more = document.querySelector('div.more');
const items = document.querySelectorAll('li');
const list = document.querySelector('ul.menu');
const button = document.querySelector('button');
The core logic, which contains the “toggle” logic, and the callback for the IntersectionObserver:
let isOverflowing = false;
const clones = new WeakMap();
const listWidth = list.getBoundingClientRect().width;
const moreWidth = more.getBoundingClientRect().width;
const options = {
root: nav,
threshold: 1,
};
const toggle = (entry) => {
const hide = (entry.intersectionRatio < 1);
const item = entry.target;
const clone = clones.get(entry.target);
item.classList.toggle('invisible', hide);
clone.classList.toggle('hidden', !hide);
};
const callback = (entries) => {
isOverflowing = entries.some(i => i.rootBounds.width < listWidth);
more.classList.toggle('invisible', !isOverflowing);
entries.forEach(toggle);
};
const navIO = new IntersectionObserver(callback, options);
- a
WeakMapis used to store clones of the overflowing nav items, which are set up below - a threshold of 1 means: trigger when any child is not 100% contained (i.e. < 1)
- each
entryin the callback is an object that contains a few useful items. We userootBoundsto know what the parent boundingClientRect is
And, the initialization:
nav.style.setProperty('--more-width', `${ moreWidth }px`);
items.forEach(li => {
const clone = li.cloneNode(true);
dropdown.appendChild(clone);
clones.set(li, clone);
navIO.observe(li);
});
button.addEventListener('click', (e) => {
dropdown.classList.toggle('hidden');
});
- we reserve an amount of margin equal to the dropdown button
- we also
cloneeach nav item and insert them into the dropdown, keeping a reference to each in our WeakMap
At this point, things actually work pretty well. Though, there is one minor detail that may be an issue – or not – depending on your use-case. When the viewport is shrinking, each intersection is detected on the right edge of each nav item. When the viewport expands, though, this means we “miss” the intersection of the last item until we’re beyond it. That is to say, when the right edge of the last nav item is clear of the bounding container.
It’s not a huge deal (as we wouldn’t expect the user to be frequently resizing the viewport) but more importantly, the menu still works fine. To hide the overflow dropdown again, the user would need to resize a little beyond the bounding nav container in order to trigger the intersection.
If we did want to address this, though, we could get fancy by keeping track of widths and margins and updating offsets dynamically (yuck), but it’s far simpler to have an addtional IntersectionObserver, instead. We’d need to detect intersections on the bounding nav container, and also on an “adjusted” bounding nav container – one with the width of the dropdown button taken into consideration:
let isOverflowing = false;
let isIntersecting = false;
// ...
const navIO = new IntersectionObserver((entries) => {
isOverflowing = entries.some(i => i.rootBounds.width < listWidth);
isIntersecting = entries.some(i => i.intersectionRatio < 1);
more.classList.toggle('invisible', !isOverflowing);
(isOverflowing == isIntersecting) && entries.forEach(toggle);
}, options);
const moreIO = new IntersectionObserver((entries) => {
isOverflowing && entries.forEach(toggle)
}, {
...options,
rootMargin: `0px -${ moreWidth }px 0px 0px`
});
- we no longer need to set a CSS var to reserve margin
- we extend our options in the second IntersectionObserver, using a negative
rootMargininstead - both IntersectionObservers will call the same
togglemethod (from above) upon intersection. The logic for this (isIntersecting, isOverflowing) is discussed in the next section
Finally, we need to make sure we observe both:
items.forEach(li => {
// lines removed for brevity
navIO.observe(li);
moreIO.observe(li);
});
Discussion
The approach in the first version uses a CSS var to dynamically set a margin on the containing nav element, which is equal to the width of the more overflow toggle. This way, the overflow toggle is able to sit in the free space created, floating at the edge of the parent element. Then, when any items intersect (i.e. any element is less that 100% contained), we toggle isOverflowing to true and use it to 1) show the overflow dropdown and 2) add a right-margin equal to the width of the dropdown.
The example with two IntersectionObservers foregoes this approach, though, using a constant negative rootMargin on one of the observers instead. The offset-adjusted observer detects intersections offset by the more button width and is used to manage the display of nav items (as before), while the non-offset one is used to simply activate / deactivate the overflow dropdown at the correct time. For this, an isIntersecting variable tracks when a nav item is intersecting, which, when used in tandem with the isOverFlow variable, can be used to activate the more dropdown accordingly. We use isOverflowing == isIntersecting as a trick for when to turn it on (both true) and off (both false).