# Navigation Menu

> A site's navigation: links, plus buttons that show and hide links of their own. It's the APG's disclosure navigation, not a menu.

`<slab-navigation-menu>` · Navigation · built on `<nav> + <button aria-expanded>`

## Install

```sh
pnpm add slab-elements lit
```

## Usage

### React

`MyNavigationMenu.jsx`. React 19 or later, which sets props on custom elements as properties.

```jsx
import 'slab-elements/navigation-menu.js';
import 'slab-elements/navigation-menu.item.js';

export function MyNavigationMenu() {
  return (
    <slab-navigation-menu label="Main">
      <a href="/" aria-current="page">Home</a>

      <slab-navigation-menu-item label="Products">
        <a href="/products/mixers/">Mixers</a>
        <a href="/products/trowels/">Trowels</a>
      </slab-navigation-menu-item>

      <a href="/contact/">Contact</a>
    </slab-navigation-menu>
  );
}
```

### Lit

`my-navigation-menu.ts`. Slab is built on Lit, so its elements are at home in Lit's templates.

```ts
import 'slab-elements/navigation-menu.js';
import 'slab-elements/navigation-menu.item.js';
import { html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';

@customElement('my-navigation-menu')
export class MyNavigationMenu extends LitElement {
  render() {
    return html`
      <slab-navigation-menu label="Main">
        <a href="/" aria-current="page">Home</a>

        <slab-navigation-menu-item label="Products">
          <a href="/products/mixers/">Mixers</a>
          <a href="/products/trowels/">Trowels</a>
        </slab-navigation-menu-item>

        <a href="/contact/">Contact</a>
      </slab-navigation-menu>
    `;
  }
}
```

### Vue

`MyNavigationMenu.vue`. Let Vue's compiler know slab- tags are custom elements with isCustomElement.

```vue
<script setup>
import 'slab-elements/navigation-menu.js';
import 'slab-elements/navigation-menu.item.js';
</script>

<template>
  <slab-navigation-menu label="Main">
    <a href="/" aria-current="page">Home</a>

    <slab-navigation-menu-item label="Products">
      <a href="/products/mixers/">Mixers</a>
      <a href="/products/trowels/">Trowels</a>
    </slab-navigation-menu-item>

    <a href="/contact/">Contact</a>
  </slab-navigation-menu>
</template>
```

### Svelte

`MyNavigationMenu.svelte`. Svelte binds properties and events to custom elements like any other element.

```svelte
<script>
  import 'slab-elements/navigation-menu.js';
  import 'slab-elements/navigation-menu.item.js';
</script>

<slab-navigation-menu label="Main">
  <a href="/" aria-current="page">Home</a>

  <slab-navigation-menu-item label="Products">
    <a href="/products/mixers/">Mixers</a>
    <a href="/products/trowels/">Trowels</a>
  </slab-navigation-menu-item>

  <a href="/contact/">Contact</a>
</slab-navigation-menu>
```

### HTML

`index.html`. Any bundler (or an import map) takes care of the imports.

```html
<script type="module">
  import 'slab-elements/navigation-menu.js';
  import 'slab-elements/navigation-menu.item.js';
</script>

<slab-navigation-menu label="Main">
  <a href="/" aria-current="page">Home</a>

  <slab-navigation-menu-item label="Products">
    <a href="/products/mixers/">Mixers</a>
    <a href="/products/trowels/">Trowels</a>
  </slab-navigation-menu-item>

  <a href="/contact/">Contact</a>
</slab-navigation-menu>
```

## Example

Here's a styled version, `<slab-styled-navigation-menu>`. It's not in the package — it's an example of what you can build with the headless element. Copy it and make it yours at the bottom of this page.

```html
<slab-styled-navigation-menu label="Main">
  <a href="#home" aria-current="page">Home</a>

  <slab-styled-navigation-menu-item label="Services">
    <a href="#pouring">Pouring</a>
    <a href="#finishing">Finishing</a>
    <a href="#repair">Repair</a>
  </slab-styled-navigation-menu-item>

  <slab-styled-navigation-menu-item label="Company">
    <a href="#about">About</a>
    <a href="#careers">Careers</a>
  </slab-styled-navigation-menu-item>

  <a href="#contact">Contact</a>
</slab-styled-navigation-menu>
```

