# NavLink (/docs/nav-link)





`NavLink` is a single sidebar/nav row — leading `startSlot`, `label`, trailing `endSlot`, current-page treatment, and self-nesting via `children` for arbitrary-depth sub-navigation. `Navigation` is the `data`-driven convenience wrapper over it (same relationship as [`Menu`](/docs/menu)'s `data` form to its compound parts).

Both are router-agnostic: pass whatever the target link needs as `linkOptions` — a plain `{ href }` for a bare `<a>`, or a framework router's own shape — and it's spread onto whatever renders the link. See [Router links](#router-links) below for how that resolves without either component ever importing a router package.

```tsx
import { Navigation } from "@42-central/ui-react/nav-link";

<Navigation
  data={[
    { label: "Dashboard", linkOptions: { href: "/dashboard" } },
    {
      label: "Settings",
      items: [
        { label: "Profile", linkOptions: { href: "/settings/profile" } },
        { label: "Security", linkOptions: { href: "/settings/security" } },
      ],
    },
  ]}
/>;
```

## Basic [#basic]

The `data` form is the quickest way to use it — pass an array of entries. An entry with `items` renders as an expandable row; everything else renders as a link (or plain text, with no `linkOptions`).

<Preview align="block" code="&#x22;<Navigation\n  data={[\n    { label: 'Dashboard', startSlot: <LayoutDashboard aria-hidden size={16} />, linkOptions: { href: '/dashboard' } },\n    {\n      label: 'Settings',\n      startSlot: <Settings aria-hidden size={16} />,\n      items: [\n        { label: 'Profile', startSlot: <User aria-hidden size={16} />, linkOptions: { href: '/settings/profile' } },\n        { label: 'Security', startSlot: <ShieldCheck aria-hidden size={16} />, linkOptions: { href: '/settings/security' } },\n      ],\n    },\n  ]}\n/>&#x22;">
  <Navigation
    data="[
    {
      label: &#x22;Dashboard&#x22;,
      startSlot: <LayoutDashboard aria-hidden size={16} />,
      linkOptions: { href: &#x22;/dashboard&#x22; },
    },
    {
      label: &#x22;Settings&#x22;,
      startSlot: <Settings aria-hidden size={16} />,
      items: [
        {
          label: &#x22;Profile&#x22;,
          startSlot: <User aria-hidden size={16} />,
          linkOptions: { href: &#x22;/settings/profile&#x22; },
        },
        {
          label: &#x22;Security&#x22;,
          startSlot: <ShieldCheck aria-hidden size={16} />,
          linkOptions: { href: &#x22;/settings/security&#x22; },
        },
      ],
    },
  ]"
  />
</Preview>

## Trailing slot [#trailing-slot]

`endSlot` renders at the far trailing edge of the row — `label` fills the remaining space in between, so it stays flush right regardless of how long the label is. Use it for a count, a status badge, anything that isn't the primary click target.

<Preview align="block" code="&#x22;<Navigation\n  data={[\n    { label: 'Dashboard', startSlot: <LayoutDashboard aria-hidden size={16} />, linkOptions: { href: '/dashboard' } },\n    {\n      label: 'Coalitions',\n      startSlot: <ShieldCheck aria-hidden size={16} />,\n      endSlot: <Badge variant=\&#x22;light\&#x22; color=\&#x22;purple\&#x22;>3</Badge>,\n      linkOptions: { href: '/coalitions' },\n    },\n    {\n      label: 'Wallet',\n      startSlot: <Settings aria-hidden size={16} />,\n      endSlot: <Badge variant=\&#x22;outline\&#x22; color=\&#x22;neutral\&#x22;>Soon</Badge>,\n      disabled: true,\n    },\n  ]}\n/>&#x22;">
  <Navigation
    data="[
    {
      label: &#x22;Dashboard&#x22;,
      startSlot: <LayoutDashboard aria-hidden size={16} />,
      linkOptions: { href: &#x22;/dashboard&#x22; },
    },
    {
      label: &#x22;Coalitions&#x22;,
      startSlot: <ShieldCheck aria-hidden size={16} />,
      endSlot: (
        <Badge variant=&#x22;light&#x22; color=&#x22;purple&#x22;>
          3
        </Badge>
      ),
      linkOptions: { href: &#x22;/coalitions&#x22; },
    },
    {
      label: &#x22;Wallet&#x22;,
      startSlot: <Settings aria-hidden size={16} />,
      endSlot: (
        <Badge variant=&#x22;outline&#x22; color=&#x22;neutral&#x22;>
          Soon
        </Badge>
      ),
      disabled: true,
    },
  ]"
  />
</Preview>

## Nesting [#nesting]

`items` is recursive — an item's own `items` can themselves have `items`, to any depth. A row with no `linkOptions` (a pure category heading) renders as a toggle button rather than a link.

<Preview align="block" code="&#x22;<Navigation\n  data={[{\n    label: 'Settings',\n    defaultOpen: true,\n    items: [{\n      label: 'Account',\n      defaultOpen: true,\n      items: [\n        { label: 'Profile', linkOptions: { href: '/settings/account/profile' } },\n        { label: 'Security', linkOptions: { href: '/settings/account/security' } },\n      ],\n    }],\n  }]}\n/>&#x22;">
  <Navigation
    data="[
    {
      label: &#x22;Settings&#x22;,
      defaultOpen: true,
      items: [
        {
          label: &#x22;Account&#x22;,
          defaultOpen: true,
          items: [
            { label: &#x22;Profile&#x22;, linkOptions: { href: &#x22;/settings/account/profile&#x22; } },
            { label: &#x22;Security&#x22;, linkOptions: { href: &#x22;/settings/account/security&#x22; } },
          ],
        },
      ],
    },
  ]"
  />
</Preview>

<Callout type="info" title="A link with children gets two controls">
  A row that's both a link and expandable (has `linkOptions` *and* `items`) renders the link and its expand/collapse toggle as two independent controls, side by side — nesting one interactive element inside another (an `<a>` inside a `<button>`, or vice versa) isn't valid HTML.
</Callout>

## Current page [#current-page]

Pass `current` on the active entry — it gets `aria-current="page"` and a distinct visual treatment. Nothing is auto-detected from a URL: this kit doesn't read your router's location, so pass whatever your app already knows is the current page.

<Preview align="block" code="&#x22;<Navigation\n  data={[\n    { label: 'Dashboard', linkOptions: { href: '/dashboard' } },\n    { label: 'Reports', current: true, linkOptions: { href: '/reports' } },\n  ]}\n/>&#x22;">
  <Navigation
    data="[
    { label: &#x22;Dashboard&#x22;, linkOptions: { href: &#x22;/dashboard&#x22; } },
    { label: &#x22;Reports&#x22;, current: true, linkOptions: { href: &#x22;/reports&#x22; } },
  ]"
  />
</Preview>

## Compound form [#compound-form]

Compose `NavLink` directly for anything the `data` array can't express — nesting is just JSX children, no separate wrapper needed.

<Preview align="block" code="&#x22;<NavLink label=\&#x22;Settings\&#x22; startSlot={<Settings aria-hidden size={16} />} defaultOpen>\n  <NavLink label=\&#x22;Profile\&#x22; linkOptions={{ href: '/settings/profile' }} />\n  <NavLink label=\&#x22;Security\&#x22; linkOptions={{ href: '/settings/security' }} />\n</NavLink>&#x22;">
  <NavLink label="Settings" startSlot="<Settings aria-hidden size={16} />">
    <NavLink label="Profile" linkOptions="{ href: &#x22;/settings/profile&#x22; }" />

    <NavLink label="Security" linkOptions="{ href: &#x22;/settings/security&#x22; }" />
  </NavLink>
</Preview>

## Router links [#router-links]

Neither `NavLink` nor `Navigation` imports a router package — what renders a link is resolved from `linkComponent` (a per-instance override), then the app-wide `<LinkComponentProvider>`, then a plain `<a>`. Wrap your app once instead of passing `linkComponent` to every instance:

```tsx
import { LinkComponentProvider } from "@42-central/ui-react";
import { Link } from "@tanstack/react-router"; // or Next.js's Link, React Router's Link, ...

<LinkComponentProvider component={Link}>
  <App />
</LinkComponentProvider>;
```

Every `NavLink`/`Navigation` under the provider now renders through `Link`, with `linkOptions` spread onto it verbatim — e.g. TanStack Router's own `{ to, params }` shape:

```tsx
<Navigation
  data={[{ label: "Post", linkOptions: { to: "/posts/$postId", params: { postId: "1" } } }]}
/>;
```

`LinkComponentProvider` is a shared, kit-wide piece (`@42-central/ui-react`'s root export, not specific to `NavLink`) — any future router-aware component can resolve the same app-wide `Link` from it.

### Type-safe routes, without the coupling [#type-safe-routes-without-the-coupling]

`NavLink`/`Navigation` are generic over `linkOptions`'s shape, so a router's own type-safety utilities can validate it — without `nav-link.tsx`/`navigation.tsx` ever importing that router. For TanStack Router, import from the opt-in adapter instead of the base module to get every `linkOptions` validated and autocompleted against your real route tree:

```tsx
import { NavLink, Navigation } from "@42-central/ui-react/nav-link/tanstack";
import { Link } from "@tanstack/react-router";

// ✅ typechecks — "/posts/$postId" is a real route, params matches it
<NavLink label="Post" linkComponent={Link}
  linkOptions={{ to: "/posts/$postId", params: { postId: "1" } }} />

// ❌ type error — no such route
<NavLink label="Typo" linkComponent={Link} linkOptions={{ to: "/postz" }} />

// ❌ type error — params is required for this route
<NavLink label="Missing params" linkComponent={Link} linkOptions={{ to: "/posts/$postId" }} />
```

This requires `@tanstack/react-router` (an optional peer dependency — install it yourself if you want this) purely for its types; nothing at runtime changes. Apps on a different router (or none) never need it installed — the base `@42-central/ui-react/nav-link` module has no idea TanStack Router exists.

## API [#api]

### NavLink [#navlink]

<TypeTable
  type="{
  label: {
    type: &#x22;ReactNode&#x22;,
  },
  startSlot: {
    type: &#x22;ReactNode&#x22;,
    description: &#x22;Leading slot (icon, avatar, etc).&#x22;,
  },
  endSlot: {
    type: &#x22;ReactNode&#x22;,
    description: &#x22;Trailing slot (e.g. a count or status badge).&#x22;,
  },
  current: {
    type: &#x22;boolean&#x22;,
    description: 'Marks this as the active/current page: aria-current=&#x22;page&#x22; + a distinct treatment.',
  },
  disabled: {
    type: &#x22;boolean&#x22;,
  },
  open: {
    type: &#x22;boolean&#x22;,
    description: &#x22;Whether nested children are revealed. Controlled — pair with onOpenChange.&#x22;,
  },
  defaultOpen: {
    type: &#x22;boolean&#x22;,
    default: &#x22;false&#x22;,
    description: &#x22;Initial revealed state when uncontrolled.&#x22;,
  },
  onOpenChange: {
    type: &#x22;(open: boolean) => void&#x22;,
  },
  linkOptions: {
    type: &#x22;TOptions&#x22;,
    description:
      &#x22;The router's own link options, e.g. { href } for a plain <a>, or a router's own shape. Omit for a pure category/toggle row with no link of its own.&#x22;,
  },
  linkComponent: {
    type: &#x22;ElementType&#x22;,
    description: &#x22;Overrides the LinkComponentProvider for this row only.&#x22;,
  },
  classNames: {
    type: &#x22;{ row?: string; items?: string }&#x22;,
  },
  translations: {
    type: &#x22;{ collapseLabel?: string; expandLabel?: string }&#x22;,
    description:
      'Accessible names of the expand/collapse chevron a link row with children renders (defaults &#x22;Collapse&#x22; / &#x22;Expand&#x22;). A partial override keeps the other default.',
  },
  children: {
    type: &#x22;ReactNode&#x22;,
    description: &#x22;Nested NavLink elements, revealed when open.&#x22;,
  },
}"
/>

### Navigation [#navigation]

<TypeTable
  type="{
  data: {
    type: &#x22;NavigationItem[]&#x22;,
    description: &#x22;The nav entries, in order.&#x22;,
  },
  linkComponent: {
    type: &#x22;ElementType&#x22;,
    description: &#x22;Overrides the LinkComponentProvider for every generated row.&#x22;,
  },
  translations: {
    type: &#x22;{ collapseLabel?: string; expandLabel?: string }&#x22;,
    description: &#x22;NavLink's chevron names, forwarded to every generated row.&#x22;,
  },
}"
/>

`NavigationItem` takes the same `label` / `startSlot` / `endSlot` / `current` / `disabled` / `open` / `defaultOpen` / `onOpenChange` / `linkOptions` / `classNames` as `NavLink`, plus:

<TypeTable
  type="{
  items: {
    type: &#x22;NavigationItem[]&#x22;,
    description: &#x22;Nested entries. Recursive — nests to any depth.&#x22;,
  },
}"
/>

## Accessibility [#accessibility]

* A row with `linkOptions` renders as its link element with `aria-current="page"` when `current`.
* A row with no `linkOptions` renders as a `<button>`; clicking it toggles `aria-expanded` when it has children.
* A row with both `linkOptions` and children exposes its expand/collapse toggle as a separate button (`aria-expanded`, `aria-label="Expand"/"Collapse"`), since a link can't contain another interactive element.
