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 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>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:
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
NavLink
Prop
Type
Navigation
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
linkOptionsrenders as its link element witharia-current="page"whencurrent. - A row with no
linkOptionsrenders as a<button>; clicking it togglesaria-expandedwhen it has children. - A row with both
linkOptionsand 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
- #331
A
translationsprop for the close, dismiss, collapse/expand and region labels;<Notifications />forwards its keys to every toast
- #138
NavigationItemtakesopenandonOpenChange, forwarded to itsNavLink
- #136
NavigationItemtakesclassNames, forwarded to itsNavLink
- #102
NavLinkandNavigationrenameicon/suffixtostartSlot/endSlot
- #68
New NavLink and Navigation, rendered through any router via
LinkComponentProvider