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

@aiquants/menu-bar

v1.19.1

Published

A React menu bar component with authentication support and theming

Downloads

231

Readme

@aiquants/menu-bar

A React menu bar component with authentication support and theming for modern web applications.

Key Features

  • Menu Navigation: A drawer that slides in from the right, with top-level links and collapsible groups
  • Current Page Highlighting: The entry the current route belongs to carries aria-current="page" and its group opens, with exact or prefix matching per entry
  • Dark/Light Theme: Comprehensive theming support with scoped CSS
  • Session Management: Built-in session information display with user profiles
  • Pinning: Keep the drawer open while navigating, with the pinned width exposed as a CSS variable and an event
  • Accessible: Named switch with aria-expanded, an inert closed drawer, focus that returns to the switch, visible focus rings, reduced-motion and forced-colors support
  • Configurable Switch: Position, size, layering and caption of the open/close switch set through CSS custom properties
  • TypeScript Support: Full TypeScript definitions included
  • React Router Integration: Built for React Router with generic link component support
  • Scoped Component CSS: The hand-written rules are scoped to .aiquants-* classes and package-prefixed variables; the standalone build also carries Tailwind utilities and theme variables, which are global (see "Styling (CSS)")

Installation

npm install @aiquants/menu-bar

Peer dependencies: react and react-dom ^19.2.7, and react-router-dom ^7.7.1. The package's engines field asks for Node.js 18 or later.

Exports

This package exports the following components, functions and types:

// Components
export { MenuBar } from '@aiquants/menu-bar';
export { SessionInfo, DefaultLink } from '@aiquants/menu-bar';

// Pin state (event and hook)
export { useMenuPinState, subscribeMenuPinChange, dispatchMenuPinChange, getInitialPinState, MENU_PIN_CHANGE_EVENT } from '@aiquants/menu-bar';

// Types
export type { IMenuBarProps } from '@aiquants/menu-bar';
export type { IMenu, ISubmenu, MenuBarPathMatch, MenuNavigationPayload } from '@aiquants/menu-bar';
export type { Theme } from '@aiquants/menu-bar';
export type { SessionInfoProps, LinkProps } from '@aiquants/menu-bar';
export type { UserProfile } from '@aiquants/menu-bar';
export type { MenuPinChangeDetail, MenuPinCustomEvent, SubscribeMenuPinOptions, UseMenuPinStateOptions } from '@aiquants/menu-bar';

// Collision-free aliases of the types above
export type { IMenuBarMenu, IMenuBarSubmenu, MenuBarTheme, MenuBarUserProfile, MenuBarLinkProps, MenuBarSessionInfoProps } from '@aiquants/menu-bar';

// CSS — non-Tailwind hosts load the self-contained standalone build once (Tailwind hosts: see "Styling (CSS)")
import '@aiquants/menu-bar/styles/menu-bar.standalone.css';

Basic Usage

import React, { useState } from 'react';
import { MenuBar, type Theme } from '@aiquants/menu-bar';
import '@aiquants/menu-bar/styles/menu-bar.standalone.css';

function App() {
  const [theme, setTheme] = useState<Theme>('light');

  const menuList = [
    {
      id: 1,
      title: 'Dashboard',
      href: '/dashboard',
      submenu: []
    },
    {
      id: 2,
      title: 'Data',
      submenu: [
        { subid: 1, title: 'Reports', href: '/data/reports' },
        { subid: 2, title: 'Analytics', href: '/data/analytics' }
      ]
    }
  ];

  const toggleTheme = () => {
    setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
  };

  const handleSignout = () => {
    console.log('Signing out...');
  };

  return (
    <div className="app">
      <MenuBar
        menuList={menuList}
        theme={theme}
        toggleTheme={toggleTheme}
        onClickSignout={handleSignout}
        menuHeader={<div>My App</div>}
      />
      {/* Your app content */}
    </div>
  );
}

MenuBar renders React Router links, so place it inside a router provider.

