component

Navigation

A flexible navigation component for creating horizontal or vertical navigation menus. Supports active states, disabled items, and leading/trailing slots where you can place any element — icons, badges, stamps, etc. Badges placed in trailing automatically scale with the navigation size.

Each item is a Panel, and so is the <nav> itself — the surface, hover and press ramps, the reserved 1px border and the current-item state all come from there. Anything Panel can do to a surface, a navigation item can do too; see Inheriting from Panel.

Emphasis

emphasis is Panel’s, and describes the resting rows. tertiary (the default) keeps them colorless, secondary tints them on the tone ramp, and primary fills every row with the accent surface.

tone stays neutral by default so labels read as text rather than as links. The demo below sets tone="primary" only to make the three levels tellable apart — on the neutral palette secondary and tertiary resolve to the same ramp.

---
// GENERATED from packages/demos/navigation/emphasis.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";

const levels = ["tertiary", "secondary", "primary"] as const;
const items = [
  { label: "Overview", href: "#" },
  { label: "Reports", href: "#" },
  { label: "Settings", href: "#" },
];
---

<div class={stack({ gap: "lg" })}>
  {levels.map((emphasis) => (
    <Navigation
      items={items}
      tone="primary"
      emphasis={emphasis}
      activeItem="Overview"
    />
  ))}
</div>

Active Emphasis

activeEmphasis controls how loud the current item is, independent of the resting rows. It defaults to secondary — a tint on the tone ramp with promoted text and a lifted border, which marks the row without shouting. Raise it to primary for a filled brand row when the navigation has to read at a glance, or drop to tertiary for a colorless tint.

Pair it with tone="primary" (as the demo does) for a brand-colored current item; on the default neutral palette every level paints on the neutral ramp.

The current item is matched from activeItem and marked with aria-current="page". A parent whose submenu is closed (a flyout or a compact rail) takes the active look too, so the current section stays visible when the matching child isn’t.

---
// GENERATED from packages/demos/navigation/active-emphasis.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";

const levels = ["secondary", "primary", "tertiary"] as const;
const items = [
  { label: "Overview", href: "#" },
  { label: "Reports", href: "#" },
  { label: "Settings", href: "#" },
];
---

<div class={stack({ gap: "lg" })}>
  {levels.map((activeEmphasis) => (
    <Navigation
      items={items}
      tone="primary"
      activeEmphasis={activeEmphasis}
      activeItem="Reports"
    />
  ))}
</div>

background paints the items. The <nav> element has its own set — rootBackground, rootPadding, rootRadius, rootBorder, rootShadow and rootTranslucent — so a sidebar or topbar shell can be built from the component alone instead of a wrapper. All of them default to the inert value, so a navigation that asks for nothing renders no chrome of its own.

---
// GENERATED from packages/demos/navigation/surface.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";

const items = [
  { label: "Overview", href: "#" },
  { label: "Reports", href: "#" },
  { label: "Settings", href: "#" },
];
---

<div class={stack({ gap: "lg", direction: "row" })}>
  <Navigation
    items={items}
    direction="vertical"
    background="transparent"
    rootBackground="surface.hill"
    rootPadding="xs"
    rootRadius="xl"
    rootBorder="muted"
    activeItem="Reports"
  />
  <Navigation
    items={items}
    direction="vertical"
    background="transparent"
    rootBackground="surface.ground"
    rootPadding="xs"
    rootRadius="xl"
    rootShadow="md"
    activeEmphasis="tertiary"
    activeItem="Reports"
  />
</div>

Leading Icons

Place icons in the leading slot for a sidebar-style navigation. Any component works — lucide icons, custom SVGs, anything.

---
// GENERATED from packages/demos/navigation/leading-icons.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";
import Home from "./_fragments/leading-icons.home.astro";
import Inbox from "./_fragments/leading-icons.inbox.astro";
import Drafts from "./_fragments/leading-icons.drafts.astro";
import Sent from "./_fragments/leading-icons.sent.astro";
import Logout from "./_fragments/leading-icons.logout.astro";
---

<div class={stack({ gap: "md", direction: "column" })}>
  <Navigation
    items={[
      { label: "Home", href: "#", leading: Home },
      { label: "Inbox", href: "#", leading: Inbox },
      { label: "Drafts", href: "#", leading: Drafts },
      { label: "Sent", href: "#", leading: Sent },
      { label: "Logout", href: "#", leading: Logout },
    ]}
    activeItem="Inbox"
    direction="vertical"
  />
</div>

Trailing Stamps

Use <Stamp> in the trailing slot for decorative indicators.

---
// GENERATED from packages/demos/navigation/trailing-stamps.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";
import StarStamp from "./_fragments/trailing-stamps.starStamp.astro";
import CheckStamp from "./_fragments/trailing-stamps.checkStamp.astro";
import ZapStamp from "./_fragments/trailing-stamps.zapStamp.astro";
import BellStamp from "./_fragments/trailing-stamps.bellStamp.astro";
import ShieldStamp from "./_fragments/trailing-stamps.shieldStamp.astro";
---

<div class={stack({ gap: "md", direction: "column" })}>
  <Navigation
    items={[
      { label: "Featured", href: "#", trailing: StarStamp },
      { label: "Verified", href: "#", trailing: CheckStamp },
      { label: "Promoted", href: "#", trailing: ZapStamp },
      { label: "Subscribed", href: "#", trailing: BellStamp },
      { label: "Protected", href: "#", trailing: ShieldStamp },
    ]}
    activeItem="Featured"
  />
</div>

Leading and Trailing Combined

Mix icons, badges, stamps, chevrons — leading and trailing are independent and accept any element.

---
// GENERATED from packages/demos/navigation/leading-and-trailing.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";
import HomeIcon from "./_fragments/leading-and-trailing.homeIcon.astro";
import InboxIcon from "./_fragments/leading-and-trailing.inboxIcon.astro";
import BellIcon from "./_fragments/leading-and-trailing.bellIcon.astro";
import SettingsIcon from "./_fragments/leading-and-trailing.settingsIcon.astro";
import Count12 from "./_fragments/leading-and-trailing.count12.astro";
import NewBadge from "./_fragments/leading-and-trailing.newBadge.astro";
import Chevron from "./_fragments/leading-and-trailing.chevron.astro";
import StarStamp from "./_fragments/leading-and-trailing.starStamp.astro";
---

<div class={stack({ gap: "md", direction: "column" })}>
  <Navigation
    items={[
      { label: "Home", href: "#", leading: HomeIcon, trailing: Chevron },
      { label: "Inbox", href: "#", leading: InboxIcon, trailing: Count12 },
      {
        label: "Notifications",
        href: "#",
        leading: BellIcon,
        trailing: NewBadge,
      },
      {
        label: "Settings",
        href: "#",
        leading: SettingsIcon,
        trailing: StarStamp,
      },
    ]}
    activeItem="Inbox"
    direction="vertical"
  />
