# NavigationMenu

A header nav whose keyboard walks the strip without changing route on every keystroke.

```slint
import { NavigationMenu } from "@glint/components/navigation-menu.slint";
import { Tokens } from "@glint/theme/tokens.slint";
import { IconSet } from "@lucide";

export component Demo inherits Window {
    width: 660px;
    height: 220px;
    background: Tokens.color-background;

    in-out property <int> route: 0;

    VerticalLayout {
        padding: 24px;
        spacing: 16px;
        alignment: center;

        HorizontalLayout {
            alignment: center;

            NavigationMenu {
                accessible-label: "Main";
                active <=> root.route;
                items: [
                    { label: "Home", icon: IconSet.House },
                    { label: "Products", icon: IconSet.Package, links: [
                        { label: "Analytics", icon: IconSet.ChartLine,
                          description: "Traffic, funnels and retention." },
                        { label: "Billing", icon: IconSet.CreditCard,
                          description: "Invoices, plans and payment methods." },
                        { label: "Identity", icon: IconSet.Fingerprint,
                          description: "Sign-in, SSO and access policies." },
                    ] },
                    { label: "Pricing", icon: IconSet.Tag },
                    { label: "Docs", icon: IconSet.BookOpen },
                ];
                navigate(index) => { root.route = index; }
            }
        }

        Text {
            text: "Route " + root.route + " is showing.";
            color: Tokens.color-muted-foreground;
            font-size: Tokens.typography-body-sm-size;
            horizontal-alignment: center;
        }
    }
}
```

## Usage

```slint
import { NavigationMenu } from "@glint/components/navigation-menu.slint";
import { Tokens } from "@glint/theme/tokens.slint";

export component AppWindow inherits Window {
    width: 560px;
    height: 200px;
    background: Tokens.color-background;

    in-out property <int> route: 0;

    VerticalLayout {
        padding: 24px;
        spacing: 16px;
        alignment: start;

        NavigationMenu {
            accessible-label: "Main";
            active <=> root.route;
            items: [
                { label: "Overview" },
                { label: "Reports" },
                { label: "Settings" },
            ];
            navigate(index) => { debug("route to", index); }
        }

        if root.route == 0: Text { text: "Overview"; }
        if root.route == 1: Text { text: "Reports"; }
        if root.route == 2: Text { text: "Settings"; }
    }
}
```

`items` is the strip; `active` is which item is highlighted, bound two-way so the app’s own routing drives it. Clicking a destination fires `navigate(index)` with `active` already updated.

The strip is the nav surface and nothing else. It owns no content and switches no screens — that is what tells it from [Tabs](/docs/components/tabs), which owns the selection its triggers stand for. Reach for Tabs when picking one changes a panel in place, and for this when picking one changes the route.

Two structs describe the model:

| Type             | Fields                                                                         |
| ---------------- | ------------------------------------------------------------------------------ |
| `NavigationItem` | `label`, `icon`, `links` — destinations this item opens *instead of* being one |
| `NavigationLink` | `label`, `description` (the second line under it), `icon`                      |

## Examples

### An item that opens a panel

An item carrying `links` is a trigger rather than a destination: it draws a chevron, opens a panel of destinations under itself, and fires `link-selected(item, link)` when one is taken. It never fires `navigate`, the way a menu row carrying children never fires itself (ADR-0020).

The links are a model rather than a slot, because the panel is repeated once per item and a `@children` slot is written once (ADR-0027, ADR-0028).

```slint
import { NavigationMenu } from "@glint/components/navigation-menu.slint";
import { Tokens } from "@glint/theme/tokens.slint";
import { IconSet } from "@lucide";

export component Demo inherits Window {
    width: 660px;
    height: 260px;
    background: Tokens.color-background;

    property <string> taken: "Open “Resources” and take a link.";

    VerticalLayout {
        padding: 24px;
        spacing: 16px;
        alignment: center;

        HorizontalLayout {
            alignment: center;

            NavigationMenu {
                accessible-label: "Site";
                items: [
                    { label: "Home" },
                    { label: "Resources", links: [
                        { label: "Documentation", icon: IconSet.BookOpen,
                          description: "Guides and the component reference." },
                        { label: "Changelog", icon: IconSet.History,
                          description: "What shipped, and when." },
                        { label: "Support", icon: IconSet.LifeBuoy,
                          description: "Open a ticket or read the FAQ." },
                    ] },
                    { label: "Contact" },
                ];
                link-selected(item, link) => {
                    root.taken = "Link " + link + " under item " + item + ".";
                }
            }
        }

        Text {
            text: root.taken;
            color: Tokens.color-muted-foreground;
            font-size: Tokens.typography-body-sm-size;
            horizontal-alignment: center;
        }
    }
}
```

### The keyboard’s place is not the route