Props

MenuBar Props

| Prop | Type | Required | Description | | --- | --- | --- | --- | | menuList | IMenu[] | Yes | Array of menu items to display | | theme | 'light' \| 'dark' | Yes | Theme mode for the menu bar | | toggleTheme | () => void | Yes | Function to toggle between light and dark themes | | onClickSignout | () => void | Yes | Callback function for sign out action | | menuHeader | React.ReactNode | No | Optional header content for the menu | | isMenuHide | boolean | No | Whether to hide the menu bar entirely. Renders nothing while true, but the pin state is still reported: while pinned, --aiquants-menu-pinned-width and the pin event keep the pinned width (see "Pin State") | | isMenuhide | boolean | No | Legacy spelling of isMenuHide; read only when isMenuHide is not given | | onMenuNavigate | (payload: MenuNavigationPayload) => void | No | Callback function triggered when a menu or submenu item is selected | | isPinned | boolean | No | Controlled pin state. When omitted, the menu bar keeps the pin state itself and stores it in localStorage | | pinnedWidth | number | No | Width in pixels reported while pinned (default 240) | | onPinChange | (pinned: boolean, widthPx: number) => void | No | Called when the pin button toggles the pin state, and with (false, 0) whenever the switch, the backdrop or Escape closes the drawer (not when selecting an item closes it) | | pinnedSubmenuIds | number[] | No | Pinned submenu IDs (controlled) | | initialPinnedSubmenuIds | number[] | No | Initial pinned submenu IDs | | onSubmenuPinChange | (pinnedIds: number[]) => void | No | Callback function triggered when submenu pin states change | | closeOnEscape | boolean \| "auto" | No | Controls whether the Escape key closes the menu. "auto" (default) closes only when not pinned; true always closes; false never closes |

IMenu Type

interface IMenu {
  id: number;
  title: string;
  href?: string;
  submenu: ISubmenu[];
  match?: MenuBarPathMatch; // ignored on a group without href
}

ISubmenu Type

interface ISubmenu {
  subid: number;
  title?: string;
  href: string;
  mark?: string;
  new?: boolean;
  match?: MenuBarPathMatch;
}

MenuBarPathMatch Type

type MenuBarPathMatch = 'exact' | 'prefix';

How an entry's href is matched against the current path. See "Current Page Highlighting" below.

MenuNavigationPayload Type

type MenuNavigationPayload = {
  menu: Pick<IMenu, 'id' | 'title' | 'href'>;
  submenu?: Pick<ISubmenu, 'subid' | 'title' | 'href' | 'mark' | 'new'>;
};

UserProfile Type

interface UserProfile {
  displayName: string;
  photo: string;
  id?: string;
}

SessionInfoProps Type

interface SessionInfoProps {
  user: UserProfile | null;
  LinkComponent?: React.ComponentType<LinkProps>;
}

interface LinkProps {
  to: string;
  className?: string;
  children: React.ReactNode;
}

Styling (CSS)

