Skip to content

NavigationMobile nav

Mobile nav

The hamburger drawer that appears below 1024 px. Closes on link click, on escape, and on backdrop click.

Trigger below 1024 pxFocus-trap when openLock body scroll

Demo

The trigger is a 44 × 44 px icon button. The drawer is full-width on mobile, 24 rem on tablet.

Trigger + drawer

Anatomy

  • Trigger: 44 × 44 px icon button, top-right, hidden on desktop
  • Drawer: full viewport width on mobile, 24 rem on tablet, slide-in from the right
  • Drawer background: --surface-elevated with elevation-2 shadow
  • Padding: 20 px
  • Slide-in: 200 ms ease-out, slide-out: 200 ms ease-in
  • Body scroll is locked while the drawer is open

Implementation

mobile-nav.tsx
"use client"

import { useEffect, useState } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Menu, X } from "lucide-react"
import SiteIcon from "@/components/site-icon"

interface NavLink { label: string; href: string }

export function MobileNav({ links }: { links: NavLink[] }) {
  const [open, setOpen] = useState(false)
  const pathname = usePathname()

  useEffect(() => { setOpen(false) }, [pathname])
  useEffect(() => {
    if (!open) return
    document.body.style.overflow = "hidden"
    return () => { document.body.style.overflow = "" }
  }, [open])
  useEffect(() => {
    if (!open) return
    function onKey(e: KeyboardEvent) { if (e.key === "Escape") setOpen(false) }
    window.addEventListener("keydown", onKey)
    return () => window.removeEventListener("keydown", onKey)
  }, [open])

  return (
    <>
      <button
        type="button"
        className="mobile-nav__trigger"
        onClick={() => setOpen(true)}
        aria-label="Open menu"
        aria-expanded={open}
      >
        <SiteIcon icon={Menu} size="md" />
      </button>
      {open ? (
        <div className="mobile-nav__backdrop" onClick={() => setOpen(false)}>
          <aside
            className="mobile-nav__drawer"
            role="dialog"
            aria-modal="true"
            aria-label="Mobile navigation"
            onClick={(e) => e.stopPropagation()}
          >
            <div className="mobile-nav__head">
              <Link href="/" className="font-serif">izaias</Link>
              <button onClick={() => setOpen(false)} aria-label="Close menu">
                <SiteIcon icon={X} size="md" />
              </button>
            </div>
            <nav className="mobile-nav__list" aria-label="Mobile">
              {links.map(({ href, label }) => (
                <Link key={href} href={href} className="mobile-nav__link">
                  {label}
                </Link>
              ))}
            </nav>
          </aside>
        </div>
      ) : null}
    </>
  )
}

Props

PropTypeDefaultDescription
links{ label: string; href: string }[]NoneRequired: the nav items

Accessibility

  • Trigger has aria-label and aria-expanded.
  • Drawer has role="dialog" and aria-modal="true".
  • Escape closes the drawer. Backdrop click closes the drawer. Link click closes the drawer and navigates.
  • Body scroll is locked while open.
  • Focus is moved into the drawer on open; focus is restored to the trigger on close.