Swap `slab-styled-` for `slab-` in that markup and you've got the same example as it ships: the headless elements with no CSS at all. That's your starting point.

Each link and Navigation Menu Item gets an `<li>` of its own, and so does each link in an Item. An Item's content is in the flow, right below its button, and nothing positions it, so a dropdown is a design's: give `::part(content)` a `position: absolute` and the Item a `position: relative`, or leave the content in place for a menu on a phone. Opening one Item closes the others, and a click outside an Item, or focus leaving it, closes it too.

It isn't a [Menubar](/components/menubar/). A `[role=menu]` is for commands, like a desktop app's, and tells a screenreader user to expect only the arrow keys. Here the links are links, the buttons are buttons with `aria-expanded`, and Tab goes through all of them. Mark the current page's link with `aria-current="page"`.

## Keyboard

| Key          | What it does                                                                                             |
| ------------ | -------------------------------------------------------------------------------------------------------- |
| Tab          | Moves through the links and buttons in order, including an open Item's links. Leaving an Item closes it. |
| Enter, Space | On an Item's button, opens or closes it.                                                                 |
| Down         | On an Item's button, opens it at its first link.                                                         |
| Up, Down     | In an open Item, moves to the previous or next link. Up from the first goes to the button.               |
| Home, End    | In an open Item, moves to its first or last link. In the row, to the first or last link or button.       |
| Left, Right  | In the row, moves to the previous or next link or button, wrapping.                                      |
| Escape       | Closes the open Item and returns focus to its button.                                                    |

## Behavior

### `<slab-navigation-menu>`

A Navigation Menu with no visual styles: a site's navigation, as a `<nav>` around a `<ul>` of links and Navigation Menu Items, each Item a button that shows and hides links of its own.

It's the APG's disclosure navigation, not a menu. A `[role=menu]` is for commands, like a desktop app's, and tells a screenreader user to expect the arrow keys and nothing else. Here the links are links, the buttons are buttons with `aria-expanded`, and Tab goes through all of them in order.

Each element slotted into it gets an `<li>` of its own, as in Breadcrumb. Opening one Item closes the others, and the arrow keys are a shortcut on top of Tab: Left and Right move between the links and Items' buttons in the row, wrapping at each end, and Home and End go to the first and last.

Mark the link to the current page with `aria-current="page"`.

### `<slab-navigation-menu-item>`

A Navigation Menu Item with no visual styles: a button that shows and hides a list of links, the APG's disclosure navigation. It's not a menu. The button is a button with `aria-expanded`, the links are links in a list, and Tab goes through them in order.

Each element slotted into it gets an `<li>` of its own in a `<ul>`, as each in a Navigation Menu does. That's usually a link, but a design can slot in anything, such as a `<div>` with a heading and a short description.

The content is in the flow, below the button, so a design positions it, such as absolutely below the button for a dropdown, or left in place for a menu on a phone. It's `display: none` while closed.

Escape closes it and returns focus to the button. A click outside it, or focus leaving it by the keyboard, closes it too. In a Navigation Menu, opening one Item closes the others.

Down, on the button, opens it at the first link, and Up and Down move between the links. Up from the first goes back to the button.

## API

### `<slab-navigation-menu>`

#### Attributes

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `label` (required) | `string` |  | Names the navigation. |
| `version` | `string` |  |  |

#### Properties

| Name | Type | Description |
| --- | --- | --- |
| `items` (read-only) | `Element[]` |  |

#### Slots

