npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/drilldown

Usage

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-style and transition-behavior: allow-discrete ship in Chrome 117, Safari 17.5 and Firefox 129. Older browsers switch panels at once, as without the CSS.
  • Tailwind: its preflight hides [hidden] with display: none !important. The transition still runs, as transitions win over !important declarations.
  • Focus moves to the new panel when the transition starts. pointer-events: none keeps 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-label or aria-labelledby on 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>, without menu / menuitem roles. 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