The package ships two CSS artifacts — pick the one that matches your host.

  • Tailwind v4 host — import the components-only build into the components layer, and let your own Tailwind build generate the JSX utility classes by scanning the package's built JavaScript (the published package ships dist, not src; the class names stay intact in the bundle):

    /* app tailwind.css */
    @import "@aiquants/menu-bar/styles/menu-bar.css" layer(components);
    @source "../node_modules/@aiquants/menu-bar/dist/**/*.js";
    /* consuming the package's sources from a workspace: point @source at its src/**/*.{ts,tsx} instead */
    @custom-variant dark (&:where(.dark, .dark *));

    The @custom-variant line makes the menu bar's dark: utilities follow the theme prop: the menu bar's root carries .dark while theme is 'dark'. With Tailwind's default dark variant (prefers-color-scheme), they follow the OS setting instead. The variant applies to your whole build, so your own dark: utilities then key off a .dark ancestor as well.

    menu-bar.css (Artifact A) carries only the hand-written .aiquants-* component classes and the package's own :root variables — no Tailwind theme variables, no Tailwind utilities, no preflight — so it drops into layer(components) safely. Its :root rule declares menu-bar's runtime variable --aiquants-menu-pinned-width (which the component sets inline at runtime) and the defaults of the menu switch custom properties (--aqmb-switch-*, see below). Do not also @import the standalone build in a Tailwind host: it re-emits the same utilities your build produces, and two concatenated builds cannot reproduce the single-build canonical order (base → variants), so base/variant cascade winners flip.

  • Non-Tailwind host — load the single self-contained standalone build (via <link> or a plain @import); it bundles the component classes plus every JSX utility:

    @import "@aiquants/menu-bar/styles/menu-bar.standalone.css";

    The utilities in this build are global selectors (.flex, .fixed, .hidden, ...), and it declares the Tailwind theme variables it uses on :root, :host, so a host element with one of those class names picks up the rule.

    Dark mode follows the theme prop: the menu bar's root carries .dark while theme is 'dark', and this build's dark: utilities apply to an element with the .dark class or inside one, so <html> needs no class. A SessionInfo rendered outside MenuBar takes its dark styles from a .dark ancestor of its own. If you also put .dark on <html>, keep it in step with theme: with theme="light" it turns on the dark: utilities inside the light menu bar while the light colours of the hand-written rules stay, mixing the two palettes.

In both setups the component classes sit in the components layer and the utilities in the later utilities layer, so a utility wins over a component rule for the same property.

Menu Switch Custom Properties

The open/close switch is a square box with four bars that turn into a cross while the drawer is open, over a "menu" / "close" caption. Its whole drawing lives in the package stylesheet, and it carries no utility classes, so every custom property below takes effect. The defaults are declared on :root; override them on :root (or any ancestor of the menu bar).

| Property | Default | Effect | | --- | --- | --- | | --aqmb-switch-position | fixed | position of the switch | | --aqmb-switch-top | 6px | top of the switch | | --aqmb-switch-right | 8px | right of the switch | | --aqmb-switch-left | auto | left of the switch | | --aqmb-switch-bottom | auto | bottom of the switch | | --aqmb-switch-z-index | calc(infinity) | z-index of the switch. The default computes to the largest integer the browser supports (2147483647 in Chromium), which keeps the switch above the drawer (z-index 2147483642) and the backdrop (2147483640) | | --aqmb-switch-size | 27px | Side S of the switch's square box; every length of the drawing derives from it | | --aqmb-switch-caption-display | block | display of the caption; none hides it |

The z-index default is calc(infinity) rather than a number because Lightning CSS, which Tailwind v4 builds pass stylesheets through, writes the numbers of custom-property values and var() fallbacks with 6 significant digits: 2147483647 would reach the browser as 2147480000, below the drawer. An override in a stylesheet that also goes through Lightning CSS is rounded the same way when it has 7 or more significant digits. A small value such as 50 is kept as written, and stacks the switch below the drawer and the backdrop.

How the drawing follows S:

  • The bars are S/9 thick, at 0, 2S/9, 4S/9 and 6S/9 of a stack 7S/9 high. While open, the outer bars become 32S/27 wide and 4S/27 thick and cross at ±45°; the inner bars fade out.
  • The caption's top edge is S/2 + 0.5rem below the top of the stack.
  • With the caption shown (the default) and S below 48px (at a 16px root font size), the column of bars and caption is taller than the box: it starts at the box's top and the caption hangs below the box.
  • With --aqmb-switch-caption-display: none, the bars are centred in the box and nothing is drawn above or below it. The cross can still extend slightly past the box's right edge, as it does in the default drawing.
  • The default 27px gives 3px bars spaced 6px apart, a 32px × 4px cross, and a caption 21.5px below the top of the box.
  • The switch draws in black, and in white on the dark theme and while the drawer is open. Its keyboard focus ring is a 2px outline in the same colour at a 2px offset, so it occupies 4px around the box.

