Lereng Menu

A menu for every visitor

Pandu (Malay for guide) turns the plain list of links your server already renders into dropdowns, a mega-menu and a mobile drawer. One CSS file, one JavaScript file, 7.1 KB gzipped, no framework, and every route keeps working as a normal link.

Install it Source on GitHub Before and after

Recorded on this site with Playwright: keyboard on desktop, then the drawer at 390 px. Open the GIF · MP4

What it covers

Each line of a typical "modernise our menu" brief, and where Pandu answers it.

The brief asks forHow Pandu does it
Component-based, clean HTML5, CSS3, lightweight JSOne <header> block of semantic HTML, pandu.css (2.5 KB gz) and pandu.js (4.6 KB gz). No dependencies, no build step needed.
Smooth hover and focus states180 ms transitions on transform and opacity only; hover opens after a 120 ms intent delay and closes after 300 ms, so a passing pointer does not flash panels. Focus rings are always visible.
Dropdown or mega-menuBoth. A panel with one list is a dropdown; add pandu__panel--mega and it lays out as columns with an optional feature card.
Collapsible mobile view with a hamburgerBelow 960 px a labelled Menu button opens an off-canvas drawer. Sections inside become accordions.
WCAG keyboard navigation, ARIA, tab orderWAI-ARIA disclosure pattern: real buttons with aria-expanded and aria-controls, aria-current="page" on the current link, DOM order equals tab order. See the keyboard map.
Plugs into the existing codebase without breaking routesEvery item is an ordinary <a href> your server renders. The script never routes, never rewrites links, and the menu still works with JavaScript off.
Chrome, Firefox, Safari, Edge; down to 320 pxSee the test matrix.
Setup notes and a demoThis page, and the recording above.
No console errors, passes accessibility checksEvery page of this site was checked with axe-core and the browser console during testing.

Install

Copy dist/pandu.min.css and dist/pandu.min.js into your static folder and add two lines to your base template. The script is deferred and starts itself on every element with data-pandu.

<link rel="stylesheet" href="/assets/pandu.min.css">
<script src="/assets/pandu.min.js" defer></script>

Or straight from the repository, pinned to a tag:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/juslangit/pandu@v1.0.0/dist/pandu.min.css">
<script src="https://cdn.jsdelivr.net/gh/juslangit/pandu@v1.0.0/dist/pandu.min.js" defer></script>

A bare page with the default theme and nothing else is at /pandu/example/; view its source for a complete copy-paste starting point.

HTML structure

This is the whole contract. Class names are the API; IDs are yours to choose.

<header class="pandu pandu--sticky" data-pandu>
  <div class="pandu__bar">
    <a class="pandu__brand" href="/">Your brand</a>

    <!-- Works without JS (opens the drawer with :target); the script turns it into a button -->
    <a class="pandu__toggle" href="#site-menu"><svg …></svg><span>Menu</span></a>

    <nav class="pandu__menu" id="site-menu" aria-label="Main">
      <div class="pandu__drawer-head">
        <a class="pandu__close" href="#" aria-label="Close menu"><span aria-hidden="true">Close</span></a>
      </div>

      <ul class="pandu__list">
        <!-- A plain link -->
        <li class="pandu__item"><a class="pandu__link" href="/journal/">Journal</a></li>

        <!-- A dropdown: the trigger link becomes a disclosure button -->
        <li class="pandu__item">
          <a class="pandu__trigger" href="/rent/">Rent gear</a>
          <div class="pandu__panel" id="panel-rent">
            <ul>
              <li><a class="pandu__overview" href="/rent/">All rental gear</a></li>
              <li><a href="/rent/shelter/">Tents and shelter</a></li>
            </ul>
          </div>
        </li>
      </ul>

      <div class="pandu__actions"><a class="pandu__cta" href="/book/">Book a trip</a></div>
    </nav>
  </div>
</header>
  • Keep the panel straight after its trigger, inside the same <li>.
  • Without JavaScript the trigger stays a link to its section page, and panels open on hover and keyboard focus. With JavaScript it becomes a button, so put the section page inside the panel as its first link (pandu__overview). If you forget, the script adds one for you.
  • Mark the current page on the server with aria-current="page". A trigger whose panel holds the current page is underlined too.