</div>

Item-aware leading and trailing

leading and trailing receive their item, so a list built from data renders every row’s content from one template — a label’s color dot, its unread count:

  • Svelte — a Snippet<[NavigationItem]>: {#snippet count(item)}<Badge>{unread[item.id]}</Badge>{/snippet}, then trailing: count on every item. A parameterless snippet still works.
  • React / Solid / Vue — a node, or a render function (item) => node.
  • Astro — the component reference is rendered with the item as its item prop (Astro.props.item).

Descriptions

description adds a muted second line under the label. The row grows to two lines and the line truncates with an ellipsis; a compact rail hides it. Rows without one keep the regular row height.

---
// GENERATED from packages/demos/navigation/descriptions.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { css } from "@pindoba/styled-system/css";
import General from "./_fragments/descriptions.general.astro";
import Releases from "./_fragments/descriptions.releases.astro";
import Incidents from "./_fragments/descriptions.incidents.astro";
import Design from "./_fragments/descriptions.design.astro";
---

<div class={css({ maxWidth: "28rem" })}>
  <Navigation
    items={[
      {
        label: "general",
        href: "#",
        leading: General,
        description: "Company-wide announcements and questions",
      },
      {
        label: "releases",
        href: "#",
        leading: Releases,
        description: "Changelog drafts · next cut on Thursday",
      },
      {
        label: "incidents",
        href: "#",
        leading: Incidents,
        description: "On-call handoffs and postmortems",
      },
      {
        label: "design-review",
        href: "#",
        leading: Design,
        description: "Weekly critique of in-flight work, open to everyone",
      },
      { label: "random", href: "#" },
    ]}
    activeItem="releases"
    direction="vertical"
  />
</div>

Media

media puts a cover or an avatar in the leading slot. It is larger than an icon and sets the row’s height: mediaSize picks 32, 40 (the default) or 48px, mediaShape a "square" cover or a "round" avatar. An <img>, <svg> or <video> child fills the box and is cropped to it. On a compact rail the media is what the rail shows, shrunk to the rail’s icon box. When an item sets both, media wins over leading.

---
// GENERATED from packages/demos/navigation/media.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
import Liked from "./_fragments/media.liked.astro";
import NightDrive from "./_fragments/media.nightDrive.astro";
import FieldRecordings from "./_fragments/media.fieldRecordings.astro";
import Podcast from "./_fragments/media.podcast.astro";
import ArtistOna from "./_fragments/media.artistOna.astro";
import ArtistKai from "./_fragments/media.artistKai.astro";

const modes = ["none", "icon", "stack"] as ("none" | "icon" | "stack")[];

const compact = modes[0];
---

<div
  class={stack({ gap: "md", direction: "column", align: "start" })}
  data-ir-root="navigation/media"
>
  <button type="button" data-ir="1">
    compact: <!--ir:2-->
    {compact}
    <!--/ir-->
  </button>
  <div class={css({ width: "100%", maxWidth: "28rem" })}>
    <Navigation
      sections={[
        {
          title: "Your library",
          items: [
            {
              label: "Liked Songs",
              href: "#",
              media: Liked,
              description: "Playlist · 214 songs",
            },
            {
              label: "Late Night Drive",
              href: "#",
              media: NightDrive,
              description: "Playlist · Marta Lins",
            },
            {
              label: "Field Recordings, Vol. 2",
              href: "#",
              media: FieldRecordings,
              description: "Album · Ona Varga",
            },
            {
              label: "Slow Radio",
              href: "#",
              media: Podcast,
              mediaSize: "lg",
              description: "Podcast · 34 episodes",
            },
          ],
        },
        {
          title: "Artists",
          items: [
            {
              label: "Ona Varga",
              href: "#",
              media: ArtistOna,
              mediaShape: "round",
              description: "Artist",
            },
            {
              label: "Kai Moreau",
              href: "#",
              media: ArtistKai,
              mediaShape: "round",
              mediaSize: "sm",
            },
          ],
        },
      ]}
      activeItem="late-night-drive"
      direction="vertical"
      compact={compact}
      data-ir="3"
    />
  </div>
</div>

<script>
  import { hydrate } from "./_client/media.ts";

  document
    .querySelectorAll('[data-ir-root="navigation/media"]')
    .forEach((root) => hydrate([root]));
</script>

Actions

actions puts controls beside a row’s link, such as a ”…” menu. They are always visible. actionsReveal="hover" (on the navigation or on one item) hides them until the row is hovered or holds focus, and keeps them shown while one of them is open; on a touch screen they stay visible either way. A <Button> placed directly in the slot sizes itself to the row.

A row with actions is a container rather than a link: the label’s link covers the whole row, so a click anywhere except on an action still navigates, and Tab reaches each action right after its row’s link. A trigger that opens something must expose aria-expanded, which Menu and Popover triggers already do. Actions need semantics="list"; they are ignored (with a dev warning) under "menu" and on submenu parents — except a linkable parent, whose disclosure sits right after them — and a static compact rail drops them. In an overflow="adapt" strip a row that folds to its icon keeps its actions beside the icon.

---
// GENERATED from packages/demos/navigation/actions.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { css } from "@pindoba/styled-system/css";
import SectionsMedia0 from "./_fragments/actions.sectionsMedia0.astro";
import SectionsActions0 from "./_fragments/actions.sectionsActions0.astro";
import SectionsMedia1 from "./_fragments/actions.sectionsMedia1.astro";
import SectionsTrailing0 from "./_fragments/actions.sectionsTrailing0.astro";
import SectionsActions1 from "./_fragments/actions.sectionsActions1.astro";
import SectionsMedia2 from "./_fragments/actions.sectionsMedia2.astro";
import SectionsActions2 from "./_fragments/actions.sectionsActions2.astro";
import SectionsMedia3 from "./_fragments/actions.sectionsMedia3.astro";
import SectionsActions3 from "./_fragments/actions.sectionsActions3.astro";
---

<div class={css({ width: "100%", maxWidth: "28rem" })}>
  <Navigation
    direction="vertical"
    semantics="list"
    activeItem="late-night-drive"
    sections={[
      {
        title: "Your library",
        items: [
          {
            id: "liked-songs",
            label: "Liked Songs",
            href: "#",
            description: "Playlist · 214 songs",
            media: SectionsMedia0,
            actions: SectionsActions0,
          },
          {
            id: "late-night-drive",
            label: "Late Night Drive",
            href: "#",
            description: "Playlist · Marta Lins",
            media: SectionsMedia1,
            trailing: SectionsTrailing0,
            actions: SectionsActions1,
          },
          {
            id: "field-recordings",
            label: "Field Recordings, Vol. 2",
            href: "#",
            description: "Album · Ona Varga",
            media: SectionsMedia2,
            actions: SectionsActions2,
          },
          {
            id: "slow-radio",
            label: "Slow Radio",
            href: "#",
            description: "Podcast · 34 episodes",
            media: SectionsMedia3,
            actions: SectionsActions3,
          },
        ],
      },
    ]}
  />
</div>

Standalone items in Astro

Astro also ships a standalone NavigationItem for markup you own. It takes semantics="list", actionsReveal and an actions named slot; you provide the <ul> inside a <nav>, and each item renders its own <li>. It is Astro-only, so it has no cross-framework demo.

---
import { NavigationItem } from "@pindoba/astro-navigation";
import Menu from "@pindoba/astro-menu";
import { Ellipsis } from "@lucide/astro";
---

<nav aria-label="Library">
  <ul>
    <NavigationItem
      label="Late Night Drive"
      href="/library/late-night-drive"
      semantics="list"
      actionsReveal="hover"
    >
      <Menu
        slot="actions"
        id="late-night-drive-menu"
        aria-label="Late Night Drive options"
        passThrough={{
          trigger: {
            props: { "aria-label": "More options for Late Night Drive" },
          },
        }}
        items={[{ type: "action", id: "share", label: "Share" }]}
        triggerEmphasis="ghost"
      >
        <Ellipsis />
      </Menu>
    </NavigationItem>
  </ul>
</nav>

Size with Trailing Badge

Badges composed in the trailing slot scale with the navigation size — each size maps to a smaller badge font-size token.

---
// GENERATED from packages/demos/navigation/size-badge.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";
import Twelve from "./_fragments/size-badge.twelve.astro";
import Three from "./_fragments/size-badge.three.astro";
import StarStamp from "./_fragments/size-badge.starStamp.astro";
import CheckStamp from "./_fragments/size-badge.checkStamp.astro";

const sizes = ["xs", "sm", "md", "lg"] as const;
---

<div class={stack({ gap: "xl", direction: "column" })}>
  {sizes.map((size) => (
    <div>
      <h3>{size}</h3>
      <Navigation
        items={[
          { label: "Home", href: "#", trailing: StarStamp },
          { label: "Inbox", href: "#", trailing: Twelve },
          { label: "Drafts", href: "#", trailing: Three },
          { label: "Sent", href: "#", trailing: CheckStamp },
        ]}
        activeItem="Inbox"
        size={size}
      />
    </div>
  ))}
</div>

Compact: Icon + Label

Set compact="stack" to shrink each item to icon-over-label with ellipsis — useful for narrow rails where labels still need to read. The styled Tooltip is wired automatically from item.label, so hovering surfaces the full text without falling back to the native title tooltip.

---
// GENERATED from packages/demos/navigation/compact-stack.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";
import Home from "./_fragments/compact-stack.home.astro";
import Inbox from "./_fragments/compact-stack.inbox.astro";
import Drafts from "./_fragments/compact-stack.drafts.astro";
import Sent from "./_fragments/compact-stack.sent.astro";
import Logout from "./_fragments/compact-stack.logout.astro";
---

<div class={stack({ gap: "md", direction: "column" })}>
  <Navigation
    items={[
      { label: "Home", href: "#", leading: Home },
      { label: "Inbox", href: "#", leading: Inbox },
      { label: "Drafts", href: "#", leading: Drafts },
      { label: "Sent", href: "#", leading: Sent },
      { label: "Logout", href: "#", leading: Logout },
    ]}
    activeItem="Inbox"
    direction="vertical"
    compact="stack"
  />
</div>

Compact: Icon Only

Set compact="icon" for a pure icon rail. Labels stay in the accessibility tree (visually hidden) and the auto-tooltip carries the name on hover, with placement derived from direction.

---
// GENERATED from packages/demos/navigation/compact-icon.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";
import Home from "./_fragments/compact-icon.home.astro";
import Inbox from "./_fragments/compact-icon.inbox.astro";
import Drafts from "./_fragments/compact-icon.drafts.astro";
import Sent from "./_fragments/compact-icon.sent.astro";
import Logout from "./_fragments/compact-icon.logout.astro";
---

<div class={stack({ gap: "md", direction: "column" })}>
  <Navigation
    items={[
      { label: "Home", href: "#", leading: Home },
      { label: "Inbox", href: "#", leading: Inbox },
      { label: "Drafts", href: "#", leading: Drafts },
      { label: "Sent", href: "#", leading: Sent },
      { label: "Logout", href: "#", leading: Logout },
    ]}
    activeItem="Inbox"
    direction="vertical"
    compact="icon"
  />
</div>

Toggling Compact Mode

Consumers drive the compact prop from layout state. Cycle between none, stack, and icon to see how the same items adapt. With compactToggle, items that have children stay reachable while collapsed: hovering or focusing the folded parent row opens a rail flyout panel with the full labels — in every framework.

---
// GENERATED from packages/demos/navigation/compact-toggle.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";
import Home from "./_fragments/compact-toggle.home.astro";
import Products from "./_fragments/compact-toggle.products.astro";
import Inbox from "./_fragments/compact-toggle.inbox.astro";
import Drafts from "./_fragments/compact-toggle.drafts.astro";
import Sent from "./_fragments/compact-toggle.sent.astro";
import Logout from "./_fragments/compact-toggle.logout.astro";

const modes = ["none", "stack", "icon"] as ("none" | "stack" | "icon")[];

const compact = modes[0];
---

<div
  class={stack({ gap: "md", direction: "column" })}
  data-ir-root="navigation/compact-toggle"
>
  <button type="button" data-ir="1">
    compact: <!--ir:2-->
    {compact}
    <!--/ir-->
  </button>
  <Navigation
    items={[
      { label: "Home", href: "#", leading: Home },
      {
        label: "Products",
        leading: Products,
        defaultExpanded: true,
        items: [
          { label: "Analytics", href: "#analytics" },
          { label: "Reports", href: "#reports" },
          { label: "Exports", href: "#exports" },
        ],
      },
      { label: "Inbox", href: "#", leading: Inbox },
      { label: "Drafts", href: "#", leading: Drafts },
      { label: "Sent", href: "#", leading: Sent },
      { label: "Logout", href: "#", leading: Logout },
    ]}
    activeItem="Inbox"
    direction="vertical"
    compact={compact}
    data-ir="3"
  />
</div>

<script>
  import { hydrate } from "./_client/compact-toggle.ts";

  document
    .querySelectorAll('[data-ir-root="navigation/compact-toggle"]')
    .forEach((root) => hydrate([root]));
</script>

Sections

Group items into titled sections with the sections prop instead of a flat items array. Each section becomes a labelled group (role="group" + aria-labelledby pointing at its heading), so assistive tech announces the group name. Sections work best in direction="vertical" sidebars. A plain items array keeps working exactly as before.

---
// GENERATED from packages/demos/navigation/sections.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";

const sections = [
  {
    title: "Main",
    items: [
      { label: "Dashboard", href: "#" },
      { label: "Projects", href: "#" },
      { label: "Reports", href: "#" },
    ],
  },
  {
    title: "Account",
    items: [
      { label: "Profile", href: "#" },
      { label: "Billing", href: "#" },
      { label: "Logout", href: "#" },
    ],
  },
];
---

<div class={stack({ gap: "md", direction: "column", justify: "center" })}>
  <Navigation
    direction="vertical"
    emphasis="tertiary"
    sections={sections}
    activeItem="Dashboard"
  />
</div>

Section heading action

Give a section an action to put a control at the end of its heading — a ”+” that creates a label in a “Labels” section. The heading becomes a row (sectionHeader slot) holding the title and the action wrapper (sectionAction slot, both styleable through passThrough); a section without an action renders exactly as before. The action is a Svelte snippet (it receives the section), a React/Solid/Vue node or (section) => node, or an Astro component (rendered with a section prop). It folds away with the rail under compact, the same way a row’s trailing content does. Use it with semantics="list": an ARIA menu can’t hold a free-standing button.

---
// GENERATED from packages/demos/navigation/section-action.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";
import Inbox from "./_fragments/section-action.inbox.astro";
import Sent from "./_fragments/section-action.sent.astro";
import Tag from "./_fragments/section-action.tag.astro";
import CreateLabel from "./_fragments/section-action.createLabel.astro";
---

<div class={stack({ gap: "md", direction: "column" })}>
  <Navigation
    direction="vertical"
    semantics="list"
    emphasis="tertiary"
    activeItem="inbox"
    sections={[
      {
        title: "Folders",
        items: [
          { id: "inbox", label: "Inbox", href: "#", leading: Inbox },
          { id: "sent", label: "Sent", href: "#", leading: Sent },
        ],
      },
      {
        title: "Labels",
        action: CreateLabel,
        items: [
          { id: "work", label: "Work", href: "#", leading: Tag },
          { id: "travel", label: "Travel", href: "#", leading: Tag },
        ],
      },
    ]}
  />
</div>

Per-Item Tooltips

Pass tooltip on any item to override the auto-derived tooltip. Accepts a string or { content, placement }. Items without tooltip show no tooltip outside compact mode.

---
// GENERATED from packages/demos/navigation/with-tooltips.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";
import Home from "./_fragments/with-tooltips.home.astro";
import Inbox from "./_fragments/with-tooltips.inbox.astro";
import Drafts from "./_fragments/with-tooltips.drafts.astro";
import Sent from "./_fragments/with-tooltips.sent.astro";
---

<div class={stack({ gap: "md", direction: "column" })}>
  <Navigation
    items={[
      { label: "Home", href: "#", leading: Home, tooltip: "Go home" },
      {
        label: "Inbox",
        href: "#",
        leading: Inbox,
        tooltip: { content: "12 unread", placement: "right" as const },
      },
      {
        label: "Drafts",
        href: "#",
        leading: Drafts,
        tooltip: "Drafts (3)",
      },
      { label: "Sent", href: "#", leading: Sent },
    ]}
    activeItem="Inbox"
    direction="vertical"
  />
</div>

Compact: Trailing Badges

In compact modes the inline trailing slot is hidden — the same trailing content floats over the item’s corner as an <Attachment> instead, so unread counts survive the collapse.

---
// GENERATED from packages/demos/navigation/compact-badges.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";
import Home from "./_fragments/compact-badges.home.astro";
import Inbox from "./_fragments/compact-badges.inbox.astro";
import Drafts from "./_fragments/compact-badges.drafts.astro";
import Sent from "./_fragments/compact-badges.sent.astro";
import Twelve from "./_fragments/compact-badges.twelve.astro";
import Three from "./_fragments/compact-badges.three.astro";
---

<div class={stack({ gap: "xl", direction: "row" })}>
  <Navigation
    items={[
      { label: "Home", href: "#", leading: Home },
      { label: "Inbox", href: "#", leading: Inbox, trailing: Twelve },
      { label: "Drafts", href: "#", leading: Drafts, trailing: Three },
      { label: "Sent", href: "#", leading: Sent },
    ]}
    activeItem="Inbox"
    direction="vertical"
    compact="icon"
  />
  <Navigation
    items={[
      { label: "Home", href: "#", leading: Home },
      { label: "Inbox", href: "#", leading: Inbox, trailing: Twelve },
      { label: "Drafts", href: "#", leading: Drafts, trailing: Three },
      { label: "Sent", href: "#", leading: Sent },
    ]}
    activeItem="Inbox"
    direction="vertical"
    compact="stack"
  />
</div>

Compact: Badge Placement

trailingAttachment takes the <Attachment> positioning props (placement, anchor, shape, hugCorners, offset/offsetX/offsetY, flip, shift, padding) and applies them to every item’s badge. Any item can override it with its own trailingAttachment, and either level accepts false to switch the attachment off — the trailing content is then simply not shown while compact. passThrough.trailingAttachment.props still overrides everything as the raw escape hatch.

---
// GENERATED from packages/demos/navigation/compact-badge-placement.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";
import Home from "./_fragments/compact-badge-placement.home.astro";
import Inbox from "./_fragments/compact-badge-placement.inbox.astro";
import Drafts from "./_fragments/compact-badge-placement.drafts.astro";
import Twelve from "./_fragments/compact-badge-placement.twelve.astro";
import Three from "./_fragments/compact-badge-placement.three.astro";
---

<div class={stack({ gap: "xl", direction: "row" })}>
  <Navigation
    items={[
      { label: "Home", href: "#", leading: Home },
      { label: "Inbox", href: "#", leading: Inbox, trailing: Twelve },
      { label: "Drafts", href: "#", leading: Drafts, trailing: Three },
    ]}
    activeItem="Inbox"
    direction="vertical"
    compact="icon"
  />

  <Navigation
    items={[
      { label: "Home", href: "#", leading: Home },
      { label: "Inbox", href: "#", leading: Inbox, trailing: Twelve },
      { label: "Drafts", href: "#", leading: Drafts, trailing: Three },
    ]}
    activeItem="Inbox"
    direction="vertical"
    compact="icon"
    trailingAttachment={{ placement: "bottom-end", offset: 2 }}
  />

  <Navigation
    items={[
      { label: "Home", href: "#", leading: Home },
      {
        label: "Inbox",
        href: "#",
        leading: Inbox,
        trailing: Twelve,
        trailingAttachment: { placement: "top-start" as const },
      },
      {
        label: "Drafts",
        href: "#",
        leading: Drafts,
        trailing: Three,
        trailingAttachment: false as const,
      },
    ]}
    activeItem="Inbox"
    direction="vertical"
    compact="icon"
  />
</div>

Borders

Items have no border by default. border paints the resting line, borderInteract the hover/press line, and borderActive the current item’s — these are Panel’s own props, taking the same token levels (none, default, bold, muted, accent), so a border can appear only on hover or only on the active row.

borderActive defaults to inherit, meaning “keep the ring activeEmphasis chose”; pass none to explicitly clear the line in that state (e.g. an outline on every row except the current one). The active item’s border holds while it’s hovered, so a borderInteract color can’t steal it.

The 1px border is reserved at every level, so switching levels or states never shifts the row. appearance="tabs" (the tab-strip treatment) owns the item’s edge itself and ignores these props.

---
// GENERATED from packages/demos/navigation/borders.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";

const items = [
  { label: "Overview", href: "#" },
  { label: "Reports", href: "#" },
  { label: "Settings", href: "#" },
];
---

<div class={stack({ gap: "lg" })}>
  <Navigation items={items} activeItem="Overview" />

  <Navigation
    items={items}
    activeItem="Overview"
    border="muted"
    borderInteract="default"
  />

  <Navigation items={items} activeItem="Overview" borderActive="accent" />

  <Navigation
    items={items}
    activeItem="Overview"
    border="muted"
    borderActive="none"
  />

  <Navigation items={items} activeItem="Overview" borderInteract="bold" />
</div>

Tabs

appearance="tabs" turns the strip into minimal underline tabs: transparent items, a 2px accent bar marking the current one (bottom edge when horizontal, inline-start edge when vertical), and a 1px baseline track on the container. In this mode the per-item border props are ignored and tone defaults to "primary" so the bar picks up the accent color — pass another tone to retint it. It’s the same visual language as Radio’s appearance="tab" and Tab’s appearance="tabs".

Pass activeEmphasis explicitly to give the tabs fills as well: they get the usual hover and press steps, and the current tab paints that surface under its bar (activeEmphasis="secondary" for a soft tint, "primary" for a filled brand tab). Without it the strip stays underline-only.

---
// GENERATED from packages/demos/navigation/tabs.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";

const items = [
  { label: "Overview", href: "#" },
  { label: "Reports", href: "#" },
  { label: "Settings", href: "#" },
];
---

<div class={stack({ gap: "xl", direction: "column" })}>
  <Navigation items={items} appearance="tabs" activeItem="Reports" />

  <div class={stack({ gap: "lg", direction: "row" })}>
    <Navigation
      items={items}
      appearance="tabs"
      direction="vertical"
      activeItem="Reports"
    />
    <Navigation
      items={items}
      appearance="tabs"
      direction="vertical"
      tone="danger"
      activeItem="Settings"
    />
  </div>
</div>

Narrow containers: overflow

A horizontal strip in a narrow column (a split reading pane, a phone) needs a plan for when its items no longer fit. overflow picks one; the default "none" keeps the classic behaviour (items shrink and truncate). Drag the corner of each box below to narrow it.

  • overflow="adapt" follows the container width — not the viewport — through progressive stages:

    1. Full — every tab at its natural width.
    2. Shrink — labels truncate with an ellipsis, the longest first (one shared cap, so short labels keep their width), never below about four characters.
    3. Fold — tabs turn icon-only one at a time: lowest priority first, then from the end of the strip. The active tab always folds last.
    4. Icons — every tab with a leading icon is icon-only. Tabs without an icon only ever shrink.

    A folded tab keeps its accessible name (the label stays in the accessibility tree) and gains a tooltip; its trailing count folds into a corner dot but is still announced. Every threshold has hysteresis (12px): a tab only gets its label back once the label fits with room to spare, so dragging across a threshold never makes tabs flicker. The folds animate, and respect prefers-reduced-motion.

  • overflow="scroll" never truncates: the strip scrolls horizontally. Edge fades appear only on a side with more tabs beyond it, the active tab scrolls into view on mount and whenever it changes, and arrow-key roving focus keeps the focused tab clear of the fades. scrollButtons adds an arrow on each such edge (pointer-only; keyboard users scroll by moving focus), and scrollbar picks a "thin" (default) or "hidden" native scrollbar.

The measuring runs in attachNavigationOverflow from @pindoba/core-navigation: one ResizeObserver on the strip, reading each tab’s natural label width. Svelte, React, Vue and Solid attach it on mount; Astro boots it from the Navigation client runtime, also for a navigation authored with the default slot. Before hydration the server-rendered strip is at the full stage: if the container is already narrow, the labels truncate (the "none" behaviour) until the script runs. The client then applies the right stage in one step, without animating that first pass. A scroll strip is scrollable from first paint, and its fades and arrows appear once the script runs.

overflow only applies to direction="horizontal". "adapt" is ignored by a static compact navigation and paused while a compactToggle rail is collapsed.

---
// GENERATED from packages/demos/navigation/tabs-overflow.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
import Inbox from "./_fragments/tabs-overflow.inbox.astro";
import Mentions from "./_fragments/tabs-overflow.mentions.astro";
import Assigned from "./_fragments/tabs-overflow.assigned.astro";
import Archive from "./_fragments/tabs-overflow.archive.astro";
import Settings from "./_fragments/tabs-overflow.settings.astro";
import FourNew from "./_fragments/tabs-overflow.fourNew.astro";
import TwelveNew from "./_fragments/tabs-overflow.twelveNew.astro";

const resizable = css({
  resize: "horizontal",
  overflow: "hidden",
  width: "100%",
  minWidth: "8rem",
  maxWidth: "100%",
  padding: "xs",
  borderWidth: "1px",
  borderStyle: "dashed",
  borderColor: "neutral.border",
  borderRadius: "sm",
});
---

<div class={stack({ gap: "xl", direction: "column" })}>
  <div class={resizable}>
    <Navigation
      aria-label="Mailbox views (adapt)"
      appearance="tabs"
      overflow="adapt"
      activeItem="Mentions"
      items={[
        {
          label: "Inbox",
          href: "#",
          leading: Inbox,
          trailing: TwelveNew,
          priority: 1,
        },
        {
          label: "Mentions",
          href: "#",
          leading: Mentions,
          trailing: FourNew,
        },
        { label: "Assigned to me", href: "#", leading: Assigned },
        { label: "Archive", href: "#", leading: Archive },
        { label: "Settings", href: "#", leading: Settings },
      ]}
    />
  </div>
  <div class={resizable}>
    <Navigation
      aria-label="Mailbox views (scroll)"
      appearance="tabs"
      overflow="scroll"
      scrollButtons
      scrollbar="hidden"
      activeItem="Archive"
      items={[
        { label: "Inbox", href: "#", leading: Inbox, trailing: TwelveNew },
        {
          label: "Mentions",
          href: "#",
          leading: Mentions,
          trailing: FourNew,
        },
        { label: "Assigned to me", href: "#", leading: Assigned },
        { label: "Archive", href: "#", leading: Archive },
        { label: "Settings", href: "#", leading: Settings },
      ]}
    />
  </div>
</div>

Actions in an adapt strip

Tabs can carry actions in an overflow="adapt" strip: as the container narrows, a row folds to its icon and keeps its actions beside it.

---
// GENERATED from packages/demos/navigation/overflow-actions.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { css } from "@pindoba/styled-system/css";
import ItemsLeading0 from "./_fragments/overflow-actions.itemsLeading0.astro";
import ItemsActions0 from "./_fragments/overflow-actions.itemsActions0.astro";
import ItemsLeading1 from "./_fragments/overflow-actions.itemsLeading1.astro";
import ItemsActions1 from "./_fragments/overflow-actions.itemsActions1.astro";
import ItemsLeading2 from "./_fragments/overflow-actions.itemsLeading2.astro";
import ItemsActions2 from "./_fragments/overflow-actions.itemsActions2.astro";
import ItemsLeading3 from "./_fragments/overflow-actions.itemsLeading3.astro";

const resizable = css({
  resize: "horizontal",
  overflow: "hidden",
  width: "100%",
  minWidth: "8rem",
  maxWidth: "100%",
  padding: "xs",
  borderWidth: "1px",
  borderStyle: "dashed",
  borderColor: "neutral.border",
  borderRadius: "sm",
});
---

<div class={resizable}>
  <Navigation
    aria-label="Project boards"
    appearance="tabs"
    semantics="list"
    overflow="adapt"
    activeItem="Roadmap"
    items={[
      {
        label: "Roadmap",
        href: "#",
        leading: ItemsLeading0,
        priority: 1,
        actions: ItemsActions0,
      },
      {
        label: "Sprint backlog",
        href: "#",
        leading: ItemsLeading1,
        actions: ItemsActions1,
      },
      {
        label: "Bug triage",
        href: "#",
        leading: ItemsLeading2,
        actions: ItemsActions2,
      },
      {
        label: "Release notes",
        href: "#",
        leading: ItemsLeading3,
      },
    ]}
  />
</div>

Semantics

By default Navigation is an ARIA menu: menubar on the root, menu on sublists, menuitem on rows. That’s right for an application menu, but a docs sidebar or a table of contents is site navigation, not a menu — and the menu roles constrain what a row may contain.

semantics="list" drops them all and renders native navigation instead: <nav><ul><li><a>, with a submenu parent staying a disclosure button (aria-expanded), which is what it already is behaviourally. It’s an all-or-nothing switch because the roles are: a role="menu" list may only contain menuitem / group / separator children, so they can’t be suppressed on individual rows.

Switching to "list" is what makes linkableParent available.

The two models also differ on the keyboard. Under "menu" the widget follows the ARIA menubar pattern: the whole navigation is one Tab stop (a roving tabindex seeded from the active item), arrow keys move focus along the current level — with wrap, Home/End, and disabled items skipped — and the axis arrows open and close submenus (ArrowDown on a horizontal menubar, ArrowRight in a vertical sidebar; opening focuses the first child, Escape closes and refocuses the parent). Under "list" none of that applies: every link and disclosure is a normal Tab stop, which is exactly what site navigation should be.

Linkable submenu parents

A submenu parent normally renders as a toggle <button> — correct when the parent is a category rather than a destination. But a docs heading that owns sub-headings is a destination, and so is a settings section with its own landing page.

linkableParent splits that row into an <a> that navigates and a <button> that expands, side by side. The row becomes a container (itemRow), the same anatomy as a row with actions: the link is stretched over the whole row, and the disclosure sits in the row’s end Affix (itemActions), after any actions of its own.

They are two tab stops, so each rings on its own: keyboard focus on the link lights the whole row (the anchor fills it), while focus on the disclosure rings just the chevron. Mouse clicks ring neither — both are keyed on :focus-visible.

It is ignored — with a dev warning — under the default semantics="menu" (a menuitem forbids interactive descendants), and silently when the item has no href (core won’t invent a destination) or no child items. In compact rails the row collapses back to a single control, since there’s no room for two hit targets.

The disclosure gets a derived accessible name (Toggle <label>), overridable per item via submenuToggleLabel or navigation-wide with a submenuToggleLabel function.

---
// GENERATED from packages/demos/navigation/linkable-parents.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";

const items = [
  { label: "Overview", href: "#overview", tooltip: false as const },
  {
    label: "Styling",
    href: "#styling",
    tooltip: false as const,
    linkableParent: true,
    defaultExpanded: true,
    items: [
      { label: "Tokens", href: "#tokens", tooltip: false as const },
      { label: "Recipes", href: "#recipes", tooltip: false as const },
    ],
  },
  {
    label: "API",
    href: "#api",
    tooltip: false as const,
    linkableParent: true,
    items: [{ label: "Props", href: "#props", tooltip: false as const }],
  },
];
---

<div class={stack({ gap: "lg" })}>
  <Navigation
    items={items}
    direction="vertical"
    semantics="list"
    size="sm"
    background="transparent"
    activeItem="Tokens"
  />
</div>

Split rows with actions

A linkable parent can carry actions too: it keeps its link, its disclosure and its actions, with the actions placed before the chevron and revealed on hover or focus.

---
// GENERATED from packages/demos/navigation/split-row-actions.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { css } from "@pindoba/styled-system/css";
import SectionsActions0 from "./_fragments/split-row-actions.sectionsActions0.astro";
import SectionsActions1 from "./_fragments/split-row-actions.sectionsActions1.astro";
import SectionsActions2 from "./_fragments/split-row-actions.sectionsActions2.astro";
---

<div class={css({ width: "100%", maxWidth: "24rem" })}>
  <Navigation
    aria-label="Team handbook"
    direction="vertical"
    semantics="list"
    size="sm"
    background="transparent"
    activeItem="first-week"
    sections={[
      {
        title: "Handbook",
        items: [
          {
            id: "onboarding",
            label: "Onboarding",
            href: "#onboarding",
            tooltip: false as const,
            linkableParent: true,
            defaultExpanded: true,
            actions: SectionsActions0,
            items: [
              {
                id: "first-week",
                label: "Your first week",
                href: "#first-week",
                tooltip: false as const,
              },
              {
                id: "accounts",
                label: "Accounts and access",
                href: "#accounts",
                tooltip: false as const,
              },
            ],
          },
          {
            id: "runbooks",
            label: "Runbooks",
            href: "#runbooks",
            tooltip: false as const,
            linkableParent: true,
            actions: SectionsActions1,
            items: [
              {
                id: "failover",
                label: "Database failover",
                href: "#failover",
                tooltip: false as const,
              },
            ],
          },
          {
            id: "glossary",
            label: "Glossary",
            href: "#glossary",
            tooltip: false as const,
            actions: SectionsActions2,
          },
        ],
      },
    ]}
  />
</div>

In a flyout (horizontal or compact) the popover attaches to the disclosure button, since that is the control that opens it. The trigger is a single binding, so with submenuTrigger="hover" the hover target is the chevron rather than the whole row.

DOM-driven active state

activeItem decides the current row at render time. A table of contents can’t work that way: which heading is current is decided at scroll time by a client scroll-spy that moves aria-current between links.

activeSource="current" hands the decision to the DOM. Nothing is baked — no data-panel-active, and no aria-current emitted from activeItem (a baked value would fight the spy) — and the row styles itself from whatever carries a truthy aria-current. On a container row (a split row or a row with actions) the marked element is the link inside the row, and the row’s own styles follow it automatically.

activeItem is still read in this mode for seeding which submenus start expanded, which is usually what you want.

One caveat: the active look follows aria-current, so if a client sets it on more than one element, more than one row lights up.

Client-side routing

Items are plain <a href> links, so by default a click loads the page. To route through your app’s router instead, Navigation gives you three pieces: the router tells it which item is current, the router renders or intercepts the links, and everything else — aria-current, the roving Tab stop, the keyboard model, submenus — keeps working.

Which item is current. Pass the router’s location as currentHref and the item whose href equals it becomes the active item, exactly as if its id had been passed as activeItem: it gets aria-current="page", the active look, the Tab stop, and its submenu ancestors start expanded. The match is an exact string comparison; for prefix or pattern matching pass isItemActive(item) instead (the first item in tree order that returns true wins). An explicit activeItem still beats both.

Plain clicks only. Every framework hook below only sees clicks the browser would follow in the current tab. Cmd/Ctrl-click, Shift-click, Alt-click, middle clicks, target="_blank", download, rel="external", cross-origin links and clicks another handler already preventDefault()ed are left to the browser — so “open in new tab” keeps working. Keyboard activation (Enter, and Space under menu semantics) arrives as a plain click and routes the same way. Links inside flyout submenus are covered too, and the flyout still closes. The rule is isPlainNavigationClick in @pindoba/core-navigation, exported for your own router links.

React: TanStack Router and Next.js

linkComponent renders every link row through your router’s <Link>. It receives the anchor’s full bag — href, className, aria-current, role, tabIndex, id, data-* and the item’s own handlers — and must forward ref to the rendered <a> (tooltips and the keyboard model attach through it; both routers’ links do). Submenu toggles stay <button>s, and items without an href stay plain anchors.

Next.js’s Link takes href, so it plugs in as-is:

import Link from "next/link";
import { usePathname } from "next/navigation";
import { Navigation } from "@pindoba/react-navigation";

export function Sidebar() {
  return (
    <Navigation
      items={items}
      direction="vertical"
      linkComponent={Link}
      currentHref={usePathname()}
    />
  );
}

TanStack Router’s Link takes to, so map it in a small adapter. Wrap it in forwardRef: React 19 passes ref as a plain prop, but React 18 only hands it to a forwardRef component, so this shape works on both:

import { forwardRef } from "react";
import { Link, useLocation } from "@tanstack/react-router";
import {
  Navigation,
  type NavigationLinkProps,
} from "@pindoba/react-navigation";

const RouterLink = forwardRef<HTMLAnchorElement, NavigationLinkProps>(
  function RouterLink({ href, ...props }, ref) {
    return <Link to={href} {...props} ref={ref} />;
  },
);

export function Sidebar() {
  const pathname = useLocation({ select: (location) => location.pathname });
  return (
    <Navigation
      items={items}
      direction="vertical"
      linkComponent={RouterLink}
      currentHref={pathname}
    />
  );
}

React 18 and NavigationLinkComponent. linkComponent is typed NavigationLinkComponent, an alias of JSXElementConstructor<NavigationLinkProps>. It is deliberately looser than ComponentType: with @types/react@18 a forwardRef component fails ComponentType<P> (its legacy propTypes carry a string ref), which rejected exactly the adapter React 18 needs. A forwardRef link, like the one above, type-checks on both React 18 and 19. A plain function component only works on React 19, where ref is an ordinary prop.

Router links mark the active link with aria-current themselves. Feeding the same location to currentHref keeps the two in agreement; alternatively set activeSource="current" and let the router’s aria-current drive the look (see DOM-driven active state).

If you’d rather keep plain anchors, onNavigate works in React too:

const navigate = useNavigate();

<Navigation
  items={items}
  currentHref={pathname}
  onNavigate={(href, event) => {
    event.preventDefault();
    navigate({ to: href });
  }}
/>;

Svelte and Vue: onNavigate

onNavigate(href, event, itemId) is called for each plain click on one of the navigation’s links. Call event.preventDefault() and hand href to the router; return without preventing to let the browser follow the link.

<Navigation
  {items}
  currentHref={page.url.pathname}
  onNavigate={(href, event) => {
    event.preventDefault();
    goto(href);
  }}
/>

SvelteKit needs no hook at all — it already intercepts plain <a> clicks — so there currentHref={page.url.pathname} is the whole integration.

In Vue it’s the navigate event. Vue also takes a linkComponent: the component receives the anchor bag plus to: href, so Vue Router’s RouterLink (or Nuxt’s NuxtLink) works as-is:

<script setup lang="ts">
import { RouterLink, useRoute } from "vue-router";
const route = useRoute();
</script>

<template>
  <Navigation
    :items="items"
    :linkComponent="RouterLink"
    :currentHref="route.path"
  />
</template>

Astro: the pindoba:navigate event

A server-rendered router needs nothing: pass currentHref={Astro.url.pathname} and every render marks the right row. For a client-side router, the navigation dispatches a cancelable, bubbling pindoba:navigate event on the <nav> for each plain link click — event.detail carries { href, itemId, originalEvent }, and cancelling the event cancels the click:

nav.addEventListener("pindoba:navigate", (event) => {
  event.preventDefault();
  router.navigate(event.detail.href);
});

The frontmatter can’t re-render on a client-side navigation, so pair it with activeSource="current" and move aria-current yourself, as the Astro demo does.

The demo below runs a tiny in-memory router: clicks change the route without a page load (the counter would reset on a reload), aria-current follows, and Cmd/Ctrl-click still opens the link in a new tab.

Route: ?route=inbox · client-side navigations: 0

---
// GENERATED from packages/demos/navigation/router-links.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

const items = [
  { label: "Inbox", href: "?route=inbox" },
  { label: "Drafts", href: "?route=drafts" },
  {
    label: "Settings",
    items: [
      { label: "Profile", href: "?route=settings/profile" },
      { label: "Billing", href: "?route=settings/billing" },
    ],
  },
];
const status = css({ fontSize: "sm", color: "neutral.text.muted" });

const path = "?route=inbox";
const navigations = 0;
---

<div class={stack({ gap: "md" })} data-ir-root="navigation/router-links">
  <Navigation
    items={items}
    direction="vertical"
    currentHref={path}
    data-ir="1"
  />
  <p class={status}>
    Route:{" "}
    <code>
      <!--ir:2-->
      {path}
      <!--/ir-->
    </code>{" "}
    · client-side navigations: <!--ir:3-->
    {navigations}
    <!--/ir-->
  </p>
</div>

<script>
  import { hydrate } from "./_client/router-links.ts";

  document
    .querySelectorAll('[data-ir-root="navigation/router-links"]')
    .forEach((root) => hydrate([root]));
</script>

Suppressing the native title

An item with no tooltip falls back to a native title on hover. Where labels are always fully visible — a table of contents again — that’s a tooltip repeating text the reader can already see. tooltip: false on the item declines both the tooltip and the title, including the auto-derived one a compact rail would otherwise add.

Give any item child items and it becomes a submenu. In an expanded vertical navigation children expand inline below the parent (the parent renders as a toggle button with aria-expanded). Parents of the active item start expanded; defaultExpanded seeds the rest.

---
// GENERATED from packages/demos/navigation/submenu-inline.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";
import Home from "./_fragments/submenu-inline.home.astro";
import Products from "./_fragments/submenu-inline.products.astro";
import Settings from "./_fragments/submenu-inline.settings.astro";
---

<div
  class={stack({
    gap: "md",
    direction: "column",
    maxWidth: "320px",
    width: "100%",
  })}
>
  <Navigation
    items={[
      { label: "Home", href: "#", leading: Home },
      {
        label: "Products",
        leading: Products,
        items: [
          { label: "Analytics", href: "#analytics" },
          { label: "Automation", href: "#automation" },
          {
            label: "Reports",
            items: [
              { label: "Weekly", href: "#weekly" },
              { label: "Monthly", href: "#monthly" },
            ],
          },
        ],
      },
      {
        label: "Settings",
        leading: Settings,
        defaultExpanded: true,
        items: [
          { label: "Profile", href: "#profile" },
          { label: "Billing", href: "#billing" },
        ],
      },
    ]}
    activeItem="analytics"
    direction="vertical"
    emphasis="tertiary"
  />
</div>

In a horizontal navigation submenus open as dropdown flyouts below the item. The default trigger is click; set submenuTrigger="hover" for hover-open with intent delays.

---
// GENERATED from packages/demos/navigation/submenu-flyout.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";
---

<div class={stack({ gap: "xl", direction: "column" })}>
  <Navigation
    items={[
      { label: "Home", href: "#" },
      {
        label: "Products",
        items: [
          { label: "Analytics", href: "#analytics" },
          { label: "Automation", href: "#automation" },
          { label: "Reports", href: "#reports" },
        ],
      },
      {
        label: "Resources",
        items: [
          { label: "Documentation", href: "#docs" },
          { label: "Community", href: "#community" },
          {
            label: "Guides",
            items: [
              { label: "Getting started", href: "#getting-started" },
              { label: "Theming", href: "#theming" },
            ],
          },
        ],
      },
      { label: "Pricing", href: "#pricing" },
    ]}
    activeItem="analytics"
  />
  <Navigation
    submenuTrigger="hover"
    items={[
      { label: "Home", href: "#" },
      {
        label: "Products",
        items: [
          { label: "Analytics", href: "#analytics" },
          { label: "Automation", href: "#automation" },
        ],
      },
      { label: "Pricing", href: "#pricing" },
    ]}
    emphasis="tertiary"
  />
</div>

Flyouts work in a tab strip too. Here activeEmphasis fills the tabs, the current page is a child under Reports (so that tab reads as current), and overflow="adapt" folds the tabs to icons as you narrow the box — a folded parent still opens its flyout.

---
// GENERATED from packages/demos/navigation/submenu-flyout-tabs.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { css } from "@pindoba/styled-system/css";
import Overview from "./_fragments/submenu-flyout-tabs.overview.astro";
import Projects from "./_fragments/submenu-flyout-tabs.projects.astro";
import Reports from "./_fragments/submenu-flyout-tabs.reports.astro";
import Team from "./_fragments/submenu-flyout-tabs.team.astro";
import Settings from "./_fragments/submenu-flyout-tabs.settings.astro";
import Invites from "./_fragments/submenu-flyout-tabs.invites.astro";

const resizable = css({
  resize: "horizontal",
  overflow: "hidden",
  width: "100%",
  minWidth: "8rem",
  maxWidth: "100%",
  padding: "xs",
  borderWidth: "1px",
  borderStyle: "dashed",
  borderColor: "neutral.border",
  borderRadius: "sm",
});
---

<div class={resizable}>
  <Navigation
    aria-label="Workspace"
    appearance="tabs"
    activeEmphasis="secondary"
    overflow="adapt"
    activeItem="usage"
    items={[
      {
        id: "overview",
        label: "Overview",
        href: "#overview",
        leading: Overview,
        priority: 1,
      },
      {
        id: "projects",
        label: "Projects",
        leading: Projects,
        items: [
          { id: "active", label: "Active", href: "#active" },
          { id: "archived", label: "Archived", href: "#archived" },
          { id: "templates", label: "Templates", href: "#templates" },
        ],
      },
      {
        id: "reports",
        label: "Reports",
        leading: Reports,
        items: [
          { id: "revenue", label: "Revenue", href: "#revenue" },
          { id: "usage", label: "Usage", href: "#usage" },
          { id: "exports", label: "Exports", href: "#exports" },
        ],
      },
      {
        id: "team",
        label: "Team",
        href: "#team",
        leading: Team,
        trailing: Invites,
      },
      {
        id: "settings",
        label: "Settings",
        href: "#settings",
        leading: Settings,
      },
    ]}
  />
</div>

Submenus keep working when the navigation collapses — the children fly out to the side with their full labels, and the parent shows the active state when its (hidden) child is the current page. Trailing badges float over the corner at the same time.

---
// GENERATED from packages/demos/navigation/submenu-compact.demo.tsx — edit the source, not this file.
import Navigation from "@pindoba/astro-navigation";
import { stack } from "@pindoba/styled-system/patterns";
import Home from "./_fragments/submenu-compact.home.astro";
import Products from "./_fragments/submenu-compact.products.astro";
import Settings from "./_fragments/submenu-compact.settings.astro";
import Five from "./_fragments/submenu-compact.five.astro";
---

<div class={stack({ gap: "md", direction: "row" })}>
  <Navigation
    items={[
      { label: "Home", href: "#", leading: Home },
      {
        label: "Products",
        leading: Products,
        trailing: Five,
        items: [
          { label: "Analytics", href: "#analytics" },
          { label: "Automation", href: "#automation" },
          { label: "Reports", href: "#reports" },
        ],
      },
      {
        label: "Settings",
        leading: Settings,
        items: [
          { label: "Profile", href: "#profile" },
          { label: "Billing", href: "#billing" },
        ],
      },
    ]}
    activeItem="analytics"
    direction="vertical"
    compact="icon"
  />
</div>
  • Components
  • Blocks