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 feedback ramp, and primary fills every row with the accent surface.

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

---
import Navigation from "../Navigation.astro";
import NavigationItem from "../NavigationItem.astro";
import { stack } from "@pindoba/styled-system/patterns";
import type { NavigationBaseProps } from "@pindoba/core-navigation";

// Every item is a Panel, so `emphasis` is Panel's: how the RESTING rows look.
// `tertiary` (the default) is colorless, `secondary` tints on the feedback
// ramp, `primary` fills every row with the accent surface. In the composed
// Astro form each `<NavigationItem>` takes its own style props (there's no
// context cascade).
// `feedback="primary"` is set here only so the levels are TELLABLE APART: on
// the default neutral palette `secondary` and `tertiary` resolve to the same
// ramp. It also tints the resting labels, which is why it isn't the default.
const levels: NonNullable<NavigationBaseProps["emphasis"]>[] = [
  "tertiary",
  "secondary",
  "primary",
];
const labels = ["Overview", "Reports", "Settings"];
---

<div class={stack({ gap: "lg" })}>
  {
    levels.map((emphasis) => (
      <Navigation feedback="primary" emphasis={emphasis}>
        {labels.map((label) => (
          <NavigationItem
            label={label}
            href="#"
            feedback="primary"
            emphasis={emphasis}
            isActive={label === "Overview"}
          />
        ))}
      </Navigation>
    ))
  }
</div>

Active Emphasis

activeEmphasis controls how loud the current item is, independent of the resting rows. It defaults to secondary — a tint on the feedback 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 feedback="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.

---
import Navigation from "../Navigation.astro";
import NavigationItem from "../NavigationItem.astro";
import { stack } from "@pindoba/styled-system/patterns";
import type { NavigationBaseProps } from "@pindoba/core-navigation";

// How loud the CURRENT item is, independent of the resting rows. `secondary`
// (the default) tints on the feedback ramp and promotes the text — enough to
// mark the row without shouting; `primary` fills it with the brand surface
// for a nav that has to read at a glance, `tertiary` stays colorless. In the
// composed Astro form each `<NavigationItem>` takes its own style props
// (there's no context cascade).
// `feedback="primary"` is set here only so the levels are TELLABLE APART: on
// the default neutral palette `secondary` and `tertiary` resolve to the same
// ramp. It also tints the resting labels, which is why it isn't the default.
const levels: NonNullable<NavigationBaseProps["activeEmphasis"]>[] = [
  "secondary",
  "primary",
  "tertiary",
];
const labels = ["Overview", "Reports", "Settings"];
---

<div class={stack({ gap: "lg" })}>
  {
    levels.map((activeEmphasis) => (
      <Navigation feedback="primary" activeEmphasis={activeEmphasis}>
        {labels.map((label) => (
          <NavigationItem
            label={label}
            href="#"
            feedback="primary"
            activeEmphasis={activeEmphasis}
            isActive={label === "Reports"}
          />
        ))}
      </Navigation>
    ))
  }
</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.

---
import Navigation from "../Navigation.astro";
import NavigationItem from "../NavigationItem.astro";
import { stack } from "@pindoba/styled-system/patterns";

// The `<nav>` is a Panel too: the `root*` props give it its own surface, so a
// sidebar shell needs no wrapper element. Items go `background="transparent"`
// so only the current row paints against the rail.
const labels = ["Overview", "Reports", "Settings"];
---

<div class={stack({ gap: "lg", direction: "row" })}>
  <Navigation
    direction="vertical"
    background="transparent"
    rootBackground="surface.hill"
    rootPadding="xs"
    rootRadius="xl"
    rootBorder="muted"
  >
    {
      labels.map((label) => (
        <NavigationItem
          label={label}
          href="#"
          direction="vertical"
          background="transparent"
          isActive={label === "Reports"}
        />
      ))
    }
  </Navigation>

  <Navigation
    direction="vertical"
    background="transparent"
    rootBackground="surface.ground"
    rootPadding="xs"
    rootRadius="xl"
    rootShadow="md"
  >
    {
      labels.map((label) => (
        <NavigationItem
          label={label}
          href="#"
          direction="vertical"
          background="transparent"
          activeEmphasis="tertiary"
          isActive={label === "Reports"}
        />
      ))
    }
  </Navigation>
</div>

