Documentation shell
Header, search and a collapsible section tree that becomes an in-flow outline on mobile.
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 { 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.
Related
All navigationDocumentation starter
A developer documentation site with six article routes, a guide index, an API reference and a working search page.
intermediateFeatured4 variantsDocumentation footer
Previous/next cards with rel hints, plus edit and report links.
starter3 variantsMulti-level application navigation
Three tiers — product bar, breadcrumb bar and a nested tree — answering "where am I" twice.
advanced4 variants