Or the priority navigation pattern, or progressively collapsing navigation menu. We can name it in at least three ways.
There are multiple UX solutions for tabs and menus and each of them have their own advantages over another, you just need to pick the best for the case you are trying to solve. At design and development agency Kollegorna we were debating on the most appropriate UX technique for tabs for our client’s website…
We agreed it should be a one-liner because the amount of tab items is unknown and narrowed our options down to two: horizontal scroll and adaptive with “more” button. Firstly, the problem with the former one is that horizontal scroll as a feature is not always visually obvious for users (especially for narrow elements like tabs) whereas what else can be more obvious than a button (“more”), right? Secondly, scrolling horizontally using a mouse-controlled device isn’t a very comfortable thing to do, so we might need to make our UI more complex with additional arrow buttons. All considered, we ended up choosing the later option:
Planning
The main intrigue here is if it’s possible to achieve that without JavaScript? Partly yes, however the limitations that it comes with probably make it only good for a concept museum rather than real life scenarios (anyway, Kenan did a really nice job). Still, the dependency on JS doesn’t mean we can’t make it usable if for some reason the technology is not available. Progressive enhancement and graceful degradation for the win!
Since the amount of tab items is uncertain or volatile, we will make use of Flexbox which ensures the items are nicely spread in the container element without setting the widths.
Initial Prototype
There are two lists both visually and technically: one is for items that fit in the container, and one for items that don’t. Since we’ll depend on JavaScript, it’s totally fine to have our initial markup with a single list only (we will duplicate it with JS):
<nav class="tabs"> <ul class="-primary"> <li><a href="...">Falkenberg</a></li> <li><a href="...">Braga</a></li> <!-- ... --> </ul>
</nav>
With a tiny touch of flex-based CSS things are starting to get serious here. I’ll skip the decorative CSS properties in my examples here and place here what really matters:
.tabs .-primary { display: flex;
}
.tabs .-primary > li { flex-grow: 1;
}
Here’s what we already have:
Graceful Degradation
Now before enhancing it progressively with JavaScript, let’s make sure it degrades gracefully if there is no JS available. There multiple of reasons for JS absence: it’s still loading, it has fatal errors, it failed to be transferred over the network.
.tabs:not(.--jsfied) { overflow-x: auto; -webkit-overflow-scrolling: touch;
}
And once JavaScript is here, the --jsfied
class name is added to the container element which neutralizes the CSS above:
container.classList.add('--jsfied')
Turns out the horizontal scroll strategy that I mentioned before might make a fine use here! When there’s not enough room for menu items the overflowing content gets clipped inside the container and scrollbars are displayed. That’s way better than empty space or something that’s broken, isn’t it?
Missing Parts
First off, let’s insert the missing DOM parts:
- Secondary (dropdown) list which is a copy of the main list;
- “More” button.
const container = document.querySelector('.tabs')
const primary = container.querySelector('.-primary')
const primaryItems = container.querySelectorAll('.-primary > li:not(.-more)')
container.classList.add('--jsfied')
// insert "more" button and duplicate the list
primary.insertAdjacentHTML('beforeend', ` <li class="-more"> <button type="button" aria-haspopup="true" aria-expanded="false"> More ↓ </button> <ul class="-secondary"> ${primary.innerHTML} </ul> </li>
`)
const secondary = container.querySelector('.-secondary')
const secondaryItems = secondary.querySelectorAll('li')
const allItems = container.querySelectorAll('li')
const moreLi = primary.querySelector('.-more')
const moreBtn = moreLi.querySelector('button')
moreBtn.addEventListener('click', (e) => { e.preventDefault() container.classList.toggle('--show-secondary') moreBtn.setAttribute('aria-expanded', container.classList.contains('--show-secondary'))
})
Here we are nesting secondary list into primary and using some aria-*
properties. We want our navigation menu to be accessible, right?
There’s also an event handler attached to the “more” button that toggles the --show-secondary
class name on the container element. We’ll use it to show and hide the secondary list. Now let’s style the new parts. You may want to visually accent “more” button.
.tabs { position: relative;
}
.tabs .-secondary { display: none; position: absolute; top: 100%; right: 0;
}
.tabs.--show-secondary .-secondary { display: block;
}
Here’s where that brought us to:
Obviously, we need some code that hides and shows the tabs…
Hiding and Showing Tabs in the Lists
Because of Flexbox, the tab items will never break into multiple lines and will shrink to their minimum possible widths. This means we can walk through the each item one by one, add up their widths, compare it to the width of .tabs
element and toggle the visibility of particular tabs accordingly. For that we will create a function called doAdapt
and wrap in the code below in this section.
To begin width, we should visually reveal all the items:
allItems.forEach((item) => { item.classList.remove('--hidden')
})
On a side note, .--hidden
works the way you’ve probably expected:
.tabs .--hidden { display: none;
}
Math time! I’ll have to disappoint you if you expected some advanced mathematics. So, as described previously, we walk through the each primary tab by adding up their widths under stopWidth
variable. We also perform a check if the item fits in the container, hide the item if not and save its index for later use.
let stopWidth = moreBtn.offsetWidth
let hiddenItems = []
const primaryWidth = primary.offsetWidth
primaryItems.forEach((item, i) => { if(primaryWidth >= stopWidth + item.offsetWidth) { stopWidth += item.offsetWidth } else { item.classList.add('--hidden') hiddenItems.push(i) }
})
Hereafter, we need to hide the equivalent items from the secondary list that remained visible in the primary one. As well as hide “more” button if no tabs were hidden.
if(!hiddenItems.length) { moreLi.classList.add('--hidden') container.classList.remove('--show-secondary') moreBtn.setAttribute('aria-expanded', false)
}
else { secondaryItems.forEach((item, i) => { if(!hiddenItems.includes(i)) { item.classList.add('--hidden') } })
}
Finally, ensure doAdapt
function is executed at the right moments:
doAdapt() // adapt immediately on load
window.addEventListener('resize', doAdapt) // adapt on window resize
Ideally the resize event handler should be debounced to prevent unnecessary calculations.
Ladies and gentlemen, this is the result (play with resizing the demo window):
See the Pen Container-Adapting Tabs With “More” Button by Osvaldas (@osvaldas) on CodePen.
I could probably end my article here, but there is an extra mile we can walk to make it better and some things to note…
Enhancements
It has been implemented in the demo above, but we haven’t overviewed a small detail that improves the UX of our tabs widget. It’s hiding the dropdown list automatically if user clicks anywhere outside the list. For that we can bind a global click listener and check if the clicked element or any of its parents is the secondarylist or “more” button. If not, the dropdown list gets dismissed.
document.addEventListener('click', (e) => { let el = e.target while(el) { if(el === secondary || el === moreBtn) { return; } el = el.parentNode } container.classList.remove('--show-secondary') moreBtn.setAttribute('aria-expanded', false)
})
Edge Cases
Long Tab Titles
You might have been wondering how the widget behaves with long tab titles. Well, you have at least two options here…
- Let titles wrap to the next line which is how they behave by default (you can also enable word wrapping with
word-wrap: break-word
):
- Or you can disable all kinds of wrapping in the primary list with
white-space: nowrap
. The script is flexible enough to put the too-long items to the dropdown (where the titles are free to wrap) by stepping aside the shorter siblings:
Many Tabs
Even though the secondary list is position: absolute
it doesn’t matter how long your document’s height is. As long as the container element or its parents are not position: fixed
, the document will adapt and the bottom items will be reachable by scrolling down the page.
A Thing to be Aware Of
Things may become tricky if the tabs are buttons rather than anchors semantically, which means their response to clicks are decided by JavaScript, e.g.: dynamic tabs. The problem here is that tab button event handlers aren’t duplicated along with the markup. I see at least two approaches to solve that:
- Place dynamic event handler attachments right after the adaptive tab code;
- Use an event delegation method instead (think of jQuery’s
live()
).
Unfortunately, events occur in quantity: most likely your tabs will have a selected state that visually indicates the current tab so it’s also important to manage the states simultaneously. Otherwise, flip the tablet and you’re lost.
Browser Compatibility
Even though I used ES6 syntax in the examples and demo, it should be converted to ES5 by a compiler such as Babel to significantly widen the browser support (down to IE9 including).
You can also expand the Flexbox implementation with an older version and syntax (all the way down to IE10). If you need to also support non-Flexbox browsers you can always do feature detection with CSS @supports
, apply the technique progressively, and rely on horizontal scroll for older browsers.
Happy tabbing!
The post Container-Adapting Tabs With “More” Button appeared first on CSS-Tricks.