Example — fit the switch into a 32px header bar (4px + 24px line box + 4px):

:root {
  --aqmb-switch-size: 24px;
  --aqmb-switch-caption-display: none;
  --aqmb-switch-top: 4px;
}

The box sits on the bar's 24px line box, and the focus ring (0 to 32px from the top) stays inside the bar. Adjust --aqmb-switch-right to align it horizontally.

Theming

The menu bar supports both light and dark themes. The hand-written styles are scoped with the .aiquants-menu-bar class; the utility classes and Tailwind theme variables of the standalone build are global (see "Styling (CSS)"). The root element carries the theme as a class: .aiquants-menu-bar.light or .aiquants-menu-bar.dark.

Custom Styling

You can override the default styles by targeting the scoped classes:

.aiquants-menu-bar {
  /* Custom menu bar styles */
}

.aiquants-menu-bar.dark {
  /* Dark theme overrides */
}

.aiquants-menu-bar.light {
  /* Light theme overrides */
}

Usage with React Router

import React, { useState } from 'react';
import { MenuBar, type Theme } from '@aiquants/menu-bar';

function App() {
  const [theme, setTheme] = useState<Theme>('light');

  const menuList = [
    {
      id: 1,
      title: 'Home',
      href: '/',
      submenu: []
    },
    {
      id: 2,
      title: 'Products',
      submenu: [
        { subid: 1, title: 'All Products', href: '/products' },
        { subid: 2, title: 'Categories', href: '/products/categories' }
      ]
    }
  ];

  return (
    <MenuBar
      menuList={menuList}
      theme={theme}
      toggleTheme={() => setTheme(theme === 'light' ? 'dark' : 'light')}
      onClickSignout={() => console.log('Signout')}
    />
  );
}

Current Page Highlighting

The menu finds the one entry the current route belongs to, marks its link with aria-current="page", and opens its group. Each top-level item with an href and each submenu item can choose how its href is matched with match:

const menuList: IMenu[] = [
  { id: 1, title: 'Home', href: '/', submenu: [] },
  {
    id: 2,
    title: 'Admin',
    submenu: [
      // Also the current entry on /admin/console/search, /admin/console/users/42, ...
      { subid: 1, title: 'Console', href: '/admin/console', match: 'prefix' },
      { subid: 2, title: 'Audit log', href: '/admin/audit' },
    ],
  },
];

The rule:

  • 'exact' (the default) matches the path itself. 'prefix' also matches every path below the href at a segment boundary: /admin matches /admin/users, not /administration. Any other value behaves as 'exact'.
  • The query, the hash and a trailing slash are ignored on both the href and the path.
  • An href of / matches only the root, even with 'prefix'. External URLs never match.
  • When several entries match, the longest href wins, across groups too; an exact match therefore always beats a prefix match. On a tie, the first entry in menu order wins (a top-level item before its own submenu items).

The group of the matched entry opens once per route change, and only one group is open at a time (pinned groups always stay open). When the matched entry is a top-level link, no group opens, and the group that was open closes. A route with no matching entry leaves the open group as it is, and reloading the menu on the same route does not reopen a group the user closed.

Pin State

The pin button keeps the drawer open, without the backdrop, while the user navigates. When it mounts and whenever the pin state, pinnedWidth or the open state changes, the menu bar reports the state in two ways, and it resets both to "not pinned" when it unmounts:

  • CSS variable: --aiquants-menu-pinned-width on <html> holds the pinned width (pinnedWidth, default 240) in pixels while pinned, and 0px otherwise.
  • Event: a CustomEvent named by MENU_PIN_CHANGE_EVENT ("aiquants-menu-pin-change") on window. Its detail is a MenuPinChangeDetail, { pinned, width, active? }: width matches the CSS variable, and active tells whether the drawer is open or pinned.

These exports read the state:

| Export | Behaviour | | --- | --- | | useMenuPinState(options?) | Returns the current MenuPinChangeDetail and re-renders when it changes. Options: initialPinned (default false) and defaultPinnedWidth (default 240). During server rendering it returns { pinned: initialPinned, width: initialPinned ? defaultPinnedWidth : 0 }, without active | | subscribeMenuPinChange(listener, options?) | Calls listener with the detail of every event and returns a function that unsubscribes. Options: immediate (default true: also calls listener once right away with getInitialPinState(defaultPinnedWidth)) and defaultPinnedWidth (default 240) | | getInitialPinState(defaultPinnedWidth = 240) | Reads the CSS variable: pinned when it is a positive number of pixels, which is then the width. When the variable is empty or not a number, it reads the aiquants-menu-pinned localStorage key instead ("true": pinned at defaultPinnedWidth); the package stylesheet declares the variable as 0px, so this happens only while neither the stylesheet nor a menu bar has set it. active is that of the last dispatchMenuPinChange call (false before the first) | | dispatchMenuPinChange(pinned, width, active = pinned) | Dispatches the event on window. The menu bar calls it itself |

import type { ReactNode } from 'react';
import { useMenuPinState } from '@aiquants/menu-bar';

function Page({ children }: { children: ReactNode }) {
  const { width } = useMenuPinState();
  // The pinned drawer covers the right edge of the viewport
  return <main style={{ paddingRight: width }}>{children}</main>;
}

Accessibility

  • Switch: a native <button> named "Open menu" / "Close menu" (aria-label) with aria-expanded; the visible caption is aria-hidden. Keyboard focus shows a 2px ring (:focus-visible).
  • Closed drawer is inert: while closed, nothing in the drawer takes focus (by Tab or by script) or appears in the accessibility tree, so the switch is the menu bar's only Tab stop. A pinned drawer stays open and interactive. This relies on the HTML inert attribute.
  • Decorative backdrop: the dimming layer behind the open drawer is an aria-hidden div that never takes focus. A pointer press on it closes the drawer without moving the focus; keyboard and assistive-technology users close the drawer with Escape (per closeOnEscape) or the switch.
  • Focus return: when the drawer closes — by the switch, the backdrop, Escape or selecting an item while not pinned — with the focus inside it, the focus moves to the switch. A focus outside the drawer stays where it is.
  • Current page: the matched entry's link carries aria-current="page".
  • Groups: group summaries toggle with Enter or Space and show a focus outline on keyboard focus. Every button has an accessible name.
  • Reduced motion: the drawer's slide, the backdrop's fade and the switch's transitions run only under prefers-reduced-motion: no-preference.
  • Forced colors: the switch's bars are drawn in ButtonText, so they stay visible when forced colors replace backgrounds.
  • Built-in labels: the names and texts are fixed, and no prop localises them. The switch is named in English ("Open menu" / "Close menu", caption "menu" / "close"); the drawer's pin and theme buttons, the sign-out button and the group pin buttons are named in Japanese (for example "ピン留め", "ダークモードに切り替え", "サインアウト"), and SessionInfo shows Japanese texts such as "プロフィール" and "ログインしてください".

DOM Hooks

Select the menu bar's elements in scripts and tests by these attributes (or by role and accessible name). Class names — .aiquants-* and utilities — are styling and may change.

| Attribute | Element | | --- | --- | | data-aqmb-menu-switch | The open/close switch button | | data-aqmb-menu-content | The drawer's root element | | data-aqmb-menu-overlay | The backdrop | | data-aqmb-menu-list | The scroll container of the menu list | | data-aqmb-pin-button | The drawer's pin button | | data-aqmb-theme-button | The theme toggle button | | data-aqmb-signout-button | The sign-out button | | data-aqmb-submenu-pin-button | The pin button of each group | | data-aqmb-session-card | The SessionInfo card, with or without a user |

Advanced Usage

With Menu Header

