Skip to content

Documentation shell

Header, search and a collapsible section tree that becomes an in-flow outline on mobile.

ShellsintermediateFeatureddocssidebartreeshellsearch

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 { Badge } from '@/components/ui/badge'
import { SearchField } from '@/components/ui/search-field'
import { docsNav } from '@/content/nav-demo'

/**
 * Documentation navbar
 *
 * Header plus a collapsible section tree. The tree is the primary navigation
 * on desktop and moves into an in-flow disclosure below `lg` — deliberately
 * not a drawer, so a reader can keep their place in the page while scanning
 * the outline.
 *
 * Groups use a real `<details>`-style disclosure with `aria-expanded`, so the
 * open/closed state is announced.
 */
export default function DocumentationNavbar() {
  const [mobileOpen, setMobileOpen] = useState(false)
  const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
  const activeHref = '/docs/design-tokens'

  const toggle = (label: string) =>
    setCollapsed((current) => ({ ...current, [label]: !current[label] }))

  const tree = (
    <nav aria-label="Documentation" className="flex flex-col gap-4">
      {docsNav.map((group) => {
        const isCollapsed = collapsed[group.label] ?? false
        return (
          <div key={group.label}>
            <button
              type="button"
              onClick={() => toggle(group.label)}
              aria-expanded={!isCollapsed}
              className="flex w-full items-center gap-1.5 rounded-sm px-2 py-1 text-left"
            >
              <ChevronRight
                className={cn(
                  'size-3 text-ink-subtle transition-transform',
                  !isCollapsed && 'rotate-90',
                )}
                aria-hidden="true"
              />
              <span className="label-caps text-ink-subtle">{group.label}</span>
            </button>
            {!isCollapsed ? (
              <ul className="mt-1 flex flex-col gap-0.5 border-l border-line-subtle pl-3">
                {group.links.map((link) => {
                  const active = link.href === activeHref
                  return (
                    <li key={link.href}>
                      <Link
                        href={link.href}
                        aria-current={active ? 'page' : undefined}
                        className={cn(
                          '-ml-px flex min-h-8 items-center border-l-2 px-3 text-sm transition-colors',
                          active
                            ? 'border-accent font-medium text-accent'
                            : 'border-transparent text-ink-muted hover:border-line-strong hover:text-ink',
                        )}
                      >
                        {link.label}
                      </Link>
                    </li>
                  )
                })}
              </ul>
            ) : null}
          </div>
        )
      })}
    </nav>
  )

  return (
    <div className="w-full bg-surface">
      <div className="border-b border-line">
        <div className="mx-auto flex h-14 w-full max-w-7xl items-center gap-3 px-4 sm:px-6">
          <button
            type="button"
            onClick={() => setMobileOpen((value) => !value)}
            aria-expanded={mobileOpen}
            aria-controls="docs-nav-panel"
            className="flex size-9 items-center justify-center rounded-md text-ink-muted hover:bg-surface-sunken lg:hidden"
          >
            {mobileOpen ? (
              <X className="size-5" aria-hidden="true" />
            ) : (
              <Menu className="size-5" aria-hidden="true" />
            )}
            <span className="sr-only">{mobileOpen ? 'Close outline' : 'Open outline'}</span>
          </button>
          <Link href="/docs/getting-started" className="flex items-center gap-2 text-ink-strong">
            <BrandMark className="size-5 text-accent" />
            <span className="display-type text-md font-semibold">Foundry</span>
          </Link>
          <Badge size="sm" className="hidden sm:inline-flex">
            v1.0
          </Badge>
          <div className="ml-auto w-full max-w-64">
            <SearchField
              fieldSize="sm"
              placeholder="Search docs"
              aria-label="Search documentation"
              shortcutHint="/"
            />
          </div>
        </div>
      </div>

      <div className="mx-auto flex w-full max-w-7xl gap-8 px-4 sm:px-6">
        <aside className="hidden w-56 shrink-0 py-6 lg:block">{tree}</aside>
        <div
          id="docs-nav-panel"
          hidden={!mobileOpen}
          className="w-full border-b border-line-subtle py-4 lg:hidden"
        >
          {tree}
        </div>
        <div className="hidden min-w-0 flex-1 border-l border-line-subtle py-6 pl-8 lg:block">
          <p className="text-sm text-ink-muted">
            Page content sits here. The outline stays fixed while the article scrolls.
          </p>
        </div>
      </div>
    </div>
  )
}