← and → walk the strip **without following what they land on**: walking a header nav must not perform a route change per keystroke, so where the keyboard stands (`focused-index`) is a different thing from which item is active. They stop at both ends rather than wrapping — a route list has a first item and a last one. Enter or Space follows the item the keyboard landed on, or opens its panel; ↓ opens the panel outright.

Both are properties, so an app can see where the keyboard is and put it somewhere.

```slint
import { NavigationMenu } from "@glint/components/navigation-menu.slint";
import { Tokens } from "@glint/theme/tokens.slint";

export component Demo inherits Window {
    width: 660px;
    height: 240px;
    background: Tokens.color-background;

    in-out property <int> route: 1;
    in-out property <int> keyboard-at: 1;

    VerticalLayout {
        padding: 24px;
        spacing: 16px;
        alignment: center;

        HorizontalLayout {
            alignment: center;

            NavigationMenu {
                accessible-label: "Sections";
                active <=> root.route;
                focused-index <=> root.keyboard-at;
                items: [
                    { label: "Inbox" },
                    { label: "Drafts" },
                    { label: "Sent" },
                    { label: "Archive" },
                ];
                navigate(index) => { root.route = index; }
            }
        }

        Text {
            text: "Active route: " + root.route
                + "  ·  keyboard on: " + root.keyboard-at;
            color: Tokens.color-muted-foreground;
            font-size: Tokens.typography-body-sm-size;
            horizontal-alignment: center;
        }
    }
}
```

Tab to the strip and press → a few times: the highlight stays where the route is, and only Enter moves it.

### A description under the link

`NavigationLink.description` is the second line — what the destination is for. It rides along as the row’s accessible description rather than as a text node of its own, so a screen reader hears the link’s name and then its purpose instead of two unrelated strings.

```slint
import { NavigationMenu } from "@glint/components/navigation-menu.slint";
import { Tokens } from "@glint/theme/tokens.slint";
import { IconSet } from "@lucide";

export component Demo inherits Window {
    width: 660px;
    height: 280px;
    background: Tokens.color-background;

    VerticalLayout {
        padding: 24px;
        alignment: center;

        HorizontalLayout {
            alignment: center;

            NavigationMenu {
                accessible-label: "Platform";
                items: [
                    { label: "Platform", icon: IconSet.Layers, links: [
                        { label: "Compute", icon: IconSet.Cpu,
                          description: "Machines, autoscaling and regions." },
                        { label: "Storage", icon: IconSet.Database,
                          description: "Buckets, volumes and retention." },
                        { label: "Networking", icon: IconSet.Network },
                    ] },
                    { label: "Status", icon: IconSet.Activity },
                ];
            }
        }
    }
}
```

A link with no `description` draws one line and announces one — the second line is left out of the layout rather than drawn empty.

## API Reference

### Properties

| Property            | Type                  | Default       | Description                                                                                                              |
| ------------------- | --------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `items`             | `in [NavigationItem]` | no default    | Items shown in the nav strip (left-to-right).                                                                            |
| `active`            | `in-out int`          | `0`           | Index of the highlighted item; bound two-way so consumers drive routing.                                                 |
| `focused-index`     | `in-out int`          | `root.active` | Where the keyboard stands in the strip. It starts on the active item and moves with the arrows; consumers rarely set it. |
| `highlighted-index` | `in-out int`          | `0`           | Row the keyboard stands on inside an open panel.                                                                         |

### Callbacks

| Callback                   | Description                                                         |
| -------------------------- | ------------------------------------------------------------------- |
| `navigate(int)`            | Fired when a destination item is taken; `active` is updated first.  |
| `link-selected(int , int)` | Fired with the trigger a panel hung off and the link taken from it. |

`NavigationItem` and `NavigationLink` are data types rather than components; their fields are in [Usage](#usage) above.

## Accessibility

- **A navigation landmark.** The strip carries `accessible-role: navigation` and publishes its item count. Naming it is the call site’s — set `accessible-label`.
- **One tab stop, and a delegated announcement.** The strip is a single stop, so it delegates the accessible focus to the item the keyboard stands on (`focused-index`). Without that, a screen reader announces the strip rather than the item the arrows reached.
- **Every item is a named control.** Each carries its `label`, its index and whether it is the active one (`accessible-item-selected`), and answers the accessible default action.
- **A trigger reports its panel.** An item with `links` declares `accessible-expandable` and `accessible-expanded`, and its chevron turns to match. Taking a link closes the panel.
- **An open panel is a list.** It carries its row count, and every link is a named list item whose `description` is announced as its description. ↑ and ↓ move the highlight and wrap at both ends, Home and End jump to its ends, Enter or Space takes the highlighted link, and Esc closes the panel.
- **The focus ring is keyboard-only.** It draws around the whole strip when the keyboard put focus there, and not when a click did.
