---
title: Nav
description: A nav tree with roving focus, typeahead, collapsible groups that animate, and a guide ladder down the left edge.
source: nav
---

```tsx title="primitives/nav/demos/basic.tsx"
"use client";

import { Nav } from "@intentface/chat/nav";
import { IconBox, IconChevronDown, IconHome, IconInbox, IconMessage } from "@tabler/icons-react";
import { type ComponentProps, useState } from "react";

/*
 * Modelled on the hard case: a section group with no rail, a group two levels
 * in that has branches, plain indented lists nested inside it, an outer rail
 * carrying on past an expanded inner group, and a collapsed group as the last
 * child.
 *
 * All of it is one `Nav.Group` nesting inside itself. There is no second set of
 * parts for the nesting and no depth-aware CSS — the rail recipe below is a
 * single rule that works at any depth. The Root's `guide` is the default every
 * list inherits — "indent", a lane with nothing drawn in it — and lists opt
 * out with "none" or up to "branches" where the tree forks.
 *
 * Leaves are real links. `render` in its function form hands over everything
 * the part would have put on its own div — attributes, handlers, ref, children
 * — and you decide the element. (That form is also why this is a client
 * component: a function cannot cross the server boundary. The element form,
 * `render={<a href="…" />}`, can.)
 *
 * Written out in full rather than folded into a local Row component, so the
 * anatomy here is the anatomy of the primitive.
 */
export const Basic = () => {
  const [current, setCurrent] = useState("chat-views");

  return (
    <div className="nav-demo w-64 rounded-xl border border-[#f0f0f0] bg-white py-2 [--rail:#e4e4e4] dark:border-[#262626] dark:bg-[#111111] dark:[--rail:#2d2d2d]">
      <RailRecipe />
      <Nav.Root
        aria-label="Main"
        guide="indent"
        defaultExpanded={["teams", "intentface", "chat"]}
        render={<nav />}
        className="flex flex-col gap-0.5 px-2"
      >
        <Nav.List guide="none" className={listClass}>
          <Nav.Item
            value="overview"
            active={current === "overview"}
            className={rowClass}
            render={link("/overview", () => setCurrent("overview"))}
          >
            <Nav.Icon>
              <IconHome className="size-4" />
            </Nav.Icon>
            <Nav.Label className="min-w-0 truncate">Overview</Nav.Label>
          </Nav.Item>

          <Nav.Item
            value="inbox"
            active={current === "inbox"}
            className={rowClass}
            render={link("/inbox", () => setCurrent("inbox"))}
          >
            <Nav.Icon>
              <IconInbox className="size-4" />
            </Nav.Icon>
            <Nav.Label className="min-w-0 truncate">Inbox</Nav.Label>
          </Nav.Item>

          {/* A section heading whose children are a plain indented list. `guide`
            is a prop rather than something derived from depth precisely so a
            railless group stays possible. */}
          <Nav.Group value="teams" className="mt-3">
            <Nav.Trigger className={rowClass}>
              <Nav.Label className="min-w-0 truncate">Your teams</Nav.Label>
              <Chevron />
            </Nav.Trigger>

            <Nav.List guide="none" className={listClass}>
              <Nav.Group value="intentface">
                <Nav.Trigger className={rowClass}>
                  <Nav.Icon>
                    <IconBox className="size-4" />
                  </Nav.Icon>
                  <Nav.Label className="min-w-0 truncate">Intentface</Nav.Label>
                  <Chevron />
                </Nav.Trigger>

                {/* Elbows, and only on groups — one at every leaf turns the rail
                  into a comb and buries where the tree actually forks. */}
                <Nav.List guide="branches" className={listClass}>
                  <Nav.Item
                    value="team-home"
                    active={current === "team-home"}
                    className={rowClass}
                    render={link("/intentface/home", () => setCurrent("team-home"))}
                  >
                    <Nav.Icon>
                      <IconHome className="size-4" />
                    </Nav.Icon>
                    <Nav.Label className="min-w-0 truncate">Home</Nav.Label>
                  </Nav.Item>

                  <Nav.Item
                    value="team-issues"
                    active={current === "team-issues"}
                    className={rowClass}
                    render={link("/intentface/issues", () => setCurrent("team-issues"))}
                  >
                    <Nav.Icon>
                      <IconInbox className="size-4" />
                    </Nav.Icon>
                    <Nav.Label className="min-w-0 truncate">Issues</Nav.Label>
                  </Nav.Item>

                  {/* The rail above carries on past this whole group to its next
                    sibling — that is what the ::after on a group child is for.
                    The group's own list inherits "indent" and just indents. */}
                  <Nav.Group value="chat">
                    <Nav.Trigger className={rowClass}>
                      <Nav.Icon>
                        <IconMessage className="size-4" />
                      </Nav.Icon>
                      <Nav.Label className="min-w-0 truncate">Chat</Nav.Label>
                      <Chevron />
                    </Nav.Trigger>

                    <Nav.List className={listClass}>
                      <Nav.Item
                        value="chat-home"
                        active={current === "chat-home"}
                        className={rowClass}
                        render={link("/intentface/chat/home", () => setCurrent("chat-home"))}
                      >
                        <Nav.Label className="min-w-0 truncate">Home</Nav.Label>
                      </Nav.Item>
                      <Nav.Item
                        value="chat-views"
                        active={current === "chat-views"}
                        className={rowClass}
                        render={link("/intentface/chat/views", () => setCurrent("chat-views"))}
                      >
                        <Nav.Label className="min-w-0 truncate">Views</Nav.Label>
                      </Nav.Item>
                    </Nav.List>
                  </Nav.Group>

                  {/* Collapsed, and the last child — so the rail stops at its
                    elbow rather than running on into empty space. */}
                  <Nav.Group value="website">
                    <Nav.Trigger className={rowClass}>
                      <Nav.Icon>
                        <IconBox className="size-4" />
                      </Nav.Icon>
                      <Nav.Label className="min-w-0 truncate">Website</Nav.Label>
                      <Chevron />
                    </Nav.Trigger>

                    <Nav.List className={listClass}>
                      <Nav.Item
                        value="site-home"
                        active={current === "site-home"}
                        className={rowClass}
                        render={link("/website/home", () => setCurrent("site-home"))}
                      >
                        <Nav.Label className="min-w-0 truncate">Home</Nav.Label>
                      </Nav.Item>
                    </Nav.List>
                  </Nav.Group>
                </Nav.List>
              </Nav.Group>
            </Nav.List>
          </Nav.Group>
        </Nav.List>
      </Nav.Root>
    </div>
  );
};

/**
 * Every leaf is a real anchor — that is the point of the function form. A real
 * app hands it a route and lets the browser navigate; this one is a demo on a
 * docs page, so it keeps the href for the semantics and stops the jump.
 */
const link = (href: string, onSelect: () => void) => (props: ComponentProps<"a">) => (
  <a
    {...props}
    href={href}
    onClick={(event) => {
      event.preventDefault();
      onSelect();
      props.onClick?.(event);
    }}
  />
);

const listClass = "flex flex-col gap-0.5";

/*
 * One row style for leaves and group headings alike — in a sidebar they are the
 * same thing you click, and the only visible difference is the chevron.
 *
 * The explicit height is load-bearing: 14px text has a fractional line-height,
 * so padded rows land on a fraction of a pixel and nothing lines up. And no
 * `truncate` here — `overflow: hidden` on a row would clip the ::before and
 * ::after that draw the rail, which sit outside its box. The label truncates
 * instead, which is what a separate part is for.
 */
const rowClass = [
  "group/row flex h-8 shrink-0 cursor-pointer select-none items-center gap-2 rounded-md px-2 text-sm",
  // No `outline-none` here: it sets --tw-outline-style: none, and the
  // focus-visible ring below resolves its style from that very variable — so
  // the ring would be 2px of nothing.
  "text-[#686868] no-underline transition-colors dark:text-[#9b9b9b]",
  "hover:bg-[#f4f4f4] hover:text-[#1a1a1a] dark:hover:bg-[#232323] dark:hover:text-[#fcfcfc]",
  "focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#1a1a1a] dark:focus-visible:outline-[#fcfcfc]",
  "data-[active]:bg-[#ececec] data-[active]:text-[#1a1a1a] dark:data-[active]:bg-[#2d2d2d] dark:data-[active]:text-[#fcfcfc]",
  "data-[disabled]:pointer-events-none data-[disabled]:opacity-40",
  "[&_svg]:size-4 [&_svg]:shrink-0",
].join(" ");

/**
 * The disclosure arrow. Not a part of the primitive — it is this sidebar's
 * convention, not the widget's. It rotates with the group it belongs to by
 * reading `data-closed` off the enclosing trigger, so nothing is threaded down.
 */
const Chevron = () => (
  <IconChevronDown className="ml-auto !size-3 text-[#949494] transition-transform group-data-[closed]/row:-rotate-90 dark:text-[#6f6f6f]" />
);

/*
 * The rail, its branches and the collapse. The package publishes the signal
 * (`data-rail`, `data-branches`, `data-open`); the geometry is yours. Copy it
 * and change the numbers:
 *
 *   15px  hangs the rail under the centre of a size-4 icon at px-2, so it drops
 *         out of the parent's icon rather than beside it.
 *   7px   the rail's lane. It is the list's padding rather than the row's,
 *         because an active row paints a background and would cover a line
 *         drawn inside its own box.
 *   6px   the elbow's corner radius. The curve pulls the vertical away 6px
 *         early, so the continuation starts 6px above the row's centre.
 *   2px   the gap between rows, bridged so the line reads as unbroken.
 */
const RailRecipe = () => (
  <style>{`
