Skip to content

Multi-level application navigation

Three tiers — product bar, breadcrumb bar and a nested tree — answering "where am I" twice.

Shellsadvancedhierarchytreebreadcrumbapplicationdeep

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

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.