import React from 'react';

const menuHeader = (
  <div className="flex items-center gap-2 p-4">
    <img src="/logo.png" alt="Logo" className="w-8 h-8" />
    <span className="font-bold">My Application</span>
  </div>
);

<MenuBar
  menuList={menuList}
  menuHeader={menuHeader}
  theme={theme}
  toggleTheme={toggleTheme}
  onClickSignout={handleSignout}
/>

Hiding the Menu

import React from 'react';

<MenuBar
  menuList={menuList}
  theme={theme}
  toggleTheme={toggleTheme}
  onClickSignout={handleSignout}
  isMenuHide={shouldHideMenu}
/>

Components

MenuBar

The main menu bar component that includes:

  • Open/close switch with four bars that turn into a cross
  • Header slot (menuHeader) and navigation menu
  • Pin, theme toggle and sign-out buttons
  • Backdrop that closes the drawer when pressed
  • Focus management and current page highlighting

SessionInfo

A standalone session information component that displays:

  • The user's avatar (a default icon when photo is empty), display name and a "Google Account" label
  • Two links rendered through the customizable LinkComponent (default: DefaultLink, a plain <a>): a profile link to / and a link to https://myaccount.google.com/
  • A "please sign in" card ("ログインしてください") when user is null

The card carries data-aqmb-session-card in both states. Sign-out is not part of SessionInfo: it is the drawer's sign-out button, which calls onClickSignout.

import { SessionInfo } from '@aiquants/menu-bar';

<SessionInfo
  user={{
    displayName: "John Doe",
    photo: "https://example.com/avatar.jpg"
  }}
  LinkComponent={({ to, children, ...props }) => (
    <a href={to} {...props}>{children}</a>
  )}
/>

Menu Features

  • Hierarchical Menu Structure: Support for main menu items with submenus
  • Dynamic Navigation: Menu items can have direct links or submenu collections
  • Current Page: Exact or prefix matching per entry, with aria-current="page"
  • Visual Indicators: An icon per submenu mark (report, slip, table, chart, download, lock, href); any other value, or none, renders the text only
  • External Links: new: true opens a submenu item in a new tab (target="_blank", rel="noopener noreferrer"); it draws no badge

CSS Classes

Hand-written component classes use the .aiquants- prefix (the modifiers are the theme class light / dark on the root and pinned on a pinned group's pin button), for example:

  • .aiquants-menu-bar - Main container (with the theme class light or dark)
  • .aiquants-menu-switch - Open/close switch (.aiquants-menu-switch-bars, .aiquants-menu-switch-bar, .aiquants-menu-switch-caption)
  • .aiquants-menu-content - Drawer
  • .aiquants-menu-list-item - Menu item and group summary
  • .aiquants-session-card - Session information card

The menu list's scroll container also carries hidden-scrollbar, a class the package does not define, so a host rule of that name applies to it.

These classes are a styling contract, not DOM hooks — use the data-aqmb-* attributes above to select elements.

Browser Support

  • Chrome/Edge 111+
  • Firefox 128+
  • Safari 16.4+

These are the versions Tailwind CSS v4, which builds both stylesheets, targets. The stylesheets also sit in cascade layers, which an older browser drops whole, and the closed drawer and the backdrop rely on the HTML inert attribute.

Development

# Install dependencies
pnpm install

# Build the package
pnpm run build

# Run type checking
pnpm run typecheck

# Run the unit tests (Vitest, jsdom)
pnpm run test

# Run the browser tests (Playwright, Chromium) against a local harness page
pnpm run test:e2e

# Clean build files
pnpm run clean

The browser tests start a Vite server for the harness on port 25178; set MENU_BAR_E2E_PORT to use another port, and MENU_BAR_E2E_REUSE_SERVER=1 to reuse a server that is already running. Install Chromium once with pnpm exec playwright install chromium if it is missing.

License

MIT License - see LICENSE file for details.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

Author

fehde-k