.nav-demo [data-nav-group] > [data-nav-list] {
  height: var(--nav-list-height);
  overflow: hidden;
  opacity: 1;
  transition: height 200ms cubic-bezier(0.4, 0, 0.2, 1), opacity 200ms ease-out;
}
.nav-demo [data-nav-list][data-starting-style],
.nav-demo [data-nav-list][data-ending-style] { height: 0; opacity: 0; }
.nav-demo [data-nav-list] > * { flex-shrink: 0; }
.nav-demo [data-nav-list][data-indent] {
  --nav-row: 2rem;
  margin-top: 2px;
  margin-left: 15px;
  padding-left: 7px;
}
.nav-demo [data-nav-list][data-rail] > * { position: relative; overflow: visible; }
.nav-demo [data-nav-list][data-rail] > *::before,
.nav-demo [data-nav-list][data-rail] > *::after {
  content: "";
  position: absolute;
  left: -7px;
  border-color: var(--rail);
  border-left-width: 1px;
}
.nav-demo [data-nav-list][data-rail] > *::before {
  top: -2px;
  width: 6px;
  height: calc(var(--nav-row) / 2 + 2px);
}
.nav-demo [data-nav-list][data-rail] > *::after {
  top: calc(var(--nav-row) / 2 - 6px);
  bottom: -2px;
}
.nav-demo [data-nav-list][data-rail] > *:last-child::after { display: none; }
.nav-demo [data-nav-list][data-branches] > [data-nav-group]::before {
  border-bottom-width: 1px;
  border-bottom-left-radius: 6px;
}
@media (prefers-reduced-motion: reduce) {
  .nav-demo [data-nav-group] > [data-nav-list] { transition: none; }
}
`}</style>
);
```