Adding items

Growing the menu never touches the JavaScript.

A link

<li class="pandu__item"><a class="pandu__link" href="/careers/">Careers</a></li>

A link inside a dropdown, with a description

<li><a href="/rent/stoves/">Stoves<span class="pandu__desc">Gas and meths, by the day</span></a></li>

A mega-menu column

Add pandu__panel--mega to the panel; each pandu__col is a column and the grid fits as many as the width allows. Label each list by its heading.

<div class="pandu__panel pandu__panel--mega" id="panel-trips">
  <div class="pandu__col">
    <p class="pandu__heading" id="trips-sabah">Sabah</p>
    <ul aria-labelledby="trips-sabah">
      <li><a href="/trips/kinabalu/">Mount Kinabalu</a></li>
      <li><a href="/trips/danum/">Danum Valley</a></li>
    </ul>
  </div>
  <!-- optional feature card -->
  <a class="pandu__feature" href="/trips/kinta-valley/">
    <img src="/img/lake.webp" alt="" width="480" height="320" loading="lazy">
    <span class="pandu__feature-title">Kinta Valley loop</span>
    <span class="pandu__desc">From RM 180</span>
  </a>
</div>

A dropdown near the right edge can open leftwards with pandu__panel--end.

Your backend and routes

Pandu has no router and never changes a URL. Render the same markup from whatever produces your pages today, and set aria-current where you already know the route. Three examples:

<a href="{{ route('rent.index') }}" @if(request()->routeIs('rent.index')) aria-current="page" @endif>All rental gear</a>
<a href="{% url 'rent' %}" {% if request.resolver_match.url_name == 'rent' %}aria-current="page"{% endif %}>All rental gear</a>
<a href="/rent/" <?= parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) === '/rent/' ? 'aria-current="page"' : '' ?>>All rental gear</a>

If your templates cannot mark the current page, add data-pandu-current="auto" to the header and the script will match the link whose path equals the address bar.

Building from JSON

For a site whose menu lives in a CMS or an API, Pandu.mount() renders the same markup from a config object and starts it. Server-rendered HTML is still the better default, because it works before any script loads.

Pandu.mount(document.getElementById('nav-slot'), {
  brand: { label: 'Lereng', href: '/' },
  items: [
    { label: 'Trips', href: '/trips/', columns: [
      { heading: 'Perak', links: [
        { label: 'Gua Tempurung', href: '/trips/gua-tempurung/', desc: 'Half day' }
      ] }
    ], feature: { title: 'Kinta Valley loop', href: '/trips/kinta-valley/', image: '/img/lake.webp' } },
    { label: 'Rent gear', href: '/rent/', links: [ { label: 'Tents', href: '/rent/shelter/' } ] },
    { label: 'Journal', href: '/journal/' }
  ],
  cta: { label: 'Book a trip', href: '/book/' }
});

Theming

Everything visual is a custom property on .pandu. Set them in your own stylesheet; no need to edit pandu.css. Try them on this page's menu:

PropertyDefaultControls
--pandu-bg / --pandu-fg#ffffff / #1c1b1aBar background and text
--pandu-muted#4a4741Descriptions and column headings
--pandu-accent / --pandu-accent-fg#2a72de / #ffffffCall-to-action button
--pandu-panel-bg / --pandu-panel-fg#ffffff / inheritsDropdowns, mega-menu and drawer
--pandu-hover-bgrgba(28,27,26,.07)Hover and open states
--pandu-focus#2a72deFocus ring colour
--pandu-font / --pandu-font-sizeinherit / 0.9375remType
--pandu-height4.5remBar height
--pandu-max-width / --pandu-gutter80rem / clamp(1rem, 4vw, 2.5rem)Content width and side padding
--pandu-radius / --pandu-item-radius0.875rem / 999pxCorners of panels and of items
--pandu-shadow / --pandu-scrimsoft / 50% blackPanel shadow, drawer backdrop
--pandu-drawer-width26remDrawer width (full width on small phones)
--pandu-duration / --pandu-ease180ms / ease-outMotion
--pandu-z50Stacking order

The mobile breakpoint is the one @media (max-width: 59.99em) line in pandu.css. The script reads the result from the stylesheet, so that line is the only place to change it.