components/blocks/navigation/documentation.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 { Badge } from '@/components/ui/badge'
import { SearchField } from '@/components/ui/search-field'
import { docsNav } from '@/content/nav-demo'

/**
 * Documentation navbar
 *
 * Header plus a collapsible section tree. The tree is the primary navigation
 * on desktop and moves into an in-flow disclosure below `lg` — deliberately
 * not a drawer, so a reader can keep their place in the page while scanning
 * the outline.
 *
 * Groups use a real `<details>`-style disclosure with `aria-expanded`, so the
 * open/closed state is announced.
 */
export default function DocumentationNavbar() {
  const [mobileOpen, setMobileOpen] = useState(false)
  const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
  const activeHref = '/docs/design-tokens'

  const toggle = (label: string) =>
    setCollapsed((current) => ({ ...current, [label]: !current[label] }))

  const tree = (
    <nav aria-label="Documentation" className="flex flex-col gap-4">
      {docsNav.map((group) => {
        const isCollapsed = collapsed[group.label] ?? false
        return (
          <div key={group.label}>
            <button
              type="button"
              onClick={() => toggle(group.label)}
              aria-expanded={!isCollapsed}
              className="flex w-full items-center gap-1.5 rounded-sm px-2 py-1 text-left"
            >
              <ChevronRight
                className={cn(
                  'size-3 text-ink-subtle transition-transform',
                  !isCollapsed && 'rotate-90',
                )}
                aria-hidden="true"
              />
              <span className="label-caps text-ink-subtle">{group.label}</span>
            </button>
            {!isCollapsed ? (
              <ul className="mt-1 flex flex-col gap-0.5 border-l border-line-subtle pl-3">
                {group.links.map((link) => {
                  const active = link.href === activeHref
                  return (
                    <li key={link.href}>
                      <Link
                        href={link.href}
                        aria-current={active ? 'page' : undefined}
                        className={cn(
                          '-ml-px flex min-h-8 items-center border-l-2 px-3 text-sm transition-colors',
                          active
                            ? 'border-accent font-medium text-accent'
                            : 'border-transparent text-ink-muted hover:border-line-strong hover:text-ink',
                        )}
                      >
                        {link.label}
                      </Link>
                    </li>
                  )
                })}
              </ul>
            ) : null}
          </div>
        )
      })}
    </nav>
  )

  return (
    <div className="w-full bg-surface">
      <div className="border-b border-line">
        <div className="mx-auto flex h-14 w-full max-w-7xl items-center gap-3 px-4 sm:px-6">
          <button
            type="button"
            onClick={() => setMobileOpen((value) => !value)}
            aria-expanded={mobileOpen}
            aria-controls="docs-nav-panel"
            className="flex size-9 items-center justify-center rounded-md text-ink-muted hover:bg-surface-sunken lg:hidden"
          >
            {mobileOpen ? (
              <X className="size-5" aria-hidden="true" />
            ) : (
              <Menu className="size-5" aria-hidden="true" />
            )}
            <span className="sr-only">{mobileOpen ? 'Close outline' : 'Open outline'}</span>
          </button>
          <Link href="/docs/getting-started" className="flex items-center gap-2 text-ink-strong">
            <BrandMark className="size-5 text-accent" />
            <span className="display-type text-md font-semibold">Foundry</span>
          </Link>
          <Badge size="sm" className="hidden sm:inline-flex">
            v1.0
          </Badge>
          <div className="ml-auto w-full max-w-64">
            <SearchField
              fieldSize="sm"
              placeholder="Search docs"
              aria-label="Search documentation"
              shortcutHint="/"
            />
          </div>
        </div>
      </div>

      <div className="mx-auto flex w-full max-w-7xl gap-8 px-4 sm:px-6">
        <aside className="hidden w-56 shrink-0 py-6 lg:block">{tree}</aside>
        <div
          id="docs-nav-panel"
          hidden={!mobileOpen}
          className="w-full border-b border-line-subtle py-4 lg:hidden"
        >
          {tree}
        </div>
        <div className="hidden min-w-0 flex-1 border-l border-line-subtle py-6 pl-8 lg:block">
          <p className="text-sm text-ink-muted">
            Page content sits here. The outline stays fixed while the article scrolls.
          </p>
        </div>
      </div>
    </div>
  )
}

