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.
What it covers
Each line of a typical "modernise our menu" brief, and where Pandu answers it.
| The brief asks for | How Pandu does it |
|---|---|
| Component-based, clean HTML5, CSS3, lightweight JS | One <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 states | 180 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-menu | Both. 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 hamburger | Below 960 px a labelled Menu button opens an off-canvas drawer. Sections inside become accordions. |
| WCAG keyboard navigation, ARIA, tab order | WAI-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 routes | Every 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 px | See the test matrix. |
| Setup notes and a demo | This page, and the recording above. |
| No console errors, passes accessibility checks | Every 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:
| Property | Default | Controls |
|---|---|---|
--pandu-bg / --pandu-fg | #ffffff / #1c1b1a | Bar background and text |
--pandu-muted | #4a4741 | Descriptions and column headings |
--pandu-accent / --pandu-accent-fg | #2a72de / #ffffff | Call-to-action button |
--pandu-panel-bg / --pandu-panel-fg | #ffffff / inherits | Dropdowns, mega-menu and drawer |
--pandu-hover-bg | rgba(28,27,26,.07) | Hover and open states |
--pandu-focus | #2a72de | Focus ring colour |
--pandu-font / --pandu-font-size | inherit / 0.9375rem | Type |
--pandu-height | 4.5rem | Bar height |
--pandu-max-width / --pandu-gutter | 80rem / clamp(1rem, 4vw, 2.5rem) | Content width and side padding |
--pandu-radius / --pandu-item-radius | 0.875rem / 999px | Corners of panels and of items |
--pandu-shadow / --pandu-scrim | soft / 50% black | Panel shadow, drawer backdrop |
--pandu-drawer-width | 26rem | Drawer width (full width on small phones) |
--pandu-duration / --pandu-ease | 180ms / ease-out | Motion |
--pandu-z | 50 | Stacking 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
| Key | Wide screens | Drawer (phones) |
|---|---|---|
| Tab / Shift Tab | Moves 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 / Space | On a trigger, opens or closes its panel. On a link, follows it. | On Menu, opens the drawer. On a section, expands or collapses it. |
| Esc | Closes 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 / End | First 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 madeinert, 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));| Option | Attribute | Default |
|---|---|---|
hover | data-pandu-hover="false" | true: panels open on hover with a mouse |
hoverDelay | data-pandu-hover-delay | 120 ms |
closeDelay | data-pandu-close-delay | 300 ms |
current | data-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.
| Browser | Engine | Widths | Result |
|---|---|---|---|
| Google Chrome (installed) | Chromium 154 | 1440, 390, 320 px | 53 / 53 passed |
| Chromium (Playwright build) | Chromium 155 | 1440, 390, 320 px | 54 / 54 passed, axe-core: 0 violations |
| Firefox | Gecko 156 | 1440, 390, 320 px | 53 / 53 passed |
| Safari engine | WebKit 26.6 | 1440, 390, 320 px (touch, mobile) | 53 / 53 passed, Option+Tab supported |
| Microsoft Edge | Chromium | n/a | Not run separately: same engine as Chrome above |
File sizes
| File | Minified | Gzipped |
|---|---|---|
| pandu.min.css | 9.5 KB | 2.5 KB |
| pandu.min.js | 13.7 KB | 4.6 KB |
Readable sources sit next to them as pandu.css and pandu.js. MIT licensed.