Search code examples
javascripthtmlcss

Menu expand transition with dynamic max-height


I have a website header with an expandable menu (in mobile screen widths) that operates very similarly to the example listed below:

const menuButton = document.getElementById("menu-button");
const menu       = document.getElementById("menu");

menuButton.addEventListener("click", function() {
  menu.classList.toggle("open");
});
body {
  margin: 0;
  padding: 0;
}

header {
  background-color: #333;
  color: #fff;
  padding: 0 1rem;
}

nav {
  display: flex;
  justify-content: space-between;
  align-items: center;
  height: 80px;
}

.menu {
  background-color: #888;
  position: absolute;
  top: 80px;
  left: 0;
  margin: 0;
  padding: 0;
  width: 100%;
  text-align: center;
  max-height: 0;
  overflow: hidden;
  transition: max-height 3s;
}

.menu.open {
  max-height: 100vh;
}

.menu-item {
  list-style-type: none;
}

.menu-item:not(:last-child) {
  border-bottom: 1px solid #333;
}

.menu-item a {
  display: block;
  padding: 1rem;
  color: #fff;
}
<header class="drop-shadow">
  <nav class="container">
    <!-- Logo -->
    <h2>LOGO</h2>

    <!-- Menu Button -->
    <button id="menu-button" class="menu-button">☰</button>

    <!-- Menu -->
    <ul id="menu" class="menu">
      <li class="menu-item">
        <a href="/" class="menu-link active">Home</a>
      </li>
      <li class="menu-item">
        <a href="/blog" class="menu-link">Blog</a>
      </li>
      <li class="menu-item">
        <a href="/about" class="menu-link">About</a>
      </li>
    </ul>
  </nav>
</header>

The issue is that while the menu opens instantly upon clicking, the max-height is greater than the actual height and therefore when collapsing, it takes a moment before the menu can be visibly seen collapsing.

I know that a common solution is to dynamically add the max-height using javascript. But the problem here is that it adds the max-height via inline styling on the element. The menu on my actual site is responsive to screen width, and always visible on larger screen screens. So if the window size is expanded after the menu is toggled, inline max-height styling messes the whole thing up.

I don't want to hardcode the max-height, as the menu items (and consequently the max-height) will be regularly changing.

Is there a way to ensure instant visible collapsing of the menu without dynamically adding max-height via in-line styling? (I hope this all makes sense)


Solution

  • A workaround is to use grid layout, and make a transition from 0fr to 1fr for its row. But you would need an extra wrapper.

    /* close */
    grid-template-rows: 0fr;
    /* open */
    grid-template-rows: 1fr;
    

    Here's an example.

    document.addEventListener("DOMContentLoaded", function() {
      const menuButton = document.getElementById("menu-button");
      const menu = document.getElementById("menu");
    
      menuButton.addEventListener("click", function() {
        menu.classList.toggle("open");
      });
    });
    body {
      margin: 0;
      padding: 0;
    }
    
    header {
      background-color: #333;
      color: #fff;
      padding: 0 1rem;
    }
    
    nav {
      display: flex;
      justify-content: space-between;
      align-items: center;
      height: 80px;
    }
    
    .menu-wrapper {
      display: grid;
      grid-template-rows: 0fr;
      position: absolute;
      top: 80px;
      left: 0;
      width: 100%;
      text-align: center;
      transition: grid-template-rows 3s;
    }
    
    .menu {
      background-color: #888;
      margin: 0;
      padding: 0;
      overflow: hidden;
    }
    
    .menu-wrapper:has(.menu.open) {
      grid-template-rows: 1fr;
    }
    
    .menu-item {
      list-style-type: none;
    }
    
    .menu-item:not(:last-child) {
      border-bottom: 1px solid #333;
    }
    
    .menu-item a {
      display: block;
      padding: 1rem;
      color: #fff;
    }
    <!DOCTYPE html>
    <html lang="en">
    
    <head>
      <meta charset="UTF-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <title>Test Document</title>
    </head>
    
    <body>
      <header class="drop-shadow">
        <nav class="container">
          <!-- Logo -->
          <h2>LOGO</h2>
    
          <!-- Menu Button -->
          <button id="menu-button" class="menu-button">☰</button>
    
          <!-- Menu -->
          <div class="menu-wrapper">
            <ul id="menu" class="menu">
              <li class="menu-item">
                <a href="/" class="menu-link active">Home</a>
              </li>
              <li class="menu-item">
                <a href="/blog" class="menu-link">Blog</a>
              </li>
              <li class="menu-item">
                <a href="/about" class="menu-link">About</a>
              </li>
            </ul>
          </div>
        </nav>
      </header>
    </body>
    
    </html>

    However in the near future, you can make a transition directly from 0 to dynamic sizes like auto or max-content with function calc-size(). But for now (March 2025) the coverage isn't very optimal so you shouldn't use it in production environments.

    /* close */
    height: 0; 
    /* open */
    height: calc-size(auto, size);
    

    document.addEventListener("DOMContentLoaded", function() {
      const menuButton = document.getElementById("menu-button");
      const menu = document.getElementById("menu");
    
      menuButton.addEventListener("click", function() {
        menu.classList.toggle("open");
      });
    });
    body {
      margin: 0;
      padding: 0;
    }
    
    header {
      background-color: #333;
      color: #fff;
      padding: 0 1rem;
    }
    
    nav {
      display: flex;
      justify-content: space-between;
      align-items: center;
      height: 80px;
    }
    
    .menu {
      background-color: #888;
      position: absolute;
      height: 0px;
      top: 80px;
      left: 0;
      margin: 0;
      padding: 0;
      width: 100%;
      text-align: center;
      overflow: hidden;
      transition: height 3s;
    }
    
    .menu.open {
      height: calc-size(auto, size);
    }
    
    .menu-item {
      list-style-type: none;
    }
    
    .menu-item:not(:last-child) {
      border-bottom: 1px solid #333;
    }
    
    .menu-item a {
      display: block;
      padding: 1rem;
      color: #fff;
    }
    <!DOCTYPE html>
    <html lang="en">
    
    <head>
      <meta charset="UTF-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <title>Test Document</title>
    </head>
    
    <body>
      <header class="drop-shadow">
        <nav class="container">
          <!-- Logo -->
          <h2>LOGO</h2>
    
          <!-- Menu Button -->
          <button id="menu-button" class="menu-button">☰</button>
    
          <!-- Menu -->
          <ul id="menu" class="menu">
            <li class="menu-item">
              <a href="/" class="menu-link active">Home</a>
            </li>
            <li class="menu-item">
              <a href="/blog" class="menu-link">Blog</a>
            </li>
            <li class="menu-item">
              <a href="/about" class="menu-link">About</a>
            </li>
          </ul>
        </nav>
      </header>
    </body>
    
    </html>