Nav

A nav tree with roving focus, typeahead, collapsible groups that animate, and a guide ladder down the left edge.

"use client";

import { Nav } from "@intentface/chat/nav";
import { ChevronDown, Home, Inbox, MessageSquare, Package } from "@keyline-icons/react";
import { type ComponentProps, useState } from "react";
import "./rail.css";

/*
 * 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 in rail.css 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="relative nav-demo w-64 rounded-xl bg-[#f5f5f6] shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] py-2 [--rail:#e4e4e7] dark:bg-[#131315] dark:shadow-[0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:after:pointer-events-none dark:after:absolute dark:after:inset-0 dark:after:z-50 dark:after:rounded-[inherit] dark:after:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06)] dark:[--rail:#2b2b2e]">
      <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>
              <Home 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>
              <Inbox 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>
                    <Package 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>
                      <Home 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>
                      <Inbox 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>
                        <MessageSquare 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>
                        <Package 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: 13px 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-[30px] shrink-0 cursor-pointer select-none items-center gap-2 rounded-md px-2 font-medium text-[13px]",
  // 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-zinc-700 no-underline transition-colors dark:text-zinc-300",
  "hover:bg-zinc-950/5 hover:text-zinc-900 dark:hover:bg-white/8 dark:hover:text-zinc-100",
  // Inset: the collapsing lists clip their overflow, so an outset ring would be cut.
  "focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#0169cc]/60",
  // The selected row is raised off the sidebar. Its ring is inset because the
  // collapsing lists would clip one drawn outside the row.
  "data-[active]:bg-white data-[active]:bg-linear-to-b data-[active]:from-white data-[active]:to-[#fdfdfd] data-[active]:text-zinc-900 data-[active]:shadow-[inset_0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)]",
  "dark:data-[active]:bg-[#2d2d30] dark:data-[active]:from-[#29292c] dark:data-[active]:to-[#242427] dark:data-[active]:text-zinc-100 dark:data-[active]:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]",
  "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 = () => (
  <ChevronDown className="ml-auto !size-3 text-zinc-400 transition-transform group-data-[closed]/row:-rotate-90 dark:text-zinc-500" />
);

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.
  • 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 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.Toggle />
        <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.

ValueDescription
"none"default
No indent lane and no attributes.
"indent"
Emits data-indent — the lane exists, nothing is drawn in it.
"rail"
Emits data-indent and data-rail — a line down the lane.
"branches"
Emits data-indent, data-rail and data-branches — elbows off the rail.
none

No lane, no attributes.

indent

The lane exists, nothing drawn in it.

rail

A line down the lane.

branches

Elbows off the rail, on groups only.

"use client";

import { Nav } from "@intentface/chat/nav";
import { ChevronDown, Home, Package } from "@keyline-icons/react";
import "./guides-rail.css";

/*
 * 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 in guides-rail.css 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." />
  </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-xs text-zinc-900 dark:text-zinc-100">{guide}</code>
    </div>

    <div className="relative rounded-xl bg-[#f5f5f6] shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] py-2 [--rail:#e4e4e7] dark:bg-[#131315] dark:shadow-[0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:after:pointer-events-none dark:after:absolute dark:after:inset-0 dark:after:z-50 dark:after:rounded-[inherit] dark:after:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06)] dark:[--rail:#2b2b2e]">
      <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>
              <Home 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>
                <Package 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-xs text-zinc-500 leading-5 dark:text-zinc-400">{caption}</p>
  </div>
);

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

// The explicit height is load-bearing: 13px 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-[30px] shrink-0 cursor-pointer select-none items-center gap-2 rounded-md px-2 font-medium text-[13px]",
  "text-zinc-700 no-underline transition-colors dark:text-zinc-300",
  "hover:bg-zinc-950/5 hover:text-zinc-900 dark:hover:bg-white/8 dark:hover:text-zinc-100",
  // Inset: the collapsing lists clip their overflow, so an outset ring would be cut.
  "focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#0169cc]/60",
  // The selected row is raised off the sidebar.
  "data-[active]:bg-white data-[active]:bg-linear-to-b data-[active]:from-white data-[active]:to-[#fdfdfd] data-[active]:text-zinc-900 data-[active]:shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)]",
  "dark:data-[active]:bg-[#2d2d30] dark:data-[active]:from-[#29292c] dark:data-[active]:to-[#242427] dark:data-[active]:text-zinc-100 dark:data-[active]:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]",
  "[&_svg]:size-4 [&_svg]:shrink-0",
].join(" ");

const Chevron = () => (
  <ChevronDown className="ml-auto size-3! text-zinc-400 transition-transform group-data-closed/row:-rotate-90 dark:text-zinc-500" />
);

The attributes are cumulative, so a stylesheet asking for [data-rail] gets branches too. See 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.

"use client";

import { Nav } from "@intentface/chat/nav";
import { ChevronDown } from "@keyline-icons/react";
import "./collapse.css";

/*
 * 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="relative collapse-demo w-72 rounded-xl bg-[#f5f5f6] shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] py-2 dark:bg-[#131315] dark:shadow-[0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:after:pointer-events-none dark:after:absolute dark:after:inset-0 dark:after:z-50 dark:after:rounded-[inherit] dark:after:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06)]">
    <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-[30px] shrink-0 cursor-pointer select-none items-center gap-2 rounded-md px-2 font-medium text-[13px]",
  "text-zinc-700 transition-colors dark:text-zinc-300",
  "hover:bg-zinc-950/5 hover:text-zinc-900 dark:hover:bg-white/8 dark:hover:text-zinc-100",
  // Inset: the collapsing lists clip their overflow, so an outset ring would be cut.
  "focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#0169cc]/60",
].join(" ");

const Chevron = () => (
  <ChevronDown className="ml-auto size-3 text-zinc-400 transition-transform group-data-closed/row:-rotate-90 dark:text-zinc-500" />
);
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.

Making a branch a page

Put a Nav.Toggle inside the trigger of a branch that has a page of its own. Pressing the row then activates it like Nav.Item, through your onClick or the link, and only the caret opens the group.

Showing guides
"use client";

import { Nav } from "@intentface/chat/nav";
import { type ComponentProps, useState } from "react";

/*
 * Branches that are also pages.
 *
 * "Guides" has an index of its own. The `Nav.Toggle` inside its trigger is what
 * makes it a destination: pressing the row (click, Enter or Space) shows the
 * page, and the caret opens the branch. The caret is a named button that says
 * whether the branch is open; ArrowRight and ArrowLeft still work from the row.
 *
 * "Reference" is an ordinary trigger beside it, for comparison: no toggle, so
 * its whole row is the disclosure and its chevron is only decoration.
 */