components/ui/search-field.tsx

'use client'

import { useRef, useState, type InputHTMLAttributes } from 'react'
import { Search, X } from 'lucide-react'
import { cn } from '@/lib/cn'
import { controlSurface } from './input'

/**
 * SearchField
 *
 * `type="search"` with the browser's own clear button suppressed and replaced
 * by one that matches the library — mainly so it is reachable by keyboard and
 * carries a real accessible name, which the native one does not in every
 * engine.
 *
 * Clearing returns focus to the input; otherwise keyboard users land at the
 * top of the document after emptying a query.
 */
export interface SearchFieldProps
  extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange'> {
  onValueChange?: (value: string) => void
  fieldSize?: 'sm' | 'md' | 'lg'
  /** Shown on the trailing edge when empty — typically a ⌘K hint. */
  shortcutHint?: string
}

const heights = {
  sm: 'h-control-sm text-xs',
  md: 'h-control text-sm',
  lg: 'h-control-lg text-base',
} as const

export function SearchField({
  onValueChange,
  fieldSize = 'md',
  shortcutHint,
  className,
  defaultValue = '',
  value: controlledValue,
  placeholder = 'Search',
  ...props
}: SearchFieldProps) {
  const inputRef = useRef<HTMLInputElement>(null)
  const [uncontrolled, setUncontrolled] = useState(String(defaultValue))
  const value = controlledValue === undefined ? uncontrolled : String(controlledValue)

  const update = (next: string) => {
    if (controlledValue === undefined) setUncontrolled(next)
    onValueChange?.(next)
  }

  return (
    <div className={cn('relative flex items-center', className)}>
      <Search
        className="pointer-events-none absolute left-3 size-4 text-ink-subtle"
        aria-hidden="true"
      />
      <input
        ref={inputRef}
        type="search"
        value={value}
        placeholder={placeholder}
        onChange={(event) => update(event.target.value)}
        className={cn(
          controlSurface,
          heights[fieldSize],
          'pr-9 pl-9',
          '[&::-webkit-search-cancel-button]:appearance-none',
        )}
        {...props}
      />
      {value ? (
        <button
          type="button"
          onClick={() => {
            update('')
            inputRef.current?.focus()
          }}
          className="absolute right-2 flex size-6 items-center justify-center rounded-sm text-ink-subtle hover:bg-surface-sunken hover:text-ink"
        >
          <X className="size-3.5" aria-hidden="true" />
          <span className="sr-only">Clear search</span>
        </button>
      ) : shortcutHint ? (
        <kbd
          className="pointer-events-none absolute right-2.5 rounded-sm border border-line bg-surface-sunken px-1.5 py-0.5 font-mono text-2xs text-ink-subtle"
          aria-hidden="true"
        >
          {shortcutHint}
        </kbd>
      ) : null}
    </div>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

The shell this documentation site uses. The outline stays in flow on mobile rather than becoming a drawer, so a reader can scan the structure without losing their place in the page.

  • Group disclosures are buttons with `aria-expanded`, so the open/closed state is announced.
  • The active page uses a left border plus colour plus `aria-current` — three signals for one state.

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.

  • Collapsible groups
  • Active page marking
  • Mobile in-flow outline

Accessibility

Current page
`aria-current="page"` on the active link, independent of styling.
Landmark
The tree is a labelled `<nav>` distinct from the site-level navigation.

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.