Leading Icons

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

---
import Navigation from "../Navigation.astro";
import NavigationItem from "../NavigationItem.astro";
import Stamp from "@pindoba/astro-stamp";
import { FileText, House, Inbox, LogOut, Send } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";

const items = [
  { label: "Home", icon: House },
  { label: "Inbox", icon: Inbox },
  { label: "Drafts", icon: FileText },
  { label: "Sent", icon: Send },
  { label: "Logout", icon: LogOut },
];
---

<div class={stack({ gap: "md", direction: "column" })}>
  <Navigation direction="vertical">
    {
      items.map((item) => {
        const LeadingIcon = item.icon;
        return (
          <NavigationItem
            label={item.label}
            href="#"
            direction="vertical"
            isActive={item.label === "Inbox"}
          >
            <Stamp slot="leading" emphasis="ghost">
              <LeadingIcon />
            </Stamp>
          </NavigationItem>
        );
      })
    }
  </Navigation>
</div>

Trailing Stamps

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

---
import Navigation from "../Navigation.astro";
import NavigationItem from "../NavigationItem.astro";
import Stamp from "@pindoba/astro-stamp";
import { Bell, Check, Shield, Star, Zap } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";
---

<div class={stack({ gap: "md", direction: "column" })}>
  <Navigation>
    <NavigationItem label="Featured" href="#" isActive>
      <Stamp slot="trailing" size="xs" emphasis="adaptive" shape="circle">
        <Star />
      </Stamp>
    </NavigationItem>
    <NavigationItem label="Verified" href="#">
      <Stamp
        slot="trailing"
        size="xs"
        emphasis="secondary"
        feedback="success"
        shape="circle"
      >
        <Check />
      </Stamp>
    </NavigationItem>
    <NavigationItem label="Promoted" href="#">
      <Stamp
        slot="trailing"
        size="xs"
        emphasis="secondary"
        feedback="warning"
        shape="circle"
      >
        <Zap />
      </Stamp>
    </NavigationItem>
    <NavigationItem label="Subscribed" href="#">
      <Stamp slot="trailing" size="xs" emphasis="secondary" feedback="primary">
        <Bell />
      </Stamp>
    </NavigationItem>
    <NavigationItem label="Protected" href="#">
      <Stamp slot="trailing" size="xs" emphasis="secondary" feedback="neutral">
        <Shield />
      </Stamp>
    </NavigationItem>
  </Navigation>
</div>

Leading and Trailing Combined

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

---
import Navigation from "../Navigation.astro";
import NavigationItem from "../NavigationItem.astro";
import Badge from "@pindoba/astro-badge";
import Stamp from "@pindoba/astro-stamp";
import {
  Bell,
  ChevronRight,
  House,
  Inbox,
  Settings,
  Star,
} from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";
---

<div class={stack({ gap: "md", direction: "column" })}>
  <Navigation direction="vertical">
    <NavigationItem label="Home" href="#" direction="vertical">
      <Stamp slot="leading" emphasis="ghost">
        <House />
      </Stamp>
      <Stamp slot="trailing" emphasis="ghost">
        <ChevronRight />
      </Stamp>
    </NavigationItem>
    <NavigationItem label="Inbox" href="#" direction="vertical" isActive>
      <Stamp slot="leading" emphasis="ghost">
        <Inbox />
      </Stamp>
      <Badge slot="trailing" emphasis="primary">12</Badge>
    </NavigationItem>
    <NavigationItem label="Notifications" href="#" direction="vertical">
      <Stamp slot="leading" emphasis="ghost">
        <Bell />
      </Stamp>
      <Badge slot="trailing" emphasis="secondary" feedback="success">
        New
      </Badge>
    </NavigationItem>
    <NavigationItem label="Settings" href="#" direction="vertical">
      <Stamp slot="leading" emphasis="ghost">
        <Settings />
      </Stamp>
      <Stamp slot="trailing" emphasis="primary" shape="circle">
        <Star />
      </Stamp>
    </NavigationItem>
  </Navigation>
</div>

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.

---
import Navigation from "../Navigation.astro";
import NavigationItem from "../NavigationItem.astro";
import Badge from "@pindoba/astro-badge";
import Stamp from "@pindoba/astro-stamp";
import { Check, Star } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";

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

