|Kit

NavLink

A self-nesting sidebar/nav row — leading startSlot, label, trailing endSlot, current-page state, and expand/collapse for arbitrary-depth children.

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'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 below for how that resolves without either component ever importing a router package.

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

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

Code

<Navigation
  data={[
    { label: 'Dashboard', startSlot: <LayoutDashboard aria-hidden size={16} />, linkOptions: { href: '/dashboard' } },
    {
      label: 'Settings',
      startSlot: <Settings aria-hidden size={16} />,
      items: [
        { label: 'Profile', startSlot: <User aria-hidden size={16} />, linkOptions: { href: '/settings/profile' } },
        { label: 'Security', startSlot: <ShieldCheck aria-hidden size={16} />, linkOptions: { href: '/settings/security' } },
      ],
    },
  ]}
/>

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.

Code

<Navigation
  data={[
    { label: 'Dashboard', startSlot: <LayoutDashboard aria-hidden size={16} />, linkOptions: { href: '/dashboard' } },
    {
      label: 'Coalitions',
      startSlot: <ShieldCheck aria-hidden size={16} />,
      endSlot: <Badge variant="light" color="purple">3</Badge>,
      linkOptions: { href: '/coalitions' },
    },
    {
      label: 'Wallet',
      startSlot: <Settings aria-hidden size={16} />,
      endSlot: <Badge variant="outline" color="neutral">Soon</Badge>,
      disabled: true,
    },
  ]}
/>

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.

Code

<Navigation
  data={[{
    label: 'Settings',
    defaultOpen: true,
    items: [{
      label: 'Account',
      defaultOpen: true,
      items: [
        { label: 'Profile', linkOptions: { href: '/settings/account/profile' } },
        { label: 'Security', linkOptions: { href: '/settings/account/security' } },
      ],
    }],
  }]}
/>
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.

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.

Code

<Navigation
  data={[
    { label: 'Dashboard', linkOptions: { href: '/dashboard' } },
    { label: 'Reports', current: true, linkOptions: { href: '/reports' } },
  ]}
/>

Compound form

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

Code

<NavLink label="Settings" startSlot={<Settings aria-hidden size={16} />} defaultOpen>
  <NavLink label="Profile" linkOptions={{ href: '/settings/profile' }} />
  <NavLink label="Security" linkOptions={{ href: '/settings/security' }} />
</NavLink>

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:

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:

<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

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:

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

Prop

Type

Prop

Type

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

Prop

Type

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.

Changelog

v0.19.0
  • A translations prop for the close, dismiss, collapse/expand and region labels; <Notifications /> forwards its keys to every toast

    #331
v0.9.1
  • NavigationItem takes open and onOpenChange, forwarded to its NavLink

    #138
v0.9.0
  • NavigationItem takes classNames, forwarded to its NavLink

    #136
v0.6.0
  • NavLink and Navigation rename icon/suffix to startSlot/endSlot

    #102
v0.5.0
  • New NavLink and Navigation, rendered through any router via LinkComponentProvider

    #68

On this page