Skip to main content

Removing layout shift from a progressively enhanced burger menu

By Gerardo Rodriguez

Published on September 6th, 2022

A burger illustration where each layer drops from above, then gets chomps until its gone
Illustration by Aileen Jeffries

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. 😱

Andy Bell tweets: A rare Piccalilli blog post: A CLS punishment for a progressive enhancement? I've stumbled across an issue with Lighthouse where I get a bad performance score for a progressive enhanced burger menu pattern and I'd love some help from smart people. Authored June 27, 2022.
Andy Bell tweets that a progressively enhanced burger menu pattern produces a bad Lighthouse performance score.

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?

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.

A frame-by-frame view of the page load sequence where the menu renders open initially, then snaps closed causing a layout shift.
The progressively enhanced Sky Nav menu is initially open, but this causes a layout shift once the external Sky Nav JavaScript loads and initializes.

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!

A young child shrugging shoulders saying, "Why don't we have both?"
Why not both?

The strategies fall under two categories: 

If you’d like to jump ahead, the strategies I present in this article are:

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:

The layout shift demo shows how the delay in the nav closing causes a jarring layout shift.

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.

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.

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">

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>

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

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 */
}

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 */
}

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.

Video showing no layout shift with the synchronous inline script strategy.

When JavaScript is not available, the menu stays open, and the “Menu” toggle button is not displayed:

The synchronous inline script demo showing the menu is open and the toggle button hidden when JavaScript is unavailable.
The synchronous inline script strategy experience when JavaScript is unavailable has the menu open with the menu toggle button hidden.
  • The inline JS is one line of code
  • No inline CSS
  • 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

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">

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

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>

I like that there are fewer states to manage with this strategy; overall, it feels slightly less complex than the first one.

Video showing no layout shift with the noscript element strategy.

Same as the previous strategy, when JavaScript is not available, the menu is open and the “Menu” toggle button is not displayed:

The noscript element demo showing the menu is open and the toggle button hidden when JavaScript is unavailable.
The noscript element strategy experience when JavaScript is unavailable has the menu open with the menu toggle button hidden.
  • 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
  • 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

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).

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">
// 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';
}

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 */
}

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">
<ul id="sky-nav" class="c-sky-nav__menu-items" role="list">

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…
}

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 #sky-nav hash from the URL is the only way to close it. Not a deal-breaker, but worth noting.

Video showing no layout shift with the :target pseudo-class strategy.

When JavaScript is unavailable, the menu starts out closed but can be toggled open. Notice the default browser internal link behavior where the #sky-nav hash is added to the URL:

The Sky Nav menu closed initially, then opened via the 'menu' toggle without JavaScript.
The :target pseudo-class strategy experience when JavaScript is unavailable begins with the menu closed and allows the menu to be opened.
  • The menu starts closed initially
  • No inline CSS or JS is required
  • The menu can be opened without JS
  • The CSS is simpler
  • The menu can’t be easily closed once it has been opened without JS

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. 😊

Comments

Ahmed said:

I use a label with a hidden checkbox input and the sibling css property.

Leave a Comment

Please be kind, courteous and constructive. You may use simple HTML or Markdown in your comments. All fields are required.