<div class={stack({ gap: "xl", direction: "column" })}>
  {
    sizes.map((size) => (
      <div>
        <h3>{size}</h3>
        <Navigation size={size}>
          <NavigationItem label="Home" href="#" size={size}>
            <Stamp slot="trailing" emphasis="primary" shape="circle">
              <Star />
            </Stamp>
          </NavigationItem>
          <NavigationItem label="Inbox" href="#" size={size} isActive>
            <Badge slot="trailing" emphasis="primary" feedback="primary">
              12
            </Badge>
          </NavigationItem>
          <NavigationItem label="Drafts" href="#" size={size}>
            <Badge slot="trailing" emphasis="secondary" feedback="neutral">
              3
            </Badge>
          </NavigationItem>
          <NavigationItem label="Sent" href="#" size={size}>
            <Stamp slot="trailing" emphasis="secondary" feedback="success">
              <Check />
            </Stamp>
          </NavigationItem>
        </Navigation>
      </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.

---
import Navigation from "../Navigation.astro";
import NavigationItem from "../NavigationItem.astro";
import Stamp from "@pindoba/astro-stamp";
import { FileText, House, Inbox, LogOut, Send } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";

const items = [
  { label: "Home", icon: House },
  { label: "Inbox", icon: Inbox },
  { label: "Drafts", icon: FileText },
  { label: "Sent", icon: Send },
  { label: "Logout", icon: LogOut },
];
---

<div class={stack({ gap: "md", direction: "column" })}>
  <Navigation direction="vertical" compact="stack">
    {
      items.map((item) => {
        const LeadingIcon = item.icon;
        return (
          <NavigationItem
            label={item.label}
            href="#"
            direction="vertical"
            compact="stack"
            isActive={item.label === "Inbox"}
          >
            <Stamp slot="leading" emphasis="ghost">
              <LeadingIcon />
            </Stamp>
          </NavigationItem>
        );
      })
    }
  </Navigation>
</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.

---
import Navigation from "../Navigation.astro";
import NavigationItem from "../NavigationItem.astro";
import Stamp from "@pindoba/astro-stamp";
import { FileText, House, Inbox, LogOut, Send } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";

const items = [
  { label: "Home", icon: House },
  { label: "Inbox", icon: Inbox },
  { label: "Drafts", icon: FileText },
  { label: "Sent", icon: Send },
  { label: "Logout", icon: LogOut },
];
---

<div class={stack({ gap: "md", direction: "column" })}>
  <Navigation direction="vertical" compact="icon">
    {
      items.map((item) => {
        const LeadingIcon = item.icon;
        return (
          <NavigationItem
            label={item.label}
            href="#"
            direction="vertical"
            compact="icon"
            isActive={item.label === "Inbox"}
          >
            <Stamp slot="leading" emphasis="ghost">
              <LeadingIcon />
            </Stamp>
          </NavigationItem>
        );
      })
    }
  </Navigation>
</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.

---
import Navigation from "../Navigation.astro";
import { stack } from "@pindoba/styled-system/patterns";
import StampBox from "./parts/stamp-box.astro";
import StampHouse from "./parts/stamp-house.astro";
import StampSettings from "./parts/stamp-settings.astro";

// ONE live instance. `compactToggle` makes the connect render the runtime-safe
// structure, and the recipe keys every compact style off the root's
// `data-compact` attribute — so the client script below just flips that
// attribute and the rail morph animates in place. Matches the svelte demo's
// `none` → `stack` → `icon` cycle.
const items = [
  { id: "home", label: "Home", href: "#", leading: StampHouse },
  {
    id: "storage",
    label: "Storage",
    leading: StampBox,
    defaultExpanded: true,
    // Children stay reachable while collapsed through the rail flyout —
    // hover/focus the folded row and the panel opens with the full labels.
    items: [
      { id: "buckets", label: "Buckets", href: "#buckets" },
      { id: "snapshots", label: "Snapshots", href: "#snapshots" },
      { id: "archive", label: "Archive", href: "#archive" },
    ],
  },
  { id: "settings", label: "Settings", href: "#", leading: StampSettings },
];
---

<div class={stack({ gap: "md", direction: "column" })}>
  <button type="button" data-compact-toggle>compact: none</button>
  <Navigation
    direction="vertical"
    compactToggle
    activeItem="snapshots"
    data-compact-demo
    items={items}
  />
</div>

<script>
  const modes = ["none", "stack", "icon"] as const;

  document
    .querySelectorAll<HTMLButtonElement>("[data-compact-toggle]")
    .forEach((button) => {
      const nav = button
        .closest("div")
        ?.querySelector<HTMLElement>("[data-compact-demo]");
      if (!nav) return;

      let index = 0;

      button.addEventListener("click", () => {
        index = (index + 1) % modes.length;
        const next = modes[index];
        button.textContent = `compact: ${next}`;
        // The public runtime contract: set `data-compact="stack" | "icon"` on
        // the root `<nav>` to collapse, remove it to expand.
        if (next === "none") nav.removeAttribute("data-compact");
        else nav.setAttribute("data-compact", next);
      });
    });
</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.

---
import Navigation from "../Navigation.astro";
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>

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.

---
import Navigation from "../Navigation.astro";
import NavigationItem from "../NavigationItem.astro";
import Stamp from "@pindoba/astro-stamp";
import { FileText, House, Inbox, Send } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";

const items = [
  { label: "Home", icon: House, tooltip: "Go home" },
  {
    label: "Inbox",
    icon: Inbox,
    tooltip: { content: "12 unread", placement: "right" as const },
  },
  { label: "Drafts", icon: FileText, tooltip: "Drafts (3)" },
  { label: "Sent", icon: Send },
];
---

<div class={stack({ gap: "md", direction: "column" })}>
  <Navigation direction="vertical">
    {
      items.map((item) => {
        const LeadingIcon = item.icon;
        return (
          <NavigationItem
            label={item.label}
            href="#"
            direction="vertical"
            tooltip={item.tooltip}
            isActive={item.label === "Inbox"}
          >
            <Stamp slot="leading" emphasis="ghost">
              <LeadingIcon />
            </Stamp>
          </NavigationItem>
        );
      })
    }
  </Navigation>
</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.

---
import Navigation from "../Navigation.astro";
import NavigationItem from "../NavigationItem.astro";
import Badge from "@pindoba/astro-badge";
import Stamp from "@pindoba/astro-stamp";
import { FileText, House, Inbox, Send } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";

const modes = ["icon", "stack"] as const;
---

{
  /* In compact modes the inline trailing slot is hidden — the same trailing
    content floats over the item's corner as an Attachment instead. */
}
<div class={stack({ gap: "xl", direction: "row" })}>
  {
    modes.map((mode) => (
      <Navigation direction="vertical" compact={mode}>
        <NavigationItem
          label="Home"
          href="#"
          direction="vertical"
          compact={mode}
        >
          <Stamp slot="leading" emphasis="ghost">
            <House />
          </Stamp>
        </NavigationItem>
        <NavigationItem
          label="Inbox"
          href="#"
          direction="vertical"
          compact={mode}
          isActive
        >
          <Stamp slot="leading" emphasis="ghost">
            <Inbox />
          </Stamp>
          <Badge slot="trailing" size="xs" emphasis="primary" feedback="danger">
            12
          </Badge>
        </NavigationItem>
        <NavigationItem
          label="Drafts"
          href="#"
          direction="vertical"
          compact={mode}
        >
          <Stamp slot="leading" emphasis="ghost">
            <FileText />
          </Stamp>
          <Badge
            slot="trailing"
            size="xs"
            emphasis="secondary"
            feedback="neutral"
          >
            3
          </Badge>
        </NavigationItem>
        <NavigationItem
          label="Sent"
          href="#"
          direction="vertical"
          compact={mode}
        >
          <Stamp slot="leading" emphasis="ghost">
            <Send />
          </Stamp>
        </NavigationItem>
      </Navigation>
    ))
  }
</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.

---
import Navigation from "../Navigation.astro";
import NavigationItem from "../NavigationItem.astro";
import Badge from "@pindoba/astro-badge";
import Stamp from "@pindoba/astro-stamp";
import { FileText, House, Inbox } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";
import type { NavigationTrailingAttachment } from "@pindoba/core-navigation";

// The standalone `<NavigationItem>` takes the same `trailingAttachment` config
// as the composed `<Navigation items={…} />` form.
const rails: {
  caption: string;
  inbox?: NavigationTrailingAttachment;
  drafts?: NavigationTrailingAttachment;
}[] = [
  // Default: top-end, hugging the item's rounded corner.
  { caption: "default" },
  // Moved for both badges.
  {
    caption: "bottom-end",
    inbox: { placement: "bottom-end", offset: 2 },
    drafts: { placement: "bottom-end", offset: 2 },
  },
  // Per item: one badge moved, one attachment switched off.
  { caption: "per item", inbox: { placement: "top-start" }, drafts: false },
];
---

<div class={stack({ gap: "xl", direction: "row" })}>
  {
    rails.map((rail) => (
      <Navigation direction="vertical" compact="icon">
        <NavigationItem
          label="Home"
          href="#"
          direction="vertical"
          compact="icon"
        >
          <Stamp slot="leading" emphasis="ghost">
            <House />
          </Stamp>
        </NavigationItem>
        <NavigationItem
          label="Inbox"
          href="#"
          direction="vertical"
          compact="icon"
          trailingAttachment={rail.inbox}
          isActive
        >
          <Stamp slot="leading" emphasis="ghost">
            <Inbox />
          </Stamp>
          <Badge slot="trailing" size="xs" emphasis="primary" feedback="danger">
            12
          </Badge>
        </NavigationItem>
        <NavigationItem
          label="Drafts"
          href="#"
          direction="vertical"
          compact="icon"
          trailingAttachment={rail.drafts}
        >
          <Stamp slot="leading" emphasis="ghost">
            <FileText />
          </Stamp>
          <Badge
            slot="trailing"
            size="xs"
            emphasis="secondary"
            feedback="neutral"
          >
            3
          </Badge>
        </NavigationItem>
      </Navigation>
    ))
  }
</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.

---
import Navigation from "../Navigation.astro";
import NavigationItem from "../NavigationItem.astro";
import { stack } from "@pindoba/styled-system/patterns";
import type { NavigationBaseProps } from "@pindoba/core-navigation";

// Items reserve a 1px border at every level, so switching levels or states
// never shifts the row. The three props are independent: a border can show only
// on hover, or only on the current item. In the composed Astro form each
// `<NavigationItem>` takes its own style props (there's no context cascade).
type Border = NavigationBaseProps["border"];
const rows: {
  border?: Border;
  borderInteract?: Border;
  borderActive?: Border;
}[] = [
  // No border (the default).
  {},
  // A resting outline on every item, brighter under the pointer.
  { border: "muted", borderInteract: "default" },
  // Borderless at rest — the outline is the current item's affordance.
  { borderActive: "accent" },
  // Hover-only outline.
  { borderInteract: "bold" },
  // Outline on every row EXCEPT the current one.
  { border: "muted", borderActive: "none" },
];
const labels = ["Overview", "Reports", "Settings"];
---

<div class={stack({ gap: "lg" })}>
  {
    rows.map((row) => (
      <Navigation>
        {labels.map((label) => (
          <NavigationItem
            label={label}
            href="#"
            isActive={label === "Overview"}
            {...row}
          />
        ))}
      </Navigation>
    ))
  }
</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 feedback defaults to "primary" so the bar picks up the accent color — pass another feedback to retint it. It’s the same visual language as Radio’s appearance="tab" and Tab’s appearance="tabs".

---
import Navigation from "../Navigation.astro";
import { stack } from "@pindoba/styled-system/patterns";

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

<!-- `appearance="tabs"` turns the strip into minimal underline tabs: transparent
     items, a 2px accent bar on the current one, and a 1px baseline track on the
     container. `feedback` defaults to primary here; pass another to retint. -->
<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"
      feedback="danger"
      activeItem="Settings"
    />
  </div>
</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 Panel surface moves from the anchor to a wrapper (itemRow) so the link and the chevron read as one row and hover covers both.

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.

---
import Navigation from "../Navigation.astro";
import { stack } from "@pindoba/styled-system/patterns";
---

{
  /* A table of contents: every heading is a real destination, including the
    ones that own sub-headings. `linkableParent` splits those rows into a link
    plus a disclosure, so the group can be collapsed without giving up the
    anchor. It needs `semantics="list"` — a split row can't live inside an ARIA
    menu, where `menuitem` forbids interactive descendants. */
}
<div class={stack({ gap: "lg", maxWidth: "320px", width: "100%" })}>
  <Navigation
    direction="vertical"
    semantics="list"
    size="sm"
    background="transparent"
    activeItem="Tokens"
    items={[
      { label: "Overview", href: "#overview", tooltip: false },
      {
        label: "Styling",
        href: "#styling",
        tooltip: false,
        linkableParent: true,
        defaultExpanded: true,
        items: [
          { label: "Tokens", href: "#tokens", tooltip: false },
          { label: "Recipes", href: "#recipes", tooltip: false },
        ],
      },
      {
        label: "API",
        href: "#api",
        tooltip: false,
        linkableParent: true,
        items: [{ label: "Props", href: "#props", tooltip: false }],
      },
    ]}
  />
</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 split row the marked element is the anchor inside the Panel, so Navigation uses Panel’s current-within arm there 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.

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.

---
import Navigation from "../Navigation.astro";
import StampHouse from "./parts/stamp-house.astro";
import StampBox from "./parts/stamp-box.astro";
import StampSettings from "./parts/stamp-settings.astro";
import { stack } from "@pindoba/styled-system/patterns";
---

{
  /* Expanded vertical navigation: items with `items` expand inline. Parents of
    the active item start expanded; `defaultExpanded` seeds the rest. */
}
<div
  class={stack({
    gap: "md",
    direction: "column",
    maxWidth: "320px",
    width: "100%",
  })}
>
  <Navigation
    items={[
      { label: "Home", href: "#", leading: StampHouse },
      {
        label: "Products",
        leading: StampBox,
        items: [
          { label: "Analytics", href: "#analytics" },
          { label: "Automation", href: "#automation" },
          {
            label: "Reports",
            items: [
              { label: "Weekly", href: "#weekly" },
              { label: "Monthly", href: "#monthly" },
            ],
          },
        ],
      },
      {
        label: "Settings",
        leading: StampSettings,
        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.

---
import Navigation from "../Navigation.astro";
import { stack } from "@pindoba/styled-system/patterns";
---

{
  /* Horizontal navigation: items with `items` open a dropdown flyout. The
    second nav opens on hover instead of click. */
}
<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>

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.

---
import Navigation from "../Navigation.astro";
import StampHouse from "./parts/stamp-house.astro";
import StampBox from "./parts/stamp-box.astro";
import StampSettings from "./parts/stamp-settings.astro";
import BadgeFive from "./parts/badge-five.astro";
import { stack } from "@pindoba/styled-system/patterns";
---

{
  /* Collapsed rail: submenus fly out to the side with full labels; the parent
    of the active item shows the active state since its child is hidden.
    Trailing badges float over the corner via the Attachment. */
}
<div class={stack({ gap: "md", direction: "row" })}>
  <Navigation
    items={[
      { label: "Home", href: "#", leading: StampHouse },
      {
        label: "Products",
        leading: StampBox,
        trailing: BadgeFive,
        items: [
          { label: "Analytics", href: "#analytics" },
          { label: "Automation", href: "#automation" },
          { label: "Reports", href: "#reports" },
        ],
      },
      {
        label: "Settings",
        leading: StampSettings,
        items: [
          { label: "Profile", href: "#profile" },
          { label: "Billing", href: "#billing" },
        ],
      },
    ]}
    activeItem="analytics"
    direction="vertical"
    compact="icon"
  />
</div>
props · 30 shown · 30 total
activeAncestors
"compact""always"

Paint a parent row with the Panel active look while one of its descendants is the current item — the "where am I?" cue for rows whose active child is hidden. `"always"` keeps the cue in every state; `"compact"` shows it only while the rail is collapsed (`data-compact` present on the root), which is when the child is actually invisible. Omit for the default behavior (inline parents stay neutral; a closed flyout parent still takes the active look in `activeSource: "prop"` mode). Works with both `activeSource` modes: `"prop"` uses the baked `data-active-child` marker; `"current"` uses DOM detection — CSS for inline sublists, and (in Astro) a boot-script mirror for portaled flyout dialogs.

activeEmphasis
"primary""secondary""tertiary"
default "secondary"

How loud the *current* item is, independent of the resting `emphasis`. Defaults to `"secondary"` — a tint on the `feedback` ramp with promoted text and a lifted border, which marks the current row without shouting. Raise to `"primary"` for a filled brand row when the nav has to read at a glance, or drop to `"tertiary"` for a colorless tint.

activeItem
string

ID or label of the currently active navigation item.

activeSource
"prop""current"
default "prop"

What decides the active look. - `"prop"` (the default) — `activeItem`, resolved at render and baked as `data-panel-active`. - `"current"` — whatever element carries a truthy `aria-current` in the DOM. Nothing is baked and no `aria-current` is emitted; a client owns the state. This is the mode for a scroll-spy: the spy moves `aria-current` between links as the reader scrolls and the row styling follows with no re-render. `activeItem` is still honoured in `"current"` mode for seeding which submenus start open (`getDefaultOpenSubmenus`) — it just stops driving the visual state.

appearance
"default""tabs"
default "default"

Visual treatment of the item strip. `"tabs"` renders a minimal underline tab strip: transparent items, a 2px accent bar under (horizontal) or beside (vertical) the active item, and a 1px baseline track on the container. In this mode the per-item `border`/`borderInteract`/ `borderActive` props are ignored and `feedback` defaults to `"primary"` so the bar picks up the accent color.

background
"surface.peak""surface.hill""surface.base""surface.valley""surface.ground""transparent"
default "surface.peak"

Background surface token painted on each **item** (not on the `<nav>` — see `rootBackground` for that). Panel derives every hover/press/active step from this resting position, so it is what makes a nav on a deep surface step the right way.

border
"none""bold""default""muted""accent"
default "none"

Resting border color of each item, by token level — Panel's `border`. Items reserve a 1px border at every level (including `"none"`), so switching levels or states never shifts layout.

borderActive
"none""bold""inherit""default""muted""accent"
default "inherit"

Border color of the current item — Panel's `borderActive`. It holds while that item is hovered, so a `borderInteract` color can't steal it. `"inherit"` (the default) leaves the current row with the ring `activeEmphasis` chose; `"none"` explicitly clears it.

borderInteract
"none""bold""default""muted""accent"

Item border color while hovered or pressed — Panel's `borderInteract`. Omitted (the default) keeps whatever `border` painted; `"none"` explicitly clears the line under the pointer. Any other level makes a border appear on hover only.

compact
"none""stack""icon"
default "none"

Collapse the items to a narrow rail. `stack` shows icon over label with ellipsis; `icon` hides the label visually (kept for screen readers) and auto-wires the styled Tooltip from `item.label`.

compactToggle
boolean
default false

Render the compact-safe structural superset so `compact` can be flipped at RUNTIME by toggling `data-compact="stack" | "icon"` on the root `<nav>` — no re-render needed (the pure-Astro animated collapse; the recipe keys all compact styling off that attribute and animates the morph). Semantics compared to a static `compact`: - Inline submenus stay inline: an expanded vertical navigation keeps its in-place child lists, and the rail squeezes them closed. Children stay reachable while collapsed through the RAIL FLYOUT: each parent also renders its children as a flyout panel (`railChildren` / `railFlyout*Props`), which the Astro boot opens on row hover/focus only while `data-compact` is present — a `linkableParent` keeps navigating on click. - `linkableParent` split rows stay rendered; the rail hides the disclosure and the anchor owns the square. - Styled tooltips ARE auto-derived from `item.label`, gated on the root's `data-compact` (`openWhen`) so they only fire while collapsed — matching what a static `compact` gives, without repeating a visible label when expanded. Because a tooltip is present, the native `title` fallback is dropped in both states. An explicit `item.tooltip` still fires in both states unless it opts into the same gate with `when: "compact"`, and `tooltip: false` still declines everything. - Trailing content renders inline only (the rail folds it away); the floating compact `<Attachment>` badge is not emitted. - `data-compact` reflects the initial `compact` prop; the component does not persist runtime state.

direction
"horizontal""vertical"
default horizontal

Layout direction for the navigation.

emphasis
"primary""secondary""tertiary"
default "tertiary"

Resting emphasis of each item, on Panel's scale — every item IS a Panel, so this is Panel's `emphasis` verbatim. `"tertiary"` (the default) rests each row on the colorless neutral ramp; `"secondary"` tints it with the `feedback` palette; `"primary"` makes every row a filled accent surface.

feedback
"primary""neutral""success""warning""danger""inherit"
default "neutral"

Color palette the items resolve their surfaces and text against. Stays `"neutral"` by default so labels read as text rather than as links — every other palette tints the resting rows' text too (Panel's `tertiary` colors it `colorPalette.text.accent`), which is rarely what a navigation wants. Set `"primary"` for a brand-forward navigation: the current row then picks up brand color, and `activeEmphasis` `"secondary"` / `"tertiary"` become visibly different levels (on the neutral palette they resolve to the same ramp).

focusedItem
nullstring

ID of the item that owns the roving tabindex under `semantics: "menu"` — the single Tab stop the ARIA menubar pattern requires. Reactive input: the framework keyboard adapters own this state (it follows arrow-key navigation) and pass it in so `tabindex` mirrors stay live. When omitted (or pointing at a disabled/hidden item) the Tab stop falls back to the active item, then to the first enabled visible item — a pure function of props, so server and first client render always agree. Ignored under `semantics: "list"`, where native tab order is the model.

items
TItem[]

Array of navigation items to display. Ignored when `sections` is provided.

openSubmenus
string[]

IDs of items whose submenu is currently visible — inline-expanded parents in an expanded vertical navigation, open flyouts otherwise. Reactive input: frameworks own this state and pass it in so `aria-expanded` / `data-submenu-open` mirrors stay live. Seed it with `getDefaultOpenSubmenus`.

passThrough
NavigationPassThrough

Per-slot style and attribute override bag for the navigation's rendered elements.

rootBackground
"surface.peak""surface.hill""surface.base""surface.valley""surface.ground""transparent"
default "transparent"

Surface token for the `<nav>` element itself. The root is a Panel too, so a sidebar or topbar shell can be built from the component alone instead of a wrapper. Defaults to `"transparent"` — the nav has no chrome of its own unless you ask for it.

rootBorder
"none""bold""default""muted""accent"
default "none"

Border color of the `<nav>` element, by token level.

rootPadding
"sm""md""lg""xl""2xl""none""xs""3xl""4xl""5xl""6xl""7xl""8xl""4xs""3xs""2xs""9xl""10xl""11xl"
default "none"

Inner padding of the `<nav>` element.

rootRadius
"sm""md""lg""xl""2xl""none""xs""3xl""4xl""5xl""6xl""full""2xs""inner""inherit"
default "none"

Corner radius of the `<nav>` element.

rootShadow
"sm""md""lg""xl""none""xs"
default "none"

Drop shadow of the `<nav>` element.

rootTranslucent
boolean
default false

Frost the `<nav>`'s backdrop (for a nav floating over scrolling content).

sections
NavigationSection<TItem>[]

Grouped navigation items. Each entry renders a labelled group with an optional `title`. When provided, `items` is ignored and `api.items` is the flattened list of every section's items (back-compat).

semantics
"menu""list"
default "menu"

Which semantics the navigation exposes. - `"menu"` (the default) — an ARIA menu: `menubar` on the root, `menu` on sublists, `menuitem` on rows. Right for an application menu. - `"list"` — native navigation semantics: `<nav><ul><li><a>`, no menu roles. A submenu parent stays a disclosure button (`aria-expanded`), which is what it already is behaviourally, and `linkableParent` becomes available. `"list"` is the more accurate model for site navigation — a docs sidebar or table of contents is not an application menu, and `aria-current="page"` sits awkwardly on a `menuitem`. The default stays `"menu"` for back-compatibility.

size
"sm""md""lg""xs"
default md

Size variant for the navigation; trailing badges scale with this value.

submenuToggleLabel
(label: string) => string

Accessible name for a split row's disclosure button, derived from the item's label. A chevron alone names nothing, so the default is `` `Toggle ${label}` ``; pass a function to translate it. Per item, `item.submenuToggleLabel` wins over this.

submenuTrigger
"click""hover"
default "click"

How flyout submenus open in horizontal/compact mode. `"hover"` uses the Popover's hover-focus strategy with short open/close delays.

trailingAttachment
falseAttachmentConfig

Placement config for the compact-mode trailing `<Attachment>` (the badge floating over a rail item). Pass `false` to disable it, so trailing content is simply not shown while compact. Overridable per item via `item.trailingAttachment`. Defaults to a corner badge on `strategy: "fixed"` so it is not clipped by a scrolling rail — pass `{ strategy: "absolute" }` to keep it inside the rail's layout instead.

Plus all standard <a> HTML attributes.

Type

  • Components
  • Blocks