Keyboard map

KeyWide screensDrawer (phones)
Tab / Shift TabMoves through items in reading order, into an open panel and out of it. Leaving a panel closes it. Never trapped.Cycles inside the open drawer; the page behind is inert.
Enter / SpaceOn a trigger, opens or closes its panel. On a link, follows it.On Menu, opens the drawer. On a section, expands or collapses it.
EscCloses the open panel and returns focus to its trigger.Closes the drawer and returns focus to the Menu button.
→ / ←Next or previous top-level item (wraps). From inside a panel, closes it and moves to the neighbouring item.Not used.
↓ / ↑On a trigger, opens the panel and focuses its first or last link. Inside a panel, next or previous link.Next or previous item in the drawer.
Home / EndFirst or last top-level item, or first or last link in the open panel.First or last item in the drawer.

When the drawer opens, focus moves to its first item. A click outside an open panel closes it, and following any link closes everything.

Why the disclosure pattern, not role="menu"

The WAI-ARIA Authoring Practices offer two ways to build site navigation. The menu pattern (role="menubar" and role="menu") is meant for application commands, like a desktop app's File menu. It tells a screen reader to switch into a mode where arrow keys are required, Tab skips over items, and links stop being announced as links. Visitors who expect a website do not expect that.

The disclosure navigation pattern keeps a plain list of links inside a <nav> landmark, with a button that shows and hides each group. Tab works the way it does on every other page, links are announced as links, and arrow keys are an extra shortcut rather than a requirement. Pandu follows it.

  • The drawer is not given role="dialog" so the menu stays a navigation landmark. It behaves like a modal anyway: everything outside it is made inert, the page stops scrolling, and focus goes back to the Menu button on close.
  • Current page is shown with an underline as well as aria-current, never with colour alone.
  • Respects prefers-reduced-motion (no slides or fades) and Windows High Contrast (forced-colors: panels and buttons get system-colour borders).
  • Every target is at least 44 by 44 px, and the bar's height is fixed, so nothing shifts when fonts or the script load.

Script API and events

// Started automatically for every [data-pandu]. Add data-pandu-manual to start it yourself:
const [nav] = Pandu.init('#site-header', { hoverDelay: 150 });

nav.openPanel(nav.items[0]);   // open the first dropdown
nav.closeAll();
nav.openDrawer(); nav.closeDrawer(true); // true returns focus to the Menu button
nav.destroy();

document.addEventListener('pandu:toggle', (e) => console.log(e.detail.open, e.detail.trigger));
document.addEventListener('pandu:drawer', (e) => console.log('drawer open?', e.detail.open));
OptionAttributeDefault
hoverdata-pandu-hover="false"true: panels open on hover with a mouse
hoverDelaydata-pandu-hover-delay120 ms
closeDelaydata-pandu-close-delay300 ms
currentdata-pandu-current="auto"off: the server marks the current page

Browser and device tests

Run on 2026-10-19 with tools/check.mjs (Playwright) against this site: every page at 1440 and 320 px, a full keyboard walkthrough, hover intent, the drawer at 390 and 320 px (focus in, Tab wrap, Esc, Close, scrim, inert, scroll lock), JavaScript switched off, and axe-core (WCAG 2.2 AA plus best practice) on every page at desktop and phone width with a menu open. 213 of 213 checks passed, with no console errors or warnings and no horizontal scroll at 320 px. Edge was not installed on the test machine; it runs the same Chromium engine as Chrome.

BrowserEngineWidthsResult
Google Chrome (installed)Chromium 1541440, 390, 320 px53 / 53 passed
Chromium (Playwright build)Chromium 1551440, 390, 320 px54 / 54 passed, axe-core: 0 violations
FirefoxGecko 1561440, 390, 320 px53 / 53 passed
Safari engineWebKit 26.61440, 390, 320 px (touch, mobile)53 / 53 passed, Option+Tab supported
Microsoft EdgeChromiumn/aNot run separately: same engine as Chrome above

File sizes

FileMinifiedGzipped
pandu.min.css9.5 KB2.5 KB
pandu.min.js13.7 KB4.6 KB

Readable sources sit next to them as pandu.css and pandu.js. MIT licensed.