Removing layout shift from a progressively enhanced burger menu
At 4/19/2024
I recently saw a tweet from Andy Bell that a progressively enhanced burger menu pattern was causing a poor Cumulative Layout Shift (CLS) metric score. Both the CLS metric score and progressive enhancement prioritize end-user experiences first, yet they were at odds with each other in this instance. 😱
My curiosity was piqued. How might we be able to solve this? Do we really have to choose between progressive enhancement or a great CLS Core Web Vital metric score?
Let’s understand the problem
A progressively enhanced burger menu means the menu’s contents should be accessible when JavaScript is unavailable. The menu is typically open by default before JavaScript loads. (As we’ll discover later, we can start with the menu closed and still have access to the menu’s contents.)
Once the menu external JavaScript loads and initializes, the menu collapses. And, hello, layout shift; the content after the menu suddenly shifts, causing a poor CLS metric score.
Searching for strategies
After researching and consulting our team, I gathered notes on three strategies we have either implemented or considered before. Can we preserve progressive enhancement while avoiding a poor CLS metric score? It turns out we can!
The strategies fall under two categories:
If you’d like to jump ahead, the strategies I present in this article are:
Demos
Throughout the examples, I will use the Cloud Four Sky Nav pattern. It is a “burger” menu pattern that is progressively enhanced and also initially suffered from a poor CLS metric score.
Each of the strategies has a demo and a WebPageTest as a reference. All of the demos use the same core Sky Nav JavaScript to power the nav. This article focuses on the differences between the demos but you can view the full source code on GitHub. The Sky Nav init
function has a 1-second delay to exaggerate the length of time you see the layout shift, mimicking a slow-loading JavaScript file.
As a baseline, I’ve included a demo that does not implement any of the strategies resulting in a layout shift and a poor CLS metric score:
Yikes, that’s quite the layout shift. We can do better. Let’s take a closer look at three strategies to remove the layout shift while maintaining progressive enhancement.
Render the menu open without JavaScript
The following two strategies allow the menu to render open when JavaScript is unavailable (progressive enhancement) while avoiding a layout shift (good CLS metric score) once JavaScript enhances the experience.
Synchronous inline script strategy
With this strategy, the Sky Nav has three states:
- no-js: When no JavaScript is available
- loading: When JavaScript is available, but the Sky Nav JavaScript has not initialized
- ready: When the Sky Nav JavaScript has initialized
A data-state
attribute on the Sky Nav root element keeps track of the state. On server render, its value is set to “no-js”:
<div class="c-sky-nav js-sky-nav" data-state="no-js">
Code language: JavaScript (javascript)
The key to this strategy is to add a synchronous inline script that updates the state to “loading”:
<body>
<div class="c-sky-nav js-sky-nav" data-state="no-js">
<!-- Sky Nav content -->
</div>
<!-- Other HTML content -->
<script>
// This line of JavaScript is the key to removing the layout
// shift for this technique. CSS hides the menu removing the
// layout shift using the updated state value as the hook as
// soon as this line of JavaScript runs.
document.querySelector('.js-sky-nav').dataset.state = 'loading';
</script>
</body>
Code language: Handlebars (handlebars)
And in the Sky Nav external JavaScript, the state is eventually updated to “ready” once it has loaded and initialized:
// sky-nav.js
export const initSkyNav = (toggleEl) => {
// Sky Nav JS initialized: Update the state to "ready"
const navWrapper = toggleEl.closest('.js-sky-nav');
navWrapper.dataset.state = 'ready';
}
Code language: TypeScript (typescript)
We can now write the CSS to handle the different menu display states referencing the data-state
attribute:
/**
* Manage the display of the menu
*
* 1. Make sure the menu is accessible when JS is unavailable.
* 2. Hide the menu during the ‘loading’ state; this solves the
* poor Cumulative Layout Shift score.
* 3. The Sky Nav JavaScript initialized, reset the `display`.
* The Sky Nav JavaScript now toggles the menu open/closed.
*/
[data-state='no-js'] .c-sky-nav__menu-items {
display: block; /* 1 */
}
[data-state='loading'] .c-sky-nav__menu-items {
display: none; /* 2 */
}
[data-state='ready'] .c-sky-nav__menu-items {
display: block; /* 3 */
}
Code language: CSS (css)
Since the menu button toggle won’t function without JavaScript, we can also manage its display so that it’s not available without JavaScript:
/**
* Manage the display of the menu toggle
*
* 1. The button toggle is not needed if JS is not available.
* 2. During the 'loading' and 'ready' state, show the button
*/
[data-state='no-js'] .c-sky-nav__menu-toggle {
display: none; /* 1 */
}
[data-state='loading'] .c-sky-nav__menu-toggle,
[data-state='ready'] .c-sky-nav__menu-toggle {
display: inline-flex; /* 2 */
}
Code language: CSS (css)
And that’s it for this strategy! Since the JavaScript is inlined, it runs instantaneously. This allows the menu to immediately close, removing the layout shift.
For this strategy, you can experiment with where the inline script can be placed. In some cases, you might be able to place the inline script right after the menu; just make sure you confirm that the inline JavaScript isn’t causing a different layout shift by blocking rendering.
When JavaScript is not available, the menu stays open, and the “Menu” toggle button is not displayed:
Pros
- The inline JS is one line of code
- No inline CSS
Cons
- You need to add JavaScript code in the HTML template for this to work
- If the Sky Nav menu JavaScript is slow to load or doesn’t load, the menu won’t work
noscript element strategy
For this strategy, the Sky Nav only has two states:
- no-js: No JavaScript is available
- ready: The Sky Nav JavaScript has initialized
Same as the first strategy, the state is stored in a data-state
attribute on the Sky Nav root element and is set to “no-js” on server render:
<div class="c-sky-nav js-sky-nav" data-state="no-js">
Code language: JavaScript (javascript)
Following a similar pattern (but skipping the “loading” state), the state is updated in the Sky Nav external JavaScript to “ready”:
// sky-nav.js
export const initSkyNav = (toggleEl) => {
// Sky Nav JS initialized: Update the state to "ready"
const navWrapper = toggleEl.closest('.js-sky-nav');
navWrapper.dataset.state = 'ready';
}
Code language: TypeScript (typescript)
That’s it for the setup. The magic of this strategy is using a <noscript>
element as shown below. The code also hides the menu toggle if JavaScript is not available since it doesn’t function in this state:
<!-- index.html -->
<head>
<!-- Other head content -->
<style>
/**
* 1. Hide the menu during the 'no-js' state, this solves
* the poor Cumulative Layout Shift score.
*/
[data-state='no-js'] .c-sky-nav__menu-items {
display: none; /* 1 */
}
</style>
</head>
<body>
<div class="c-sky-nav js-sky-nav" data-state="no-js">
<!-- Sky Nav content -->
</div>
<!-- Keep menu accessible when JavaScript is not available -->
<noscript>
<style>
/**
* When JavaScript is not available, the code can leverage
* the `noscript` element to undo the `display: none`.
*
* 1. Reset `display` since JavaScript is not available
* 2. Hide the menu toggle since it doesn't function
*/
[data-state='no-js'] .c-sky-nav__menu-items {
display: block; /* 1 */
}
[data-state='no-js'] .c-sky-nav__menu-toggle {
display: none; /* 2 */
}
</style>
</noscript>
<!-- Other HTML content -->
</body>
Code language: Handlebars (handlebars)
I like that there are fewer states to manage with this strategy; overall, it feels slightly less complex than the first one.
Same as the previous strategy, when JavaScript is not available, the menu is open and the “Menu” toggle button is not displayed:
Pros
- Only two states to manage (“no-js” and “ready”)
- The inline CSS only needs to be concerned with the “no-js” state, the cascade handles the rest
Cons
- You need to inline code in the HTML template for this to work
- If the Sky Nav menu JavaScript is slow to load or doesn’t load, the menu won’t work
Render the menu closed without JavaScript
The following strategy allows the menu to start closed when JavaScript is unavailable (good CLS metric score) but still allow access to the menu contents without JavaScript (progressive enhancement).
:target pseudo-class strategy
The :target
pseudo-class is an HTML + CSS feature I hadn’t actually used before this article. It’s pretty neat. If it’s helpful, take a quick peek at the MDN Web Docs for the :target
pseudo-class to better understand how it works.
For this strategy, the Sky Nav has two states:
- no-js: No JavaScript is available
- ready: The Sky Nav JavaScript has initialized
Following the same pattern as the previous strategy, the Sky Nav root element has a data-state
attribute set to “no-js” on server render. The Sky Nav external JavaScript updates the state to “ready” once it initializes:
<div class="c-sky-nav js-sky-nav" data-state="no-js">
Code language: JavaScript (javascript)
// sky-nav.js
export const initSkyNav = (toggleEl) => {
// Sky Nav JS initialized: Update the state to "ready"
const navWrapper = toggleEl.closest('.js-sky-nav');
navWrapper.dataset.state = 'ready';
}
Code language: TypeScript (typescript)
By now, that setup should be familiar. The CSS for this strategy is just a few more lines of code:
/**
* 1. Hide the menu during the 'no-js' state, this solves the
* poor Cumulative Layout Shift score.
* 2. The key to this technique is to use a `:target` pseudo-class
* to show the menu when the menu toggle link is clicked.
*/
[data-state='no-js'] .c-sky-nav__menu-items {
display: none; /* 1 */
}
[data-state='no-js'] .c-sky-nav__menu-items:target {
display: block; /* 2 */
}
Code language: CSS (css)
Different from previous strategies, the menu toggle starts as an anchor element with an href
value to the ID of the menu list of links:
<a href="#sky-nav" class="c-button c-sky-nav__menu-toggle js-sky-nav-menu-toggle">
Code language: JavaScript (javascript)
<ul id="sky-nav" class="c-sky-nav__menu-items" role="list">
Code language: JavaScript (javascript)
If no JavaScript is available, you have all you need. The menu toggle links internally to the menu link list and adds a #sky-nav
hash to the URL (default browser behavior). The CSS :target
pseudo-class selector then sets a display: block
and the magic happens, the menu opens.
If JavaScript is available, though, you’ll want to make sure to swap the anchor menu toggle for a <button>
element menu toggle as its more semantic and more accessible for the enhanced experience. One way to do that is as follows:
// sky-nav.js
export const initSkyNav = (toggleEl: HTMLButtonElement) => {
// Sky Nav JS initialized: Update the state to "ready"
const navWrapper = toggleEl.closest('.js-sky-nav');
navWrapper.dataset.state = 'ready';
// For the `:target` pseudo-class solution, the toggle is a link
// by default. Once this Sky Nav JS logic kicks in, we change the
// link to a button since using a button for this functionality
// is more semantic and more accessible.
// Will hold the menu nav toggle button
let navToggle;
// Create a new button
const buttonEl = document.createElement('button');
// Copy all of the link classes over to the new button
toggleEl.classList.forEach((toggleElCssClass) =>
buttonEl.classList.add(toggleElCssClass)
);
// Copy over the contents of the link to the button
buttonEl.innerHTML = toggleEl.innerHTML;
// Swap the link for the button
toggleEl.replaceWith(buttonEl);
// Update the code to reference the new button as the nav toggle
navToggle = buttonEl;
// Rest of Sky Nav JS logic…
}
Code language: TypeScript (typescript)
role="button"
to the <a>
element?
Why can’t I just add a role="button"
to the <a>
element?
You could, but then you’d also have to ensure the link pretending to be a button also responds to Spacebar
events for keyboard accessibility. When navigating with a keyboard, a <button>
element works with the Enter
/Return
key as well as the Spacebar
key. An <a>
element does not. Plus, ya’ know, the first rule of ARIA:
If you can use a native HTML element or attribute with the semantics and behavior you require already built in, instead of re-purposing an element and adding an ARIA role, state or property to make it accessible, then do so.
Not to mention you’d want to prevent the default <a>
behavior, or else it’d conflict with the JavaScript-enhanced toggle behavior. As you can see, it turns into a rabbit-hole real quick. Just use a native <button>
element. 🙂
This strategy is neat because, without JavaScript, you can still open the menu with the magic of the :target
pseudo-class. Once the menu is open, removing the #
hash from the URL is the only way to close it. Not a deal-breaker, but worth noting.sky-nav
When JavaScript is unavailable, the menu starts out closed but can be toggled open. Notice the default browser internal link behavior where the #
hash is added to the URL:sky-nav
Pros
- The menu starts closed initially
- No inline CSS or JS is required
- The menu can be opened without JS
- The CSS is simpler
Cons
- The menu can’t be easily closed once it has been opened without JS
Which solution to choose?
All three solutions remove the layout shift while maintaining progressive enhancement. The best strategy mostly depends on the user experience you prefer when JavaScript is unavailable.
Personally, I find the :target
pseudo-class strategy intriguing because the menu starts closed when JavaScript is unavailable, but you can still open the menu to access the nav links. I will likely start there the next time I am looking for a burger menu pattern strategy.
Andy’s article explains they used a web component for their burger menu. In theory, all three strategies above should also work with a web component. I say “in theory” because I didn’t build out a demo for that use case. Maybe that’s a follow-up to this article!
Until then, thanks for following along. May you also continue to progressively enhance user experiences while maintaining excellent CLS metric scores. There is no reason to choose between one or the other when you can have both. 😊