| Name | Type | Description |
| --- | --- | --- |
| `(default)` | `Element` | The links and Navigation Menu Items, each in an \`<li>\` of its own |

#### CSS parts

| Name | Description |
| --- | --- |
| `item` |  |
| `list` | The links and Items |
| `nav` | The navigation |

### `<slab-navigation-menu-item>`

#### Attributes

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `label` (required) | `string` |  | The button's text and so its accessible name. |
| `open` | `boolean` | `false` |  |
| `version` | `string` |  |  |

#### Properties

| Name | Type | Description |
| --- | --- | --- |
| `items` (read-only) | `Element[]` |  |

#### Methods

| Signature | Description |
| --- | --- |
| `click(): void` |  |
| `focus(options?: FocusOptions): void` |  |
| `hide(): void` |  |
| `show(): void` |  |
| `toggle(): void` |  |

#### Events

| Name | Type | Description |
| --- | --- | --- |
| `toggle` | `ToggleEvent` |  |

#### Slots

| Name | Type | Description |
| --- | --- | --- |
| `(default)` | `Element` | The links, each in an \`<li>\` of its own |

#### CSS parts

| Name | Description |
| --- | --- |
| `content` | The content shown when open |
| `item` |  |
| `list` | The links |
| `trigger` | The button that shows and hides the content |

## Copy and own it

`<slab-styled-navigation-menu>` isn't in the package. It's an example — copy it into your project and change whatever you want. Keyboard, focus, ARIA, forms, and events all come from the headless element, and the styles only reach in through `::part()` and `:state()`, same as yours would.

### Lit

Under the hood, it's just the headless element, subclassed, with `styles` set. That's it. To use it, copy these files, swap the tag in `@customElement()` for one of your own, and import it. `tokens.styles.ts` holds the colors and fonts every styled example shares, so you only need it once.

#### TypeScript

These files are TypeScript, and `@customElement()` is one of Lit's decorators (TypeScript's experimental flavor). You'll need two options in your tsconfig, per [Lit's docs](https://lit.dev/docs/components/decorators/#decorators-typescript): `experimentalDecorators` to compile them, and `useDefineForClassFields` turned off so class fields don't stomp on the accessors Lit sets up.

```json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "useDefineForClassFields": false
  }
}
```

Using plain JavaScript? Drop the types and register the class with `customElements.define()` instead of the decorator.

#### navigation-menu.ts

```ts
import { customElement } from 'lit/decorators.js';
import NavigationMenu from 'slab-elements/navigation-menu.js';
import styles from './navigation-menu.styles.js';
import tokens from './tokens.styles.js';

declare global {
  interface HTMLElementTagNameMap {
    'slab-styled-navigation-menu': StyledNavigationMenu;
  }
}

/**
 * Navigation Menu, styled: a raised bar of slab-serif links and buttons, with
 * the current page underlined in ember.
 */
@customElement('slab-styled-navigation-menu')
export default class StyledNavigationMenu extends NavigationMenu {
  static override styles = [tokens, styles];
}
```

#### navigation-menu.styles.ts

```ts
import { css } from 'lit';

/*
  The links in the row are slotted in, so they're styled through
  "::slotted()". Each Item styles its own button to match.
*/
export default css`
  [part~='list'] {
    background-color: var(--paper);
    border: var(--border);
    box-shadow: var(--shadow);
    display: flex;
    flex-wrap: wrap;
    gap: 0.25rem;
    inline-size: fit-content;
    list-style: none;
    margin: 0;
    padding: 0.25rem;
  }

  ::slotted(a) {
    border: 2px solid transparent;
    color: var(--ink);
    display: block;
    font-family: var(--font-display);
    font-size: 0.9375rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    padding: 0.375rem 0.75rem;
    text-decoration: none;
    text-transform: uppercase;
  }

  ::slotted(a:hover) {
    background-color: var(--chalk);
  }

  ::slotted(a:focus-visible) {
    outline: 3px solid var(--ember);
    outline-offset: 2px;
  }

  ::slotted([aria-current='page']) {
    text-decoration-color: var(--ember);
    text-decoration-line: underline;
    text-decoration-thickness: 2px;
    text-underline-offset: 0.3em;
  }
`;
```

#### navigation-menu.item.ts

```ts
import { customElement } from 'lit/decorators.js';
import NavigationMenuItem from 'slab-elements/navigation-menu.item.js';
import styles from './navigation-menu.item.styles.js';
import tokens from './tokens.styles.js';

declare global {
  interface HTMLElementTagNameMap {
    'slab-styled-navigation-menu-item': StyledNavigationMenuItem;
  }
}

