← Back to all tutorials

Making a Mobile Drop-down Menu

Build a functional hamburger menu with JavaScript — toggle visibility, animate the icon, and add smooth slide-down animation.

Making a Mobile Drop-down Menu

In the previous episode, we hid the navigation on mobile and showed a hamburger icon. Now let's make it functional — clicking the hamburger will slide open a drop-down menu with all navigation links.

The HTML Structure

<header class="site-header">
    <div class="container">
        <div class="header-flex">
            <a href="/" class="logo">
                <img src="img/logo.png" alt="Logo">
            </a>

            <button class="menu-toggle" 
                    aria-label="Toggle menu"
                    aria-expanded="false">
                <span class="bar"></span>
                <span class="bar"></span>
                <span class="bar"></span>
            </button>

            <nav class="nav-links">
                <a href="/">Home</a>
                <a href="/about">About</a>
                <a href="/services">Services</a>
                <a href="/blog">Blog</a>
                <a href="/contact">Contact</a>
            </nav>
        </div>
    </div>
</header>

Base Hamburger CSS

/* Hidden on desktop */
.menu-toggle {
    display: none;
    background: none;
    border: none;
    cursor: pointer;
    padding: 8px;
    z-index: 100;
}

.menu-toggle .bar {
    display: block;
    width: 28px;
    height: 3px;
    background: #fff;
    margin: 5px 0;
    border-radius: 2px;
    transition: all 0.3s ease;
}

/* Shown on mobile */
@media (max-width: 480px) {
    .menu-toggle {
        display: block;
    }
}

The JavaScript

// script.js
const menuToggle = document.querySelector('.menu-toggle');
const navLinks = document.querySelector('.nav-links');

menuToggle.addEventListener('click', () => {
    const isOpen = navLinks.classList.toggle('open');
    menuToggle.classList.toggle('active');

    // Accessibility: update aria-expanded
    menuToggle.setAttribute('aria-expanded', isOpen);
});

// Close menu when a nav link is clicked
navLinks.querySelectorAll('a').forEach(link => {
    link.addEventListener('click', () => {
        navLinks.classList.remove('open');
        menuToggle.classList.remove('active');
        menuToggle.setAttribute('aria-expanded', 'false');
    });
});

// Close menu when clicking outside
document.addEventListener('click', (e) => {
    if (!menuToggle.contains(e.target) &&
        !navLinks.contains(e.target)) {
        navLinks.classList.remove('open');
        menuToggle.classList.remove('active');
        menuToggle.setAttribute('aria-expanded', 'false');
    }
});

Animating the Hamburger to an X

When the menu is open, transform the three bars into a close (X) icon:

/* Top bar: rotate to form \ of the X */
.menu-toggle.active .bar:nth-child(1) {
    transform: rotate(45deg) translate(5px, 6px);
}

/* Middle bar: fade out */
.menu-toggle.active .bar:nth-child(2) {
    opacity: 0;
    transform: translateX(-10px);
}

/* Bottom bar: rotate to form / of the X */
.menu-toggle.active .bar:nth-child(3) {
    transform: rotate(-45deg) translate(5px, -6px);
}

Smooth Slide-Down Animation

Instead of an abrupt show/hide, use a smooth slide-down effect:

@media (max-width: 480px) {
    .nav-links {
        position: absolute;
        top: 100%;
        left: 0;
        width: 100%;
        background: #222;
        flex-direction: column;
        max-height: 0;
        overflow: hidden;
        transition: max-height 0.4s ease;
    }

    .nav-links.open {
        max-height: 350px;  /* Larger than actual content height */
    }

    .nav-links a {
        display: block;
        padding: 15px 20px;
        color: #fff;
        font-size: 16px;
        border-bottom: 1px solid #333;
        opacity: 0;
        transform: translateY(-10px);
        transition: opacity 0.3s ease, transform 0.3s ease;
    }

    .nav-links.open a {
        opacity: 1;
        transform: translateY(0);
    }

    /* Stagger the animation for each link */
    .nav-links.open a:nth-child(1) { transition-delay: 0.05s; }
    .nav-links.open a:nth-child(2) { transition-delay: 0.10s; }
    .nav-links.open a:nth-child(3) { transition-delay: 0.15s; }
    .nav-links.open a:nth-child(4) { transition-delay: 0.20s; }
    .nav-links.open a:nth-child(5) { transition-delay: 0.25s; }
}

Why max-height Instead of display?

ApproachAnimatable?Smooth?
display: none → block❌ NoAppears instantly
height: 0 → auto❌ No (auto can't be transitioned)N/A
max-height: 0 → 350px✅ YesSmooth slide
opacity + transform✅ YesFade + slide combo

Accessibility Checklist

  • aria-label="Toggle menu" on the button — announces the purpose
  • aria-expanded dynamically updated — tells screen readers if the menu is open
  • Nav links are focusable and keyboard-accessible
  • Escape key support (optional enhancement):
document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') {
        navLinks.classList.remove('open');
        menuToggle.classList.remove('active');
        menuToggle.setAttribute('aria-expanded', 'false');
        menuToggle.focus();  // Return focus to the button
    }
});

Key Takeaways

  • Use classList.toggle() to open/close the menu
  • Animate the hamburger bars into an X using CSS transforms
  • Use max-height with transition for smooth slide animation (not display)
  • Stagger link animations with transition-delay for a polished feel
  • Close the menu on link click, outside click, and Escape key
  • Always update aria-expanded for accessibility