@agencecinq/drilldown
v1.3.0
Published
Accessible drill-down navigation Web Component: nested panels with back buttons.
Readme
@agencecinq/drilldown
Accessible drill-down navigation as a lightweight Web Component.
A drilldown shows one panel of a nested navigation at a time: a trigger replaces the current panel with its child, a back button returns to the parent. Typical use: a multi-level mobile menu inside a drawer.
<cinq-drilldown> toggles hidden on its panels, syncs aria-expanded on the
triggers, moves focus and dispatches open/close events. Each trigger follows the
WAI-ARIA Authoring Practices disclosure pattern.
Installation
pnpm add @agencecinq/drilldownUsage
Import once:
import "@agencecinq/drilldown";Then write the markup: the component only adds interactivity.
HTML is the source of truth. The component will not auto-set
role, auto-migrate attributes, or warn about missing labels. Run an a11y linter (axe, Lighthouse) to catch invalid markup.
<cinq-drilldown>
<nav id="menu" aria-label="Menu">
<button type="button" aria-expanded="false" aria-controls="menu-solutions">
Solutions
</button>
</nav>
<nav id="menu-solutions" aria-label="Solutions" hidden>
<button type="button" data-close>Back to menu</button>
<a href="/audit">Audit</a>
</nav>
</cinq-drilldown>Required markup
| Attribute / element | Required | Role |
| ------------------- | -------- | ---- |
| Direct children of <cinq-drilldown> | Yes | The panels. Only one is visible at a time. |
| hidden | Yes | On every panel except the visible one. |
| id | Yes | On every child panel, referenced by its trigger. |
| <button aria-controls> | Yes | Trigger inside a panel, controlling a child panel. |
| aria-expanded | Yes | On each trigger: true while its child panel is open. |
| [data-close] | Yes | Back button inside each child panel. |
Panels can be nested any number of levels deep. Give each <nav> panel an
aria-label, and the back button an accessible name that says where it goes.
The component toggles the native hidden attribute. Layout is yours.
Opening on a sub panel
The open path is read from the HTML: the visible panel, and the aria-expanded="true"
triggers leading to it. To render the menu already on a sub panel, hide the root,
show the sub panel and expand its trigger in the markup. No stack is kept in
JavaScript.
API
| Method | Description |
| ------ | ----------- |
| init() | Binds markup + listeners. Call destroy() first if already bound. |
| destroy() | Removes listeners. Safe while the host stays mounted. |
| open(id, trigger?) | Shows panel #id and hides the current one. Returns false if cancelled or invalid. |
| close(focus?) | Goes back to the parent panel. Returns false on the root panel. |
| closeAll() | Goes back to the root panel without moving focus or animating. |
| Property | Description |
| -------- | ----------- |
| panels | Direct children of the host, resolved at connect time. |
| current | The visible panel. |
After adding or removing panels while the host stays mounted:
$host.destroy();
// mutate panels...
$host.init();To reset the menu when its drawer closes, see Inside a drawer.
Events
| Event | Cancelable | Detail | Description |
| ----- | ---------- | ------ | ----------- |
| drilldown:open | Yes | { panel, previous, $button, open: true } | Fired on the host before a child panel opens. |
| drilldown:close | Yes | { panel, previous, $button, open: false } | Fired on the host before going back to the parent. |
panel is the panel about to show, previous the one about to hide, $button the
trigger linking them. Events fire before the DOM mutates. closeAll() fires one
drilldown:close per level.
Animating panels
The component only toggles hidden, so panels switch at once by default. To let CSS
animate them, the host reflects the direction of each change:
| Attribute | Value | When |
| --------- | ----- | ---- |
| data-direction | forward | Set on the host before a child panel opens. |
| data-direction | back | Set on the host before going back to the parent. |
| data-direction | (removed) | After closeAll(), so a reset is not animated. |
It is set only when the event was not cancelled, right before hidden changes, and
stays until the next change. Nothing is animated unless your CSS uses it.
Panels must overlap while one leaves and the other arrives: stack them in the same
grid cell, or position them absolutely inside a relative host. Then transition
display as a discrete value, so the panel getting hidden stays on screen for its
exit, and give the entering panel its first frame with @starting-style:
@media (prefers-reduced-motion: no-preference) {
cinq-drilldown[data-direction] > * {
transition:
opacity 0.3s ease-out,
translate 0.3s ease-out,
display 0.3s allow-discrete;
}
cinq-drilldown[data-direction] > [hidden] {
opacity: 0;
pointer-events: none;
}
cinq-drilldown[data-direction="forward"] > [hidden] {
translate: -1.5rem 0;
}
cinq-drilldown[data-direction="back"] > [hidden] {
translate: 1.5rem 0;
}
@starting-style {
cinq-drilldown[data-direction="forward"] > :not([hidden]) {
opacity: 0;
translate: 1.5rem 0;
}
cinq-drilldown[data-direction="back"] > :not([hidden]) {
opacity: 0;
translate: -1.5rem 0;
}
}
}Drilling in, the parent slides out to the left while the child comes in from the right. Going back, the other way round.
- Browser support:
@starting-styleandtransition-behavior: allow-discreteship in Chrome 117, Safari 17.5 and Firefox 129. Older browsers switch panels at once, as without the CSS. - Tailwind: its preflight hides
[hidden]withdisplay: none !important. The transition still runs, as transitions win over!importantdeclarations. - Focus moves to the new panel when the transition starts.
pointer-events: nonekeeps the leaving panel from catching clicks meanwhile. - Reduced motion: keep the transition inside
@media (prefers-reduced-motion: no-preference), or fade only.
Keyboard & focus
Triggers and back buttons are native <button type="button">: Enter and Space work
through the browser.
- Open: focus moves to the new panel's
[data-autofocus]element if there is one, otherwise to its first focusable element, usually the back button. - Close: focus returns to the trigger that opened the panel.
- closeAll(): focus does not move.
Focus is moved, never trapped. Hidden panels are out of the tab order and the
accessibility tree. To contain focus, put the drilldown in a
@agencecinq/drawer or @agencecinq/modal.
Announcing the panel
Landing on the back button, a screen reader announces the button but not always the
panel it belongs to. To say where the user is, put data-autofocus on the panel's
heading, made focusable with tabindex="-1":
<nav id="menu-solutions" aria-labelledby="menu-solutions-title" hidden>
<button type="button" data-close>Back to menu</button>
<h2 id="menu-solutions-title" tabindex="-1" data-autofocus>Solutions</h2>
<a href="/audit">Audit</a>
</nav>Panels are not dialogs, so they share one autofocus scope: HTML allows a single
autofocus per page, and one per panel fails validation. data-autofocus keeps
each panel's target without touching the page's own autofocus. [autofocus] is
still honoured for existing markup.
Accessibility
- Labels: name every panel (
aria-labeloraria-labelledbyon the<nav>), and give the back button a name that says where it goes ("Back to menu", not "Back"). - Parents that are also pages: an element can't be both a link and a trigger. Put a link to the parent page in its sub panel (e.g. "All solutions"), or a link and a separate trigger side by side, the trigger labelled for what it opens.
- Not a menubar: keep
<nav>,<button>and<a>, withoutmenu/menuitemroles. The APG reserves the menubar pattern for application menus: for site navigation it turns links into menu items and requires arrow-key navigation. - Test with a screen reader: VoiceOver (Safari, macOS and iOS) and NVDA (Firefox or Chrome), going down and back up through every level.
Inside a drawer
The drawer opens a modal <dialog>: the browser makes the rest of the page inert.
Hidden panels are not focusable either, so Tab only cycles through the drawer's own
controls (e.g. its close button) and the visible panel. Nothing else to wire for
focus.
<cinq-drawer id="menu-drawer">
<dialog aria-labelledby="menu-drawer-title">
<header>
<h2 id="menu-drawer-title">Menu</h2>
<cinq-drawer-button>
<button type="button" aria-label="Close menu" aria-controls="menu-drawer" aria-expanded="false">
✕
</button>
</cinq-drawer-button>
</header>
<cinq-drilldown id="menu-drilldown">
<nav id="menu" aria-label="Menu">
<button type="button" aria-expanded="false" aria-controls="menu-solutions">
Solutions
</button>
</nav>
<nav id="menu-solutions" aria-label="Solutions" hidden>
<button type="button" data-close>Back to menu</button>
<a href="/audit">Audit</a>
</nav>
</cinq-drilldown>
</dialog>
</cinq-drawer>Reset the drilldown when the drawer closes, so it reopens on the root panel.
drawer:close is dispatched on document.documentElement with the drawer's ID:
import { EVENTS } from "@agencecinq/utils";
const $drilldown = document.getElementById("menu-drilldown");
document.documentElement.addEventListener(EVENTS.DRAWER_CLOSE, (event) => {
if (event.detail.drawer === "menu-drawer") {
$drilldown.closeAll();
}
});Build setup
pnpm -C packages/drilldown build