/**
 * Navigation Menu Item, styled: its button in ink while open, over a raised
 * panel of links that drops down below it.
 */
@customElement('slab-styled-navigation-menu-item')
export default class StyledNavigationMenuItem extends NavigationMenuItem {
  static override styles = [tokens, styles];
}
```

#### navigation-menu.item.styles.ts

```ts
import { css } from 'lit';

/*
  The content drops down below the button, over what's below the menu,
  rather than pushing it down.
*/
export default css`
  :host {
    position: relative;
  }

  [part~='trigger'] {
    align-items: center;
    background-color: transparent;
    border: 2px solid transparent;
    color: var(--ink);
    cursor: pointer;
    display: flex;
    font-family: var(--font-display);
    font-size: 0.9375rem;
    font-weight: 600;
    gap: 0.375rem;
    letter-spacing: 0.04em;
    padding: 0.375rem 0.75rem;
    text-transform: uppercase;

    /*
      The marker. Its text is decorative, so it's given empty alternative
      text: the button's expanded state already says whether it's open.
    */
    &::after {
      color: var(--ember);
      content: '▾' / '';
      transition: rotate 150ms;
    }

    &:hover {
      background-color: var(--chalk);
    }

    &:focus-visible {
      outline: 3px solid var(--ember);
      outline-offset: 2px;
    }
  }

  :host([open]) [part~='trigger'] {
    background-color: var(--ink);
    color: var(--paper);

    &::after {
      rotate: 180deg;
    }
  }

  /* Hidden while closed, so this runs each time it opens. */
  [part~='content'] {
    animation: drop 150ms ease-out;
    background-color: var(--paper);
    border: var(--border);
    box-shadow: var(--shadow);
    color: var(--ink);
    font-family: var(--font-body);
    inset-block-start: calc(100% + 0.5rem);
    inset-inline-start: -0.25rem;
    min-inline-size: 12rem;
    padding: 0.25rem 0;
    position: absolute;
    z-index: 1;
  }

  [part~='list'] {
    list-style: none;
    margin: 0;
    padding: 0;
  }

  ::slotted(a) {
    border-inline-start: 4px solid transparent;
    color: var(--ink);
    display: block;
    padding: 0.5rem 0.875rem 0.5rem 0.75rem;
    text-decoration: none;
    transition:
      background-color 100ms,
      border-color 100ms,
      color 100ms;
  }

  ::slotted(a:hover),
  ::slotted(a:focus-visible) {
    background-color: var(--ink);
    border-inline-start-color: var(--ember);
    color: var(--paper);
    outline: none;
  }

  ::slotted([aria-current='page']) {
    font-weight: 600;
  }

  @keyframes drop {
    from {
      opacity: 0;
      translate: 0 -0.375rem;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    [part~='trigger']::after,
    ::slotted(a) {
      transition: none;
    }

    [part~='content'] {
      animation: none;
    }
  }
`;
```

#### tokens.styles.ts

```ts
import { css } from 'lit';