export const Destination = () => {
  const [page, setPage] = useState("guides");

  return (
    <div className="flex w-full max-w-xl flex-col gap-3 sm:flex-row">
      <div className="relative w-full shrink-0 rounded-xl bg-[#f5f5f6] shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] py-2 sm:w-60 dark:bg-[#131315] dark:shadow-[0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:after:pointer-events-none dark:after:absolute dark:after:inset-0 dark:after:z-50 dark:after:rounded-[inherit] dark:after:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06)]">
        <Nav.Root
          aria-label="Documentation"
          defaultExpanded={["guides"]}
          render={<nav />}
          className="flex flex-col px-2"
        >
          <Nav.List className="flex flex-col gap-0.5">
            <Nav.Group value="guides">
              <Nav.Trigger
                active={page === "guides"}
                onClick={() => setPage("guides")}
                className={rowClass}
              >
                {/* Its own hit area, a little larger than the glyph, so the caret is an
                    easy target and the rest of the row stays the destination. */}
                <Nav.Toggle
                  aria-label="More Guides pages"
                  className={[
                    "-ml-1 flex size-5 shrink-0 items-center justify-center rounded-full text-zinc-400 dark:text-zinc-500",
                    "transition-transform data-closed:-rotate-90",
                    "hover:bg-zinc-950/5 hover:text-zinc-900 dark:hover:bg-white/8 dark:hover:text-zinc-100",
                  ].join(" ")}
                >
                  <ChevronIcon className="size-3" />
                </Nav.Toggle>
                <Nav.Label className="min-w-0 truncate">Guides</Nav.Label>
              </Nav.Trigger>

              <Nav.List className={nestedClass}>
                {["Install", "Theming", "Deploy"].map((label) => (
                  <Nav.Item
                    key={label}
                    value={label.toLowerCase()}
                    active={page === label.toLowerCase()}
                    onClick={() => setPage(label.toLowerCase())}
                    className={rowClass}
                  >
                    <Nav.Label className="min-w-0 truncate">{label}</Nav.Label>
                  </Nav.Item>
                ))}
              </Nav.List>
            </Nav.Group>

            <Nav.Group value="reference">
              <Nav.Trigger className={rowClass}>
                {/* Decoration only: it turns with the row it sits in, through the row's
                    data-closed. */}
                <span
                  aria-hidden="true"
                  className="-ml-1 flex size-5 shrink-0 items-center justify-center text-zinc-400 transition-transform group-data-closed/row:-rotate-90 dark:text-zinc-500"
                >
                  <ChevronIcon className="size-3" />
                </span>
                <Nav.Label className="min-w-0 truncate">Reference</Nav.Label>
              </Nav.Trigger>

              <Nav.List className={nestedClass}>
                {["Props", "Hooks"].map((label) => (
                  <Nav.Item
                    key={label}
                    value={label.toLowerCase()}
                    active={page === label.toLowerCase()}
                    onClick={() => setPage(label.toLowerCase())}
                    className={rowClass}
                  >
                    <Nav.Label className="min-w-0 truncate">{label}</Nav.Label>
                  </Nav.Item>
                ))}
              </Nav.List>
            </Nav.Group>
          </Nav.List>
        </Nav.Root>
      </div>

      <div className="flex min-h-32 flex-1 items-center justify-center rounded-xl bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] text-[13px] text-zinc-500 dark:bg-zinc-900 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:text-zinc-400">
        Showing <span className="ml-1 font-medium text-zinc-900 dark:text-zinc-100">{page}</span>
      </div>
    </div>
  );
};

