Wes Hatch

front-end developer

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.

GitHub Logo

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;
}
Note

A few salient details:

  • we've set flex-wrap: nowrap so that the items continue horizontally
  • inline-flex so that the ul takes up just as much room as it needs
  • the pinned class 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 for showing hiding things.
  • you also might be wondering about the transform on the div. 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, while display:none 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);
Note
  • a WeakMap is 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 entry in the callback is an object that contains a few useful items. We use rootBounds to 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');
});
Note
  • we reserve an amount of margin equal to the dropdown button
  • we also clone each 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`
});
Note
  • we no longer need to set a CSS var to reserve margin
  • we extend our options in the second IntersectionObserver, using a negative rootMargin instead
  • both IntersectionObservers will call the same toggle method (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).

Demos

Basic demo
Edge-adjusted