/*
  The styled components' palette and type, taken from the logo: ink for the
  courses, ember for the base, paper behind them.

  Each is a short alias for a "--slab-styled-*" property, with the default as
  its fallback. Retheming is "--slab-styled-ember" and the rest on ":root",
  which every shadow root inherits. Were the defaults declared under those
  names here instead, each host's own declaration would shadow the page's.

  Each default is "light-dark()", so the components follow the page's
  "color-scheme": "dark" for dark, "light" for light, and "light dark" to
  follow the operating system. Ink and paper trade places in the dark, so
  ink is always the text and paper always what's behind it. Contrast is given
  as light, then dark.
*/
export default css`
  :host {
    /* Secondary text. 6.8:1 and 6.9:1 on paper. */
    --ash: var(--slab-styled-ash, light-dark(#5e5a55, #a8a19a));
    --border: 2px solid var(--ink);

    /* Behind disabled and read-only controls, and hovered rows. */
    --chalk: var(--slab-styled-chalk, light-dark(#f4f1ec, #2b2926));

    /*
      The logo's orange, brighter in the dark. 4.25:1 and 5.2:1 on paper is
      enough for borders, fills and large text, but not always for small text,
      which takes "--ember-deep".
    */
    --ember: var(--slab-styled-ember, light-dark(#d9492a, #ec5f3b));

    /*
      Orange for small text on paper, and for paper text on it. 5.7:1 and
      7.4:1.
    */
    --ember-deep: var(--slab-styled-ember-deep, light-dark(#b3381a, #ff8566));
    --font-body: var(
      --slab-styled-font-body,
      'IBM Plex Sans',
      system-ui,
      sans-serif
    );
    --font-display: var(
      --slab-styled-font-display,
      'Zilla Slab',
      rockwell,
      'Roboto Slab',
      serif
    );
    --font-mono: var(
      --slab-styled-font-mono,
      'IBM Plex Mono',
      ui-monospace,
      monospace
    );
    --ink: var(--slab-styled-ink, light-dark(#1c1c1c, #f4f1ec));
    --paper: var(--slab-styled-paper, light-dark(#fff, #1a1918));

    /* The hard, offset shadow a raised surface casts. */
    --shadow: 4px 4px 0 var(--ink);
  }
`;
```

### React, Vue and Svelte

The same styles, written from outside the element, so there's nothing to subclass. The CSS styles every `<slab-navigation-menu>` on the page. Import `tokens.css`, the colors and fonts every styled example shares, once, wherever your global styles go.

In React and Vue, each component registers its headless element and imports its CSS. Use it like the element: its props are the element's attributes and properties.

In Svelte, import `navigation-menu.js` once, such as in your root layout, and use `<slab-navigation-menu>` as is. A Svelte component around it would take `slot="…"` content as its own slot and drop it.

#### NavigationMenu.jsx

```jsx
import 'slab-elements/navigation-menu.js';
import './navigation-menu.css';
import 'slab-elements/navigation-menu.item.js';
import './navigation-menu.item.css';

export function NavigationMenu(props) {
  return <slab-navigation-menu {...props} />;
}

export function NavigationMenuItem(props) {
  return <slab-navigation-menu-item {...props} />;
}
```

#### NavigationMenu.vue

```vue
<script setup>
import 'slab-elements/navigation-menu.js';
import './navigation-menu.css';
</script>

<template>
  <slab-navigation-menu><slot /></slab-navigation-menu>
</template>
```

#### NavigationMenuItem.vue

```vue
<script setup>
import 'slab-elements/navigation-menu.item.js';
import './navigation-menu.item.css';
</script>

<template>
  <slab-navigation-menu-item><slot /></slab-navigation-menu-item>
</template>
```

#### navigation-menu.js

```js
import 'slab-elements/navigation-menu.js';
import './navigation-menu.css';
import 'slab-elements/navigation-menu.item.js';
import './navigation-menu.item.css';
```

#### navigation-menu.css

```css
slab-navigation-menu::part(list) {
  background-color: var(--paper);
  border: var(--border);
  box-shadow: var(--shadow);
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem;
  inline-size: fit-content;
  list-style: none;
  margin: 0;
  padding: 0.25rem;
}

slab-navigation-menu > a {
  border: 2px solid transparent;
  color: var(--ink);
  display: block;
  font-family: var(--font-display);
  font-size: 0.9375rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  padding: 0.375rem 0.75rem;
  text-decoration: none;
  text-transform: uppercase;
}

slab-navigation-menu > a:hover {
  background-color: var(--chalk);
}

slab-navigation-menu > a:focus-visible {
  outline: 3px solid var(--ember);
  outline-offset: 2px;
}

slab-navigation-menu > [aria-current='page'] {
  text-decoration-color: var(--ember);
  text-decoration-line: underline;
  text-decoration-thickness: 2px;
  text-underline-offset: 0.3em;
}
```

#### navigation-menu.item.css

```css
slab-navigation-menu-item {
  position: relative;
}

slab-navigation-menu-item::part(trigger) {
  align-items: center;
  background-color: transparent;
  border: 2px solid transparent;
  color: var(--ink);
  cursor: pointer;
  display: flex;
  font-family: var(--font-display);
  font-size: 0.9375rem;
  font-weight: 600;
  gap: 0.375rem;
  letter-spacing: 0.04em;
  padding: 0.375rem 0.75rem;
  text-transform: uppercase;
}