## Usage guidelines

- **Recursive by construction** — a group's list may hold further groups, to any depth, with no second set of parts for the nesting.
- **One tab stop** — the whole tree is a single tab stop; arrow keys move focus between rows, and typing seeks a row by its label.
- **Rows hold actions** — `Nav.Item` is a `div` with `role="button"` rather than an anchor, so a menu affordance can sit inside it. Swap in a real link with `render`.
- **Depth is a variable** — each list publishes its own `--nav-depth`, so one indent rule covers every level.
- **The guide is cumulative** — `rail` implies `indent`, and `branches` implies both. See [Why the guide is a ladder](#why-the-guide-is-a-ladder).
- **Persists nothing itself** — the open set goes out through `onExpandedChange` and comes back as `defaultExpanded`, so where it is kept is yours.
- **Get started** — see [Quick start](/quick-start) to add the package.

## Anatomy

```tsx
<Nav.Root>
  <Nav.List>
    <Nav.Item>
      <Nav.Icon />
      <Nav.Label />
      <Nav.Action />
    </Nav.Item>
    <Nav.Group>
      <Nav.Trigger>
        <Nav.Label />
      </Nav.Trigger>
      <Nav.List />
    </Nav.Group>
  </Nav.List>
</Nav.Root>
```

A sidebar tree with a rail, one branch open on load, and rows that route:

```tsx
<Nav.Root guide="rail" defaultExpanded={stored ?? ["docs"]} onExpandedChange={save}>
  <Nav.List>
    <Nav.Item value="overview" active={pathname === "/"} render={<Link href="/" />}>
      <Nav.Label>Overview</Nav.Label>
    </Nav.Item>
    <Nav.Group value="docs">
      <Nav.Trigger>
        <Nav.Label>Documentation</Nav.Label>
      </Nav.Trigger>
      <Nav.List>
        <Nav.Item value="quick-start" render={<Link href="/quick-start" />}>
          <Nav.Label>Quick start</Nav.Label>
        </Nav.Item>
      </Nav.List>
    </Nav.Group>
  </Nav.List>
</Nav.Root>
```

## Examples

### Choosing a guide

`guide` sets what every list draws down its left edge. Set the default on
`Nav.Root` and override it per list.

export const guides = [
  { value: '"none"', default: true, description: "No indent lane and no attributes." },
  { value: '"indent"', description: "Emits data-indent — the lane exists, nothing is drawn in it." },
  { value: '"rail"', description: "Emits data-indent and data-rail — a line down the lane." },
  { value: '"branches"', description: "Emits data-indent, data-rail and data-branches — elbows off the rail." },
];

<ValuesTable rows={guides} />

```tsx title="primitives/nav/demos/guides.tsx"
"use client";

import { Nav } from "@intentface/chat/nav";
import { IconBox, IconChevronDown, IconHome } from "@tabler/icons-react";

/*
 * The same tree four times, once per `guide` value, so the rungs can be
 * compared side by side. Nothing else differs between the four.
 *
 * The attributes are cumulative — "rail" emits `data-indent` as well, and
 * "branches" emits all three — which is why one stylesheet covers every value
 * without wrapping a selector in `:is()`. The geometry below is the same
 * recipe the Nav page documents; only the `guide` prop changes.
 */
export const Guides = () => (
  <div className="guides-demo grid w-full grid-cols-2 gap-3 lg:grid-cols-4">
    <GuideTree guide="none" caption="No lane, no attributes." />
    <GuideTree guide="indent" caption="The lane exists, nothing drawn in it." />
    <GuideTree guide="rail" caption="A line down the lane." />
    <GuideTree guide="branches" caption="Elbows off the rail, on groups only." />

    <RailRecipe />
  </div>
);

type GuideValue = "none" | "indent" | "rail" | "branches";

const GuideTree = ({ guide, caption }: { guide: GuideValue; caption: string }) => (
  <div className="flex min-w-0 flex-col gap-2">
    <div className="flex items-baseline gap-2">
      <code className="font-mono text-[#1a1a1a] text-xs dark:text-[#fcfcfc]">{guide}</code>
    </div>

    <div className="rounded-xl border border-[#f0f0f0] bg-white py-2 [--rail:#e4e4e4] dark:border-[#262626] dark:bg-[#111111] dark:[--rail:#2d2d2d]">
      <Nav.Root
        aria-label={`Guide: ${guide}`}
        guide={guide}
        defaultExpanded={["chat"]}
        render={<nav />}
        className="flex flex-col gap-0.5 px-2"
      >
        {/* The top list opts out: a rail beside the top level would have
            nothing to descend from. Every nested list inherits the Root's. */}
        <Nav.List guide="none" className={listClass}>
          <Nav.Item value="overview" active className={rowClass}>
            <Nav.Icon>
              <IconHome className="size-4" />
            </Nav.Icon>
            <Nav.Label className="min-w-0 truncate">Overview</Nav.Label>
          </Nav.Item>

          <Nav.Group value="chat">
            <Nav.Trigger className={rowClass}>
              <Nav.Icon>
                <IconBox className="size-4" />
              </Nav.Icon>
              <Nav.Label className="min-w-0 truncate">Chat</Nav.Label>
              <Chevron />
            </Nav.Trigger>

            <Nav.List className={listClass}>
              <Nav.Item value="home" className={rowClass}>
                <Nav.Label className="min-w-0 truncate">Home</Nav.Label>
              </Nav.Item>

              {/* A group as the last child: with "branches" its elbow is drawn
                  and the rail stops there rather than running into space. */}
              <Nav.Group value="views">
                <Nav.Trigger className={rowClass}>
                  <Nav.Label className="min-w-0 truncate">Views</Nav.Label>
                  <Chevron />
                </Nav.Trigger>

                <Nav.List className={listClass}>
                  <Nav.Item value="active" className={rowClass}>
                    <Nav.Label className="min-w-0 truncate">Active</Nav.Label>
                  </Nav.Item>
                </Nav.List>
              </Nav.Group>
            </Nav.List>
          </Nav.Group>
        </Nav.List>
      </Nav.Root>
    </div>

    <p className="text-[#686868] text-xs leading-5 dark:text-[#9b9b9b]">{caption}</p>
  </div>
);

const listClass = "flex flex-col gap-0.5";

// The explicit height is load-bearing: 14px text has a fractional line-height,
// so padded rows land on a fraction of a pixel and the rail stops lining up.
const rowClass = [
  "group/row flex h-8 shrink-0 cursor-pointer select-none items-center gap-2 rounded-md px-2 text-sm",
  "text-[#686868] no-underline transition-colors dark:text-[#9b9b9b]",
  "hover:bg-[#f4f4f4] hover:text-[#1a1a1a] dark:hover:bg-[#232323] dark:hover:text-[#fcfcfc]",
  "focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#1a1a1a] dark:focus-visible:outline-[#fcfcfc]",
  "data-[active]:bg-[#ececec] data-[active]:text-[#1a1a1a] dark:data-[active]:bg-[#2d2d2d] dark:data-[active]:text-[#fcfcfc]",
  "[&_svg]:size-4 [&_svg]:shrink-0",
].join(" ");

const Chevron = () => (
  <IconChevronDown className="ml-auto size-3! text-[#949494] transition-transform group-data-closed/row:-rotate-90 dark:text-[#6f6f6f]" />
);

/*
 * One stylesheet for all four trees. Because the attributes are cumulative,
 * the `[data-indent]` rule sizes the lane for indent, rail and branches alike,
 * and `[data-rail]` draws for rail and branches alike.
 */
const RailRecipe = () => (
  <style>{`
.guides-demo [data-nav-group] > [data-nav-list] {
  height: var(--nav-list-height);
  overflow: hidden;
  opacity: 1;
  transition: height 200ms cubic-bezier(0.4, 0, 0.2, 1), opacity 200ms ease-out;
}
.guides-demo [data-nav-list][data-starting-style],
.guides-demo [data-nav-list][data-ending-style] { height: 0; opacity: 0; }
.guides-demo [data-nav-list] > * { flex-shrink: 0; }
.guides-demo [data-nav-list][data-indent] {
  --nav-row: 2rem;
  margin-top: 2px;
  margin-left: 15px;
  padding-left: 7px;
}
.guides-demo [data-nav-list][data-rail] > * { position: relative; overflow: visible; }
.guides-demo [data-nav-list][data-rail] > *::before,
.guides-demo [data-nav-list][data-rail] > *::after {
  content: "";
  position: absolute;
  left: -7px;
  border-color: var(--rail);
  border-left-width: 1px;
}
.guides-demo [data-nav-list][data-rail] > *::before {
  top: -2px;
  width: 6px;
  height: calc(var(--nav-row) / 2 + 2px);
}
.guides-demo [data-nav-list][data-rail] > *::after {
  top: calc(var(--nav-row) / 2 - 6px);
  bottom: -2px;
}
.guides-demo [data-nav-list][data-rail] > *:last-child::after { display: none; }
.guides-demo [data-nav-list][data-branches] > [data-nav-group]::before {
  border-bottom-width: 1px;
  border-bottom-left-radius: 6px;
}
`}</style>
);
```

The attributes are cumulative, so a stylesheet asking for `[data-rail]` gets
branches too. See [Why the guide is a ladder](#why-the-guide-is-a-ladder).

The package publishes the signal; the geometry is yours. This is what draws the
`rail` and `branches` trees above — copy it and change the numbers.

```css
/* The lane. `indent` is the first rung and every rung above it emits this too,
   so one rule sizes the lane for all of them. */
[data-nav-list][data-indent] {
  /* Where the elbow aims. Not 50% of the child: a child may itself be a group
     several rows tall. */
  --nav-row: 2rem;

  margin-top: 2px;
  margin-left: 15px;
  padding-left: 7px;
}

/* Both halves are laid out unconditionally and only the borders switch on: a
   box with no edges drawn takes no space and paints nothing. */
[data-nav-list][data-rail] > *::before,
[data-nav-list][data-rail] > *::after {
  content: "";
  position: absolute;
  left: -7px;
  border-color: var(--rail-color);
  border-left-width: 1px;
}

[data-nav-list][data-rail] > * {
  position: relative;
  /* Never `hidden`: these pseudo-elements sit outside the row's own box, so
     clipping them erases the rail. Truncate the label instead. */
  overflow: visible;
}

/* The top half: down to the row's centre, 6px wide so an elbow fits. */
[data-nav-list][data-rail] > *::before {
  top: -2px;
  width: 6px;
  height: calc(var(--nav-row) / 2 + 2px);
}

/* …and the rail carries on to the next row. Never past the last one: a line
   running into empty space reads as a list that got cut off. */
[data-nav-list][data-rail] > *::after {
  top: calc(var(--nav-row) / 2 - 6px);
  bottom: -2px;
}

[data-nav-list][data-rail] > *:last-child::after {
  display: none;
}

/* The elbow — only on groups. One at every leaf turns the rail into a comb and
   buries the thing worth spotting, which is where the tree forks. Group and
   Item carry different identity attributes precisely so CSS can tell them
   apart without the package taking a view. */
[data-nav-list][data-branches] > [data-nav-group]::before {
  border-bottom-width: 1px;
  border-bottom-left-radius: 6px;
}
```

The four numbers are all load-bearing. **15px** hangs the rail under the centre
of a `size-4` icon at `px-2`, so it drops out of the parent's icon rather than
beside it. **7px** is the lane, and it is the list's padding rather than the
row's, because an active row paints a background and would cover a line drawn
inside its own box. **6px** is the elbow's radius, and the curve pulls the
vertical away that early, so the continuation has to start 6px above the row's
centre or every branch leaves a radius of rail missing. **2px** is the row gap,
bridged so the line reads as unbroken.

Give rows an explicit height. Text at a fractional line-height makes a padded
row land on a fraction of a pixel, and then nothing in the tree lines up.
`--nav-row` has to match whatever height you set.

### Animating a group open

`height: auto` is not interpolable, so a collapse can only animate between
lengths. `--nav-list-height` holds a measured pixel value while a transition
runs and is released the moment it finishes, so an open, settled list is `auto`
and grows freely. The demo below is slowed to 500ms; open the outer group, then
the inner one, and watch the outer keep growing.

```tsx title="primitives/nav/demos/collapse.tsx"
"use client";

import { Nav } from "@intentface/chat/nav";
import { IconChevronDown } from "@tabler/icons-react";

/*
 * The collapse, slowed to 500ms so the mechanism is visible.
 *
 * `height: auto` is not interpolable, so the transition needs two lengths. The
 * primitive publishes `--nav-list-height` — the measured content height —
 * while a transition runs and releases it the moment the opening one finishes.
 * With the variable gone, `height: var(--nav-list-height)` is invalid at
 * computed-value time and `height` lands back on `auto`.
 *
 * That release is the point. A list pinned to a pixel height permanently could
 * not hold a group that expands inside it — open the outer group, then the
 * inner one, and watch the outer keep growing.
 */
export const Collapse = () => (
  <div className="collapse-demo w-72 rounded-xl border border-[#f0f0f0] bg-white py-2 dark:border-[#262626] dark:bg-[#111111]">
    <CollapseRecipe />

    <Nav.Root
      aria-label="Collapse"
      guide="indent"
      render={<nav />}
      className="flex flex-col gap-0.5 px-2"
    >
      <Nav.List guide="none" className={listClass}>
        <Nav.Group value="workspace">
          <Nav.Trigger className={rowClass}>
            <Nav.Label className="min-w-0 truncate">Workspace</Nav.Label>
            <Chevron />
          </Nav.Trigger>

          <Nav.List className={listClass}>
            <Nav.Item value="overview" className={rowClass}>
              <Nav.Label className="min-w-0 truncate">Overview</Nav.Label>
            </Nav.Item>

            {/* Opening this one grows the list above it, because that list is
                back on `auto` once its own transition settled. */}
            <Nav.Group value="projects">
              <Nav.Trigger className={rowClass}>
                <Nav.Label className="min-w-0 truncate">Projects</Nav.Label>
                <Chevron />
              </Nav.Trigger>

              <Nav.List className={listClass}>
                {["Chat", "Website", "Docs"].map((label) => (
                  <Nav.Item key={label} value={label.toLowerCase()} className={rowClass}>
                    <Nav.Label className="min-w-0 truncate">{label}</Nav.Label>
                  </Nav.Item>
                ))}
              </Nav.List>
            </Nav.Group>

            <Nav.Item value="settings" className={rowClass}>
              <Nav.Label className="min-w-0 truncate">Settings</Nav.Label>
            </Nav.Item>
          </Nav.List>
        </Nav.Group>
      </Nav.List>
    </Nav.Root>
  </div>
);

const listClass = "flex flex-col gap-0.5";

const rowClass = [
  "group/row flex h-8 shrink-0 cursor-pointer select-none items-center gap-2 rounded-md px-2 text-sm",
  "text-[#686868] transition-colors dark:text-[#9b9b9b]",
  "hover:bg-[#f4f4f4] hover:text-[#1a1a1a] dark:hover:bg-[#232323] dark:hover:text-[#fcfcfc]",
  "focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#1a1a1a] dark:focus-visible:outline-[#fcfcfc]",
].join(" ");

const Chevron = () => (
  <IconChevronDown className="ml-auto size-3 text-[#949494] transition-transform group-data-closed/row:-rotate-90 dark:text-[#6f6f6f]" />
);

/*
 * Deliberately slow. `flex-shrink: 0` on the rows is load-bearing: the
 * collapsing list squeezes to nothing, and a flex item shrinks below its own
 * height when the column runs short — so without it the rows compress instead
 * of sliding up behind the clip.
 */
const CollapseRecipe = () => (
  <style>{`
.collapse-demo [data-nav-group] > [data-nav-list] {
  height: var(--nav-list-height);
  overflow: hidden;
  opacity: 1;
  transition: height 500ms cubic-bezier(0.4, 0, 0.2, 1), opacity 500ms ease-out;
}
.collapse-demo [data-nav-list][data-starting-style],
.collapse-demo [data-nav-list][data-ending-style] { height: 0; opacity: 0; }
.collapse-demo [data-nav-list] > * { flex-shrink: 0; }
.collapse-demo [data-nav-list][data-indent] { margin-left: 15px; padding-left: 7px; }
`}</style>
);
```

```css
[data-nav-group] > [data-nav-list] {
  height: var(--nav-list-height);
  overflow: hidden;
  opacity: 1;
  /* Not ease-out: the last row is the bottom sliver of the height, and
     ease-out spends its whole tail crawling through exactly that stretch —
     which reads as the row popping in at the end. */
  transition:
    height 200ms cubic-bezier(0.4, 0, 0.2, 1),
    opacity 200ms ease-out;
}

[data-nav-list][data-starting-style],
[data-nav-list][data-ending-style] {
  height: 0;
  opacity: 0;
}

/* The collapsing list squeezes to nothing, and a flex item shrinks below its
   own height when the column runs short — so without this the rows compress
   instead of sliding up behind the clip. */
[data-nav-list] > * {
  flex-shrink: 0;
}
```

With the variable released, `height: var(--nav-list-height)` is invalid at
computed-value time and `height` lands back on `auto`, which is what a settled
list wants without inheriting anything from an ancestor.

### Persisting the open set

`onExpandedChange` reports the open set out and `defaultExpanded` takes it back
in, so where it is kept is yours.

```tsx
// A server component reads it before the first paint …
const stored = readNavState((await cookies()).toString());

// … and the tree reports every change back.
<Nav.Root
  defaultExpanded={stored ?? ["docs"]}
  onExpandedChange={(expanded) => writeNavState(expanded)}
>
```

Validate what comes back out of storage — `Array.isArray(x) && x.every((v) =>
typeof v === "string")` is the whole check for Nav — so a stale or hand-edited
value falls back to the default rather than reaching the tree. See
[Shell](/primitives/shell#persisting-across-sessions) for why the value has to
arrive as a prop rather than be read at init.

### Driving the tree from outside

`useNavStore(store, selector)` is the outside-the-tree twin of `useNav`, taking
an explicit `Nav.createStore()` handle. There is no global fallback, which is
what stops a tree being driven by accident from somewhere that merely imported
it.

```tsx title="primitives/nav/demos/external.tsx"
"use client";

import { Nav, type NavStore, useNavStore } from "@intentface/chat/nav";
import { IconChevronDown } from "@tabler/icons-react";
import { useState } from "react";

const GROUPS = ["workspace", "projects", "archive"];

/*
 * Driving the tree from outside it.
 *
 * `Nav.createStore()` is the handle. Pass it to the Root and the primitive
 * uses it instead of creating its own, so the buttons below — siblings of the
 * Root, not descendants — can read the open set and replace it wholesale.
 *
 * The store is created inside `useState` so it survives re-renders. There is
 * no global fallback, which is what stops a tree being driven by accident from
 * somewhere that merely imported this.
 */
export const ExternalNav = () => {
  const [store] = useState(() => Nav.createStore());

  return (
    <div className="external-nav-demo flex w-full flex-col items-center gap-3">
      <Nav.Root
        store={store}
        aria-label="Workspace"
        guide="indent"
        render={<nav />}
        className="flex w-72 flex-col gap-0.5 rounded-xl border border-[#f0f0f0] bg-white px-2 py-2 dark:border-[#262626] dark:bg-[#111111]"
      >
        <Nav.List guide="none" className={listClass}>
          {GROUPS.map((value) => (
            <Nav.Group key={value} value={value}>
              <Nav.Trigger className={rowClass}>
                <Nav.Label className="min-w-0 truncate capitalize">{value}</Nav.Label>
                <Chevron />
              </Nav.Trigger>

              <Nav.List className={listClass}>
                {["Overview", "Activity"].map((label) => (
                  <Nav.Item key={label} value={`${value}-${label}`} className={rowClass}>
                    <Nav.Label className="min-w-0 truncate">{label}</Nav.Label>
                  </Nav.Item>
                ))}
              </Nav.List>
            </Nav.Group>
          ))}
        </Nav.List>
      </Nav.Root>

      <Controls store={store} />
      <CollapseRecipe />
    </div>
  );
};

/**
 * Outside the Root, reaching the same state through the handle. It both reads
 * the open set — which is what disables each button once it would do nothing —
 * and replaces it, so the handle is doing the same two jobs context would.
 */
const Controls = ({ store }: { store: NavStore }) => {
  const expanded = useNavStore(store, (nav) => nav.expanded);

  return (
    <div className="flex flex-wrap items-center justify-center gap-2">
      <button
        type="button"
        disabled={expanded.size === GROUPS.length}
        onClick={() => store.getSnapshot().setExpanded(GROUPS)}
        className={buttonClass}
      >
        Expand all
      </button>
      <button
        type="button"
        disabled={expanded.size === 0}
        onClick={() => store.getSnapshot().setExpanded([])}
        className={buttonClass}
      >
        Collapse all
      </button>
    </div>
  );
};

const buttonClass =
  "h-8 cursor-pointer rounded-full border border-[#e4e4e4] bg-white px-4 font-medium text-[#1a1a1a] text-sm transition-colors hover:bg-[#f4f4f4] disabled:cursor-default disabled:opacity-40 disabled:hover:bg-white focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#1a1a1a] dark:border-[#2d2d2d] dark:bg-[#181818] dark:text-[#fcfcfc] dark:hover:bg-[#232323] dark:focus-visible:outline-[#fcfcfc]";

const listClass = "flex flex-col gap-0.5";

const rowClass = [
  "group/row flex h-8 shrink-0 cursor-pointer select-none items-center gap-2 rounded-md px-2 text-sm",
  "text-[#686868] transition-colors dark:text-[#9b9b9b]",
  "hover:bg-[#f4f4f4] hover:text-[#1a1a1a] dark:hover:bg-[#232323] dark:hover:text-[#fcfcfc]",
  "focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#1a1a1a] dark:focus-visible:outline-[#fcfcfc]",
].join(" ");

const Chevron = () => (
  <IconChevronDown className="ml-auto size-3 text-[#949494] transition-transform group-data-closed/row:-rotate-90 dark:text-[#6f6f6f]" />
);

const CollapseRecipe = () => (
  <style>{`
.external-nav-demo [data-nav-group] > [data-nav-list] {
  height: var(--nav-list-height);
  overflow: hidden;
  opacity: 1;
  transition: height 200ms cubic-bezier(0.4, 0, 0.2, 1), opacity 200ms ease-out;
}
.external-nav-demo [data-nav-list][data-starting-style],
.external-nav-demo [data-nav-list][data-ending-style] { height: 0; opacity: 0; }
.external-nav-demo [data-nav-list] > * { flex-shrink: 0; }
.external-nav-demo [data-nav-list][data-indent] { margin-left: 15px; padding-left: 7px; }
`}</style>
);
```

## Why the guide is a ladder

What a list draws down its left edge is a ladder rather than three independent
flags, because each rung implies the one before it. A rail lives in the indent
lane, and an elbow needs a rail to turn off.

Expressing them as booleans would mean guarding against combinations that mean
nothing — branches without a rail, a rail with no lane to sit in. Making the
attributes cumulative instead keeps the ladder readable in CSS without wrapping
every selector in `:is()`.

## Keyboard

These are the widget's own keys, not global ones: the handler sits on
`Nav.Root`, so nothing fires unless focus is already inside the tree.

export const keys = [
  { keys: "Arrow up / down", description: "Move focus to the previous or next visible row. Collapsed lists are unmounted, so their rows are not there to land on." },
  { keys: "Arrow right", description: "On a closed group, open it; on an open one, step into it. On a leaf, nothing." },
  { keys: "Arrow left", description: "On an open group, close it; otherwise step out to the parent group's trigger — which is where the row came from." },
  { keys: "Enter / Space", description: "Activate the focused row." },
  { keys: "Any letter", description: "Seek to the row whose label starts with what you typed. The query resets after half a second of no typing; turn the whole thing off with the typeahead prop." },
];

<KeysTable rows={keys} />

A field inside the nav keeps its own arrow keys — an input, a textarea, a
select, or anything `contenteditable` — or the caret could never move.

## API reference

Every part accepts `className`, `style`, and `render` (see
[Styling](/handbook/styling)) and emits a bespoke part attribute
(`data-<part>`) unless noted. Every part also carries `data-depth` and, when
nested inside a group, `data-nested`.

### Nav.Root

The provider and container. Renders `data-nav`, and owns the keyboard handling
for the whole tree.

export const rootProps = [
  { name: "defaultExpanded", type: "string[]", description: "Groups open when nothing controls them. Read it from the request so the first paint already has the right branches open." },
  { name: "expanded", type: "string[]", description: "Controlled open set." },
  { name: "onExpandedChange", type: "(expanded: string[]) => void", description: "Fires whenever a group opens or closes." },
  { name: "store", type: "NavStore", description: "An explicit Nav.createStore() handle. Must be stable for the Root's lifetime." },
  { name: "guide", type: '"none" | "indent" | "rail" | "branches"', default: '"none"', description: "What every list draws down its left edge, unless it says otherwise." },
  { name: "loop", type: "boolean", default: "false", description: "Wrap at the ends when arrowing past the first or last row." },
  { name: "disabled", type: "boolean", default: "false", description: "Disable every row at once — a read-only view of the tree." },
  { name: "typeahead", type: "boolean", default: "true", description: "Seek a row by typing its label." },
];

<PropsTable rows={rootProps} />

export const rootAttrs = [
  { attribute: "data-nav", description: "The container." },
  { attribute: "data-depth", values: "0", description: "The Root is the top level, so its depth is always zero — spelled out rather than omitted, because `0` is falsy and the default derivation would drop it." },
];

<AttributesTable rows={rootAttrs} />

### Nav.List

A level of the tree. Renders `data-nav-list`. A list inside a group is that
group's collapsible panel; a top-level list is not collapsible, because there
is no trigger above it.

export const listProps = [
  { name: "guide", type: '"none" | "indent" | "rail" | "branches"', description: "Overrides the Root's default for this list." },
  { name: "keepMounted", type: "boolean", default: "false", description: "Keep a closed list in the DOM behind `hidden`. Its rows are not navigable while hidden." },
];

<PropsTable rows={listProps} />

export const listAttrs = [
  { attribute: "data-nav-list", description: "The list." },
  { attribute: "data-depth", values: "number", description: "Nesting level; 0 is the top." },
  { attribute: "data-open", description: "Present while open." },
  { attribute: "data-closed", description: "Present while closed." },
  { attribute: "data-indent", description: "Present for every guide but none." },
  { attribute: "data-rail", description: "Present for rail and branches." },
  { attribute: "data-branches", description: "Present for branches only." },
  { attribute: "data-starting-style", description: "Present on the first open frame." },
  { attribute: "data-ending-style", description: "Present while the close animation runs." },
  { attribute: "--nav-depth", values: "number", description: "This list's depth, for one indent rule that covers every level." },
  { attribute: "--nav-list-height", values: "measured px", description: "The content's height, published only while a transition runs so the collapse has a number to animate between. Released once settled open, so the list tracks content that grows." },
  { attribute: "--nav-list-width", values: "measured px", description: "The same, for a horizontal collapse." },
];

<AttributesTable rows={listAttrs} />

### Nav.Group

A collapsible branch. Renders `data-nav-group` set to its `value`, and provides
the group context its trigger and list read.

export const groupProps = [
  { name: "value", type: "string", default: "(required)", description: "Identifies the group in the expanded set, and in persistence." },
  { name: "disabled", type: "boolean", description: "Disable this branch's rows." },
];

<PropsTable rows={groupProps} />

export const groupAttrs = [
  { attribute: "data-nav-group", values: "the group's value", description: "The branch." },
  { attribute: "data-open", description: "Present while open." },
  { attribute: "data-closed", description: "Present while closed." },
];

<AttributesTable rows={groupAttrs} />

### Nav.Trigger

The row that opens a group — its heading and its disclosure in one, because in
a sidebar they are the same thing you click. Renders `data-nav-trigger` set to
the group's value. Takes no `value`: it belongs to the group it is written
inside.

export const triggerProps = [
  { name: "active", type: "boolean", default: "false", description: "This branch is the one being shown." },
  { name: "disabled", type: "boolean", description: "Defaults to the group's own disabled state." },
];

<PropsTable rows={triggerProps} />

export const triggerAttrs = [
  { attribute: "data-nav-trigger", values: "the group's value", description: "The disclosure row, and its identity — the same channel `data-nav-item` uses, which is how one selector walks leaves and headings alike." },
  { attribute: "data-open", description: "Present while the group is open." },
  { attribute: "data-closed", description: "Present while it is closed." },
  { attribute: "data-active", description: "Present while active." },
  { attribute: "data-disabled", description: "Present while disabled." },
];

<AttributesTable rows={triggerAttrs} />

### Nav.Item

A leaf row. Renders `data-nav-item` set to its `value`.

export const itemProps = [
  { name: "value", type: "string", default: "(required)", description: "Identifies the row for roving focus and typeahead." },
  { name: "active", type: "boolean", default: "false", description: "The route this row points at is the one being shown." },
  { name: "disabled", type: "boolean", description: "Defaults to the Root's disabled state." },
];

<PropsTable rows={itemProps} />

export const itemAttrs = [
  { attribute: "data-nav-item", values: "the row's value", description: "The row, and its identity. Root's keyboard handling finds rows by this attribute and reads the value straight back off it — so the DOM is the row order, and no row has to register itself. Select it without the value for styling." },
  { attribute: "data-active", description: "Present while active." },
  { attribute: "data-disabled", description: "Present while disabled." },
];

<AttributesTable rows={itemAttrs} />

### Nav.Label

The row's text. Renders a `<span>` with `data-nav-label` — its own element so
it can truncate while the row does not. A row must never be `overflow: hidden`
itself, because the rail's pseudo-elements sit outside its box.

export const labelAttrs = [
  { attribute: "data-nav-label", description: "The text." },
  { attribute: "data-depth", values: "number", description: "The enclosing list's depth." },
  { attribute: "data-nested", description: "Present inside a group." },
];

<AttributesTable rows={labelAttrs} />

### Nav.Icon

Decoration. Renders a `<span>` with `data-nav-icon` and `aria-hidden`, so it
stays out of the row's accessible name.

export const iconAttrs = [
  { attribute: "data-nav-icon", description: "The icon slot." },
  { attribute: "data-depth", values: "number", description: "The enclosing list's depth." },
  { attribute: "data-nested", description: "Present inside a group." },
];

<AttributesTable rows={iconAttrs} />

### Nav.Action

A control inside a row — the affordance that opens a menu, say. Renders
`data-nav-action` as a `role="button"` with `tabIndex="-1"`: out of the roving
order, so arrowing walks rows rather than stopping at every affordance, and it
stops every event it handles — anything that escaped would activate the row on
its way out of opening the menu.

export const actionAttrs = [
  { attribute: "data-nav-action", description: "The control." },
  { attribute: "data-depth", values: "number", description: "The enclosing list's depth." },
  { attribute: "data-nested", description: "Present inside a group." },
];

<AttributesTable rows={actionAttrs} />

## useNav

Read which groups are open from anywhere inside `<Nav.Root>`:

```tsx
const isOpen = useNav((nav) => nav.expanded.has("docs"));
```

export const hookMembers = [
  { name: "expanded", type: "ReadonlySet<string>", description: "Which groups are open, by value." },
  { name: "toggle", type: "(value: string) => void", description: "Flip one group." },
  { name: "setOpen", type: "(value: string, open: boolean) => void", description: "Open or close one group explicitly." },
  { name: "setExpanded", type: "(expanded: Iterable<string>) => void", description: "Replace the whole open set. Takes an array or a Set, so a value derived from `expanded` goes straight back in without a spread." },
];

<PropsTable rows={hookMembers} />
