Multi-level application navigation
Three tiers — product bar, breadcrumb bar and a nested tree — answering "where am I" twice.
Live preview
Source
This exact file renders the preview above.
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { ChevronRight, Menu, X } from 'lucide-react'
import { cn } from '@/lib/cn'
import { BrandMark } from '@/components/library/brand'
import { Breadcrumb } from '@/components/ui/breadcrumb'
/**
* Multi-level application navigation
*
* Three tiers: product bar, section bar, and a nested tree. Deep hierarchies
* usually fail because a user cannot tell which level they are on, so this
* pattern pairs a persistent breadcrumb with an expanded path in the tree —
* two independent answers to "where am I".
*
* Tree nodes are disclosures with `aria-expanded`; the current page carries
* `aria-current`.
*/
interface TreeNode {
label: string
href: string
children?: TreeNode[]
}
const tree: TreeNode[] = [
{
label: 'Catalogue',
href: '/components',
children: [
{ label: 'Actions', href: '/components?category=actions' },
{ label: 'Forms', href: '/components?category=forms' },
{ label: 'Overlay', href: '/components?category=overlay' },
],
},
{
label: 'Composition',
href: '/sections',
children: [
{ label: 'Marketing', href: '/sections?category=marketing' },
{ label: 'Application', href: '/sections?category=application' },
],
},
{ label: 'Starters', href: '/starters' },
]
export default function MultiLevelNavigation() {
const [expanded, setExpanded] = useState<Record<string, boolean>>({ Catalogue: true })
const [mobileOpen, setMobileOpen] = useState(false)
const activeHref = '/components?category=forms'
const renderTree = (
<nav aria-label="Application tree" className="flex flex-col gap-1">
{tree.map((node) => {
const isOpen = expanded[node.label] ?? false
if (!node.children) {
return (
<Link
key={node.href}
href={node.href}
className="flex min-h-8 items-center rounded-md px-2.5 text-sm text-ink-muted hover:bg-surface-sunken hover:text-ink"
>
{node.label}
</Link>
)
}
return (
<div key={node.label}>
<button
type="button"
onClick={() => setExpanded((current) => ({ ...current, [node.label]: !isOpen }))}
aria-expanded={isOpen}
className="flex min-h-8 w-full items-center gap-1.5 rounded-md px-2.5 text-left text-sm font-medium text-ink hover:bg-surface-sunken"
>
<ChevronRight
className={cn('size-3 text-ink-subtle transition-transform', isOpen && 'rotate-90')}
aria-hidden="true"
/>
{node.label}
</button>
{isOpen ? (
<ul className="mt-0.5 ml-4 flex flex-col gap-0.5 border-l border-line-subtle pl-2">
{node.children.map((child) => {
const active = child.href === activeHref
return (
<li key={child.href}>
<Link
href={child.href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex min-h-8 items-center rounded-md px-2.5 text-sm transition-colors',
active
? 'bg-accent-soft font-medium text-accent-soft-ink'
: 'text-ink-muted hover:bg-surface-sunken hover:text-ink',
)}
>
{child.label}
</Link>
</li>
)
})}
</ul>
) : null}
</div>
)
})}
</nav>
)
return (
<div className="flex min-h-96 w-full flex-col bg-canvas">
<div className="flex h-12 items-center gap-3 border-b border-line bg-surface-inverse px-4 text-ink-inverse">
<BrandMark className="size-4" />
<span className="text-sm font-medium">Foundry Platform</span>
<span className="ml-auto text-xs opacity-70">Production</span>
</div>
<div className="flex h-12 items-center gap-2 border-b border-line bg-surface px-4">
<button
type="button"
onClick={() => setMobileOpen((value) => !value)}
aria-expanded={mobileOpen}
aria-controls="multi-level-tree"
className="flex size-8 items-center justify-center rounded-md text-ink-muted hover:bg-surface-sunken lg:hidden"
>
{mobileOpen ? (
<X className="size-4" aria-hidden="true" />
) : (
<Menu className="size-4" aria-hidden="true" />
)}
<span className="sr-only">{mobileOpen ? 'Close tree' : 'Open tree'}</span>
</button>
<Breadcrumb
items={[
{ label: 'Platform', href: '/' },
{ label: 'Catalogue', href: '/components' },
{ label: 'Forms' },
]}
/>
</div>
<div className="flex min-h-0 flex-1">
<aside className="hidden w-56 shrink-0 border-r border-line bg-surface p-2 lg:block">
{renderTree}
</aside>
<div
id="multi-level-tree"
hidden={!mobileOpen}
className="w-full border-b border-line bg-surface p-2 lg:hidden"
>
{renderTree}
</div>
<div className="hidden min-w-0 flex-1 p-6 lg:block">
<h2 className="text-lg font-semibold text-ink-strong">Forms</h2>
<p className="mt-2 max-w-prose text-sm text-ink-muted">
Breadcrumb and expanded tree answer “where am I” twice, independently. In deep
hierarchies that redundancy is the point.
</p>
</div>
</div>
</div>
)
}
components/blocks/navigation/multi-level.tsx
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { ChevronRight, Menu, X } from 'lucide-react'
import { cn } from '@/lib/cn'
import { BrandMark } from '@/components/library/brand'
import { Breadcrumb } from '@/components/ui/breadcrumb'
/**
* Multi-level application navigation
*
* Three tiers: product bar, section bar, and a nested tree. Deep hierarchies
* usually fail because a user cannot tell which level they are on, so this
* pattern pairs a persistent breadcrumb with an expanded path in the tree —
* two independent answers to "where am I".
*
* Tree nodes are disclosures with `aria-expanded`; the current page carries
* `aria-current`.
*/
interface TreeNode {
label: string
href: string
children?: TreeNode[]
}
const tree: TreeNode[] = [
{
label: 'Catalogue',
href: '/components',
children: [
{ label: 'Actions', href: '/components?category=actions' },
{ label: 'Forms', href: '/components?category=forms' },
{ label: 'Overlay', href: '/components?category=overlay' },
],
},
{
label: 'Composition',
href: '/sections',
children: [
{ label: 'Marketing', href: '/sections?category=marketing' },
{ label: 'Application', href: '/sections?category=application' },
],
},
{ label: 'Starters', href: '/starters' },
]
export default function MultiLevelNavigation() {
const [expanded, setExpanded] = useState<Record<string, boolean>>({ Catalogue: true })
const [mobileOpen, setMobileOpen] = useState(false)
const activeHref = '/components?category=forms'
const renderTree = (
<nav aria-label="Application tree" className="flex flex-col gap-1">
{tree.map((node) => {
const isOpen = expanded[node.label] ?? false
if (!node.children) {
return (
<Link
key={node.href}
href={node.href}
className="flex min-h-8 items-center rounded-md px-2.5 text-sm text-ink-muted hover:bg-surface-sunken hover:text-ink"
>
{node.label}
</Link>
)
}
return (
<div key={node.label}>
<button
type="button"
onClick={() => setExpanded((current) => ({ ...current, [node.label]: !isOpen }))}
aria-expanded={isOpen}
className="flex min-h-8 w-full items-center gap-1.5 rounded-md px-2.5 text-left text-sm font-medium text-ink hover:bg-surface-sunken"
>
<ChevronRight
className={cn('size-3 text-ink-subtle transition-transform', isOpen && 'rotate-90')}
aria-hidden="true"
/>
{node.label}
</button>
{isOpen ? (
<ul className="mt-0.5 ml-4 flex flex-col gap-0.5 border-l border-line-subtle pl-2">
{node.children.map((child) => {
const active = child.href === activeHref
return (
<li key={child.href}>
<Link
href={child.href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex min-h-8 items-center rounded-md px-2.5 text-sm transition-colors',
active
? 'bg-accent-soft font-medium text-accent-soft-ink'
: 'text-ink-muted hover:bg-surface-sunken hover:text-ink',
)}
>
{child.label}
</Link>
</li>
)
})}
</ul>
) : null}
</div>
)
})}
</nav>
)
return (
<div className="flex min-h-96 w-full flex-col bg-canvas">
<div className="flex h-12 items-center gap-3 border-b border-line bg-surface-inverse px-4 text-ink-inverse">
<BrandMark className="size-4" />
<span className="text-sm font-medium">Foundry Platform</span>
<span className="ml-auto text-xs opacity-70">Production</span>
</div>
<div className="flex h-12 items-center gap-2 border-b border-line bg-surface px-4">
<button
type="button"
onClick={() => setMobileOpen((value) => !value)}
aria-expanded={mobileOpen}
aria-controls="multi-level-tree"
className="flex size-8 items-center justify-center rounded-md text-ink-muted hover:bg-surface-sunken lg:hidden"
>
{mobileOpen ? (
<X className="size-4" aria-hidden="true" />
) : (
<Menu className="size-4" aria-hidden="true" />
)}
<span className="sr-only">{mobileOpen ? 'Close tree' : 'Open tree'}</span>
</button>
<Breadcrumb
items={[
{ label: 'Platform', href: '/' },
{ label: 'Catalogue', href: '/components' },
{ label: 'Forms' },
]}
/>
</div>
<div className="flex min-h-0 flex-1">
<aside className="hidden w-56 shrink-0 border-r border-line bg-surface p-2 lg:block">
{renderTree}
</aside>
<div
id="multi-level-tree"
hidden={!mobileOpen}
className="w-full border-b border-line bg-surface p-2 lg:hidden"
>
{renderTree}
</div>
<div className="hidden min-w-0 flex-1 p-6 lg:block">
<h2 className="text-lg font-semibold text-ink-strong">Forms</h2>
<p className="mt-2 max-w-prose text-sm text-ink-muted">
Breadcrumb and expanded tree answer “where am I” twice, independently. In deep
hierarchies that redundancy is the point.
</p>
</div>
</div>
</div>
)
}
components/ui/breadcrumb.tsx
import Link from 'next/link'
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/cn'
/**
* Breadcrumb
*
* A `<nav>` wrapping an ordered list. The final crumb is not a link and
* carries `aria-current="page"`, and the separators are decorative text inside
* `aria-hidden` spans so a screen reader reads "Components, Button" rather
* than "Components chevron Button".
*
* On narrow screens the trail scrolls horizontally instead of wrapping into an
* unreadable stack.
*/
export interface Crumb {
label: string
href?: string
}
export interface BreadcrumbProps {
items: Crumb[]
className?: string
/** Collapses the middle of very deep trails behind an ellipsis. */
maxItems?: number
}
export function Breadcrumb({ items, className, maxItems }: BreadcrumbProps) {
const shouldCollapse = maxItems !== undefined && items.length > maxItems
const visible: Array<Crumb | 'ellipsis'> = shouldCollapse
? [items[0] as Crumb, 'ellipsis', ...items.slice(items.length - (maxItems - 1))]
: items
return (
<nav aria-label="Breadcrumb" className={cn('min-w-0', className)}>
<ol className="hide-scrollbar flex items-center gap-1 overflow-x-auto text-xs whitespace-nowrap">
{visible.map((item, index) => {
const isLast = index === visible.length - 1
return (
<li
key={typeof item === 'string' ? `ellipsis-${index}` : `${item.label}-${index}`}
className="flex items-center gap-1"
>
{index > 0 ? (
<ChevronRight className="size-3 shrink-0 text-ink-subtle" aria-hidden="true" />
) : null}
{item === 'ellipsis' ? (
<span className="px-1 text-ink-subtle" aria-hidden="true">
…
</span>
) : isLast || !item.href ? (
<span
className={cn('font-medium', isLast ? 'text-ink' : 'text-ink-muted')}
aria-current={isLast ? 'page' : undefined}
>
{item.label}
</span>
) : (
<Link
href={item.href}
className="rounded-xs text-ink-muted transition-colors duration-150 hover:text-ink"
>
{item.label}
</Link>
)}
</li>
)
})}
</ol>
</nav>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
For deep hierarchies. These usually fail because a user cannot tell which level they are on, so the pattern pairs a persistent breadcrumb with an expanded path in the tree — deliberate redundancy.
- Expand the path to the current page on load; a collapsed tree hides the answer people came for.
- Tree nodes are disclosures with `aria-expanded`; leaves are links with `aria-current`.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- Product bar
- Breadcrumb bar
- Expandable tree
- Mobile tree disclosure
Accessibility
- Two answers
- Breadcrumb and tree convey location independently, so neither is a single point of failure.
- Nesting
- Child lists are real nested `<ul>`s, so depth is conveyed structurally.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.
Related
All navigationDocumentation shell
Header, search and a collapsible section tree that becomes an in-flow outline on mobile.
intermediateFeatured3 variantsBreadcrumb
A hierarchy trail with a collapsing middle for deep paths and horizontal scroll on mobile.
starter3 variantsDashboard shell
Top bar plus persistent sidebar, with the same destinations rendered into a drawer below lg.
intermediateFeatured4 variants