/*
  The marker. Its text is decorative, so it's given empty alternative
  text: the button's expanded state already says whether it's open.
*/
slab-navigation-menu-item::part(trigger)::after {
  color: var(--ember);
  content: '▾' / '';
  transition: rotate 150ms;
}

slab-navigation-menu-item::part(trigger):hover {
  background-color: var(--chalk);
}

slab-navigation-menu-item::part(trigger):focus-visible {
  outline: 3px solid var(--ember);
  outline-offset: 2px;
}

slab-navigation-menu-item[open]::part(trigger) {
  background-color: var(--ink);
  color: var(--paper);
}

slab-navigation-menu-item[open]::part(trigger)::after {
  rotate: 180deg;
}

/* Hidden while closed, so this runs each time it opens. */
slab-navigation-menu-item::part(content) {
  animation: slab-navigation-menu-item-drop 150ms ease-out;
  background-color: var(--paper);
  border: var(--border);
  box-shadow: var(--shadow);
  color: var(--ink);
  font-family: var(--font-body);
  inset-block-start: calc(100% + 0.5rem);
  inset-inline-start: -0.25rem;
  min-inline-size: 12rem;
  padding: 0.25rem 0;
  position: absolute;
  z-index: 1;
}

slab-navigation-menu-item::part(list) {
  list-style: none;
  margin: 0;
  padding: 0;
}

slab-navigation-menu-item > a {
  border-inline-start: 4px solid transparent;
  color: var(--ink);
  display: block;
  padding: 0.5rem 0.875rem 0.5rem 0.75rem;
  text-decoration: none;
  transition:
    background-color 100ms,
    border-color 100ms,
    color 100ms;
}

slab-navigation-menu-item > a:hover,
slab-navigation-menu-item > a:focus-visible {
  background-color: var(--ink);
  border-inline-start-color: var(--ember);
  color: var(--paper);
  outline: none;
}

slab-navigation-menu-item > [aria-current='page'] {
  font-weight: 600;
}

@keyframes slab-navigation-menu-item-drop {
  from {
    opacity: 0;
    translate: 0 -0.375rem;
  }
}

@media (prefers-reduced-motion: reduce) {
  slab-navigation-menu-item::part(trigger)::after,
  slab-navigation-menu-item > a {
    transition: none;
  }

  slab-navigation-menu-item::part(content) {
    animation: none;
  }
}
```

#### tokens.css

```css
:root {
  /* Secondary text. 6.8:1 and 6.9:1 on paper. */
  --ash: var(--slab-styled-ash, light-dark(#5e5a55, #a8a19a));
  --border: 2px solid var(--ink);

  /* Behind disabled and read-only controls, and hovered rows. */
  --chalk: var(--slab-styled-chalk, light-dark(#f4f1ec, #2b2926));

  /*
    The logo's orange, brighter in the dark. 4.25:1 and 5.2:1 on paper is
    enough for borders, fills and large text, but not always for small text,
    which takes "--ember-deep".
  */
  --ember: var(--slab-styled-ember, light-dark(#d9492a, #ec5f3b));

  /*
    Orange for small text on paper, and for paper text on it. 5.7:1 and
    7.4:1.
  */
  --ember-deep: var(--slab-styled-ember-deep, light-dark(#b3381a, #ff8566));
  --font-body: var(
    --slab-styled-font-body,
    'IBM Plex Sans',
    system-ui,
    sans-serif
  );
  --font-display: var(
    --slab-styled-font-display,
    'Zilla Slab',
    rockwell,
    'Roboto Slab',
    serif
  );
  --font-mono: var(
    --slab-styled-font-mono,
    'IBM Plex Mono',
    ui-monospace,
    monospace
  );
  --ink: var(--slab-styled-ink, light-dark(#1c1c1c, #f4f1ec));
  --paper: var(--slab-styled-paper, light-dark(#fff, #1a1918));

  /* The hard, offset shadow a raised surface casts. */
  --shadow: 4px 4px 0 var(--ink);
}
```