const nestedClass = "ml-[15px] flex flex-col gap-0.5 pl-[7px]";

const rowClass = [
  "group/row flex h-[30px] shrink-0 cursor-pointer select-none items-center gap-1.5 rounded-md px-2 font-medium text-[13px]",
  "text-zinc-700 transition-colors dark:text-zinc-300",
  "hover:bg-zinc-950/5 hover:text-zinc-900 dark:hover:bg-white/8 dark:hover:text-zinc-100",
  // The selected row is raised off the sidebar.
  "data-active:bg-white data-active:bg-linear-to-b data-active:from-white data-active:to-[#fdfdfd] data-active:text-zinc-900 data-active:shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)]",
  "dark:data-active:bg-[#2d2d30] dark:data-active:from-[#29292c] dark:data-active:to-[#242427] dark:data-active:text-zinc-100 dark:data-active:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]",
  "focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#0169cc]/60",
].join(" ");

const ChevronIcon = (props: ComponentProps<"svg">) => (
  <svg
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="2"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    {...props}
  >
    <path d="m4 6.5 4 4 4-4" />
  </svg>
);

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 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.

"use client";

import { Nav, type NavStore, useNavStore } from "@intentface/chat/nav";
import { ChevronDown } from "@keyline-icons/react";
import { useState } from "react";
import "./external.css";

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="relative flex w-72 flex-col gap-0.5 rounded-xl bg-[#f5f5f6] shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] px-2 py-2 dark:bg-[#131315] dark:shadow-[0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:after:pointer-events-none dark:after:absolute dark:after:inset-0 dark:after:z-50 dark:after:rounded-[inherit] dark:after:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06)]"
      >
        <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} />
    </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 bg-white bg-linear-to-b from-white to-[#fdfdfd] shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] px-4 font-medium text-[13px] text-zinc-900 enabled:hover:from-[#fafafa] enabled:hover:to-[#f6f6f6] disabled:cursor-default disabled:opacity-40 focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:text-zinc-100 dark:enabled:hover:from-[#38383b] dark:enabled:hover:to-[#313134]";

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

const rowClass = [
  "group/row flex h-[30px] shrink-0 cursor-pointer select-none items-center gap-2 rounded-md px-2 font-medium text-[13px]",
  "text-zinc-700 transition-colors dark:text-zinc-300",
  "hover:bg-zinc-950/5 hover:text-zinc-900 dark:hover:bg-white/8 dark:hover:text-zinc-100",
  // Inset: the collapsing lists clip their overflow, so an outset ring would be cut.
  "focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#0169cc]/60",
].join(" ");

const Chevron = () => (
  <ChevronDown className="ml-auto size-3 text-zinc-400 transition-transform group-data-closed/row:-rotate-90 dark:text-zinc-500" />
);

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.

KeyDescription
Arrow up / down
Move focus to the previous or next visible row. Collapsed lists are unmounted, so their rows are not there to land on.
Arrow right
On a closed group, open it; on an open one, step into it. On a leaf, nothing.
Arrow left
On an open group, close it; otherwise step out to the parent group's trigger — which is where the row came from.
Enter / Space
Activate the focused row: toggle a group, or go to its page when the trigger holds a Nav.Toggle.
Any letter
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.

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) and emits a bespoke part attribute (data-<part>) unless noted. Every part also carries data-depth and, when nested inside a group, data-nested.

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

PropTypeDefaultDetails
defaultExpandedstring[]—
expandedstring[]—
onExpandedChange(expanded: string[]) => void—
storeNavStore—
guide"none" | "indent" | "rail" | "branches""none"
loopbooleanfalse
disabledbooleanfalse
typeaheadbooleantrue
AttributeDescription
data-nav
The container.
data-depth
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.0

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.

PropTypeDefaultDetails
guide"none" | "indent" | "rail" | "branches"—
keepMountedbooleanfalse
AttributeDescription
data-nav-list
The list.
data-depth
Nesting level; 0 is the top.number
data-open
Present while open.
data-closed
Present while closed.
data-indent
Present for every guide but none.
data-rail
Present for rail and branches.
data-branches
Present for branches only.
data-starting-style
Present on the first open frame.
data-ending-style
Present while the close animation runs.
CSS variableDescription
--nav-depth
This list's depth, for one indent rule that covers every level.number
--nav-list-height
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.measured px
--nav-list-width
The same, for a horizontal collapse.measured px

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

PropTypeDefaultDetails
valuestring(required)
disabledboolean—
AttributeDescription
data-nav-group
The branch.the group's value
data-open
Present while open.
data-closed
Present while closed.

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.

Put a Nav.Toggle inside it and the two come apart: pressing the row activates it like Nav.Item, and the caret opens the group and carries aria-expanded. The arrow keys open and close it from the row either way.

PropTypeDefaultDetails
activebooleanfalse
disabledboolean—
AttributeDescription
data-nav-trigger
The disclosure row, and its identity — the same channel `data-nav-item` uses, which is how one selector walks leaves and headings alike.the group's value
data-open
Present while the group is open.
data-closed
Present while it is closed.
data-active
Present while active.
aria-current
Set while active, so assistive tech hears which row is being shown. Your own aria-current overrides it."page"
data-disabled
Present while disabled.

The caret inside a row — the control that opens a branch whose row is also a page. Mounting it makes pressing the row activate the page. Renders data-nav-toggle as a role="button" with tabIndex="-1" and aria-expanded, so give it a name with aria-label: out of the roving order, like Nav.Action, since the arrow keys already open the group from the row, and it stops every event it handles — anything that escaped would activate the row, or follow its link, on its way out of opening the group.

AttributeDescription
data-nav-toggle
The caret.
data-open
Present while the group is open — rotate the caret off this.
data-closed
Present while it is closed.
data-disabled
Present while the trigger is disabled.
data-depth
The enclosing list's depth.number

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

PropTypeDefaultDetails
valuestring(required)
activebooleanfalse
disabledboolean—
AttributeDescription
data-nav-item
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.the row's value
data-active
Present while active.
aria-current
Set while active. Your own aria-current overrides it."page"
data-disabled
Present while disabled.

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.

AttributeDescription
data-nav-label
The text.
data-depth
The enclosing list's depth.number
data-nested
Present inside a group.

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

AttributeDescription
data-nav-icon
The icon slot.
data-depth
The enclosing list's depth.number
data-nested
Present inside a group.

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.

AttributeDescription
data-nav-action
The control.
data-depth
The enclosing list's depth.number
data-nested
Present inside a group.

useNav

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

tsx
const isOpen = useNav((nav) => nav.expanded.has("docs"));
PropTypeDefaultDetails
expandedReadonlySet<string>—
toggle(value: string) => void—
setOpen(value: string, open: boolean) => void—
setExpanded(expanded: Iterable<string>) => void—