Collapsible sidebar
An icon rail that expands to labels — and re-labels itself with tooltips when collapsed.
Live preview
Source
This exact file renders the preview above.
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { ChevronLeft, FileText, Folder, Home, Search, Settings } from 'lucide-react'
import { cn } from '@/lib/cn'
import { BrandMark } from '@/components/library/brand'
import { Tooltip } from '@/components/ui/tooltip'
/**
* Collapsible sidebar
*
* A persistent rail that collapses to icons. Collapsing is not a purely visual
* change: the labels are removed from the DOM, so each item is re-labelled with
* `aria-label` and given a Tooltip — otherwise a collapsed sidebar becomes a
* column of unnamed buttons for screen-reader and keyboard users alike.
*/
const items = [
{ label: 'Overview', href: '/starters/admin/preview', icon: Home },
{ label: 'Projects', href: '/starters/admin/preview/orders', icon: Folder },
{ label: 'Documents', href: '/starters/admin/preview/users', icon: FileText },
{ label: 'Search', href: '/search', icon: Search },
{ label: 'Settings', href: '/starters/admin/preview/settings', icon: Settings },
]
export default function SidebarNavigation() {
const [collapsed, setCollapsed] = useState(false)
const activeHref = '/starters/admin/preview'
return (
<div className="flex min-h-96 w-full bg-canvas">
<aside
className={cn(
'flex shrink-0 flex-col border-r border-line bg-surface transition-[width] duration-200 ease-standard',
collapsed ? 'w-14' : 'w-56',
)}
>
<div
className={cn(
'flex h-14 items-center gap-2 border-b border-line-subtle px-3',
collapsed && 'justify-center px-0',
)}
>
<BrandMark className="size-5 shrink-0 text-accent" />
{!collapsed ? (
<span className="display-type text-sm font-semibold text-ink-strong">Console</span>
) : null}
</div>
<nav aria-label="Sidebar example" className="flex-1 p-2">
<ul className="flex flex-col gap-0.5">
{items.map((item) => {
const Icon = item.icon
const active = item.href === activeHref
const link = (
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
aria-label={collapsed ? item.label : undefined}
className={cn(
'flex min-h-9 items-center gap-2.5 rounded-md px-2.5 text-sm transition-colors',
collapsed && 'justify-center px-0',
active
? 'bg-accent-soft font-medium text-accent-soft-ink'
: 'text-ink-muted hover:bg-surface-sunken hover:text-ink',
)}
>
<Icon className="size-4 shrink-0" aria-hidden="true" />
{!collapsed ? <span className="truncate">{item.label}</span> : null}
</Link>
)
return (
<li key={item.href}>
{collapsed ? (
<Tooltip content={item.label} side="right">
{link}
</Tooltip>
) : (
link
)}
</li>
)
})}
</ul>
</nav>
<div className="border-t border-line-subtle p-2">
<button
type="button"
onClick={() => setCollapsed((value) => !value)}
aria-expanded={!collapsed}
className={cn(
'flex min-h-9 w-full items-center gap-2.5 rounded-md px-2.5 text-sm text-ink-muted transition-colors hover:bg-surface-sunken hover:text-ink',
collapsed && 'justify-center px-0',
)}
>
<ChevronLeft
className={cn('size-4 shrink-0 transition-transform', collapsed && 'rotate-180')}
aria-hidden="true"
/>
{!collapsed ? <span>Collapse</span> : <span className="sr-only">Expand sidebar</span>}
</button>
</div>
</aside>
<div className="min-w-0 flex-1 p-6">
<h2 className="text-lg font-semibold text-ink-strong">Overview</h2>
<p className="mt-2 max-w-prose text-sm text-ink-muted">
Collapsing the rail removes the labels from the DOM, so each item takes an{' '}
<code className="font-mono text-xs">aria-label</code> and a tooltip. Try it with the
keyboard.
</p>
</div>
</div>
)
}
components/blocks/navigation/sidebar.tsx
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { ChevronLeft, FileText, Folder, Home, Search, Settings } from 'lucide-react'
import { cn } from '@/lib/cn'
import { BrandMark } from '@/components/library/brand'
import { Tooltip } from '@/components/ui/tooltip'
/**
* Collapsible sidebar
*
* A persistent rail that collapses to icons. Collapsing is not a purely visual
* change: the labels are removed from the DOM, so each item is re-labelled with
* `aria-label` and given a Tooltip — otherwise a collapsed sidebar becomes a
* column of unnamed buttons for screen-reader and keyboard users alike.
*/
const items = [
{ label: 'Overview', href: '/starters/admin/preview', icon: Home },
{ label: 'Projects', href: '/starters/admin/preview/orders', icon: Folder },
{ label: 'Documents', href: '/starters/admin/preview/users', icon: FileText },
{ label: 'Search', href: '/search', icon: Search },
{ label: 'Settings', href: '/starters/admin/preview/settings', icon: Settings },
]
export default function SidebarNavigation() {
const [collapsed, setCollapsed] = useState(false)
const activeHref = '/starters/admin/preview'
return (
<div className="flex min-h-96 w-full bg-canvas">
<aside
className={cn(
'flex shrink-0 flex-col border-r border-line bg-surface transition-[width] duration-200 ease-standard',
collapsed ? 'w-14' : 'w-56',
)}
>
<div
className={cn(
'flex h-14 items-center gap-2 border-b border-line-subtle px-3',
collapsed && 'justify-center px-0',
)}
>
<BrandMark className="size-5 shrink-0 text-accent" />
{!collapsed ? (
<span className="display-type text-sm font-semibold text-ink-strong">Console</span>
) : null}
</div>
<nav aria-label="Sidebar example" className="flex-1 p-2">
<ul className="flex flex-col gap-0.5">
{items.map((item) => {
const Icon = item.icon
const active = item.href === activeHref
const link = (
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
aria-label={collapsed ? item.label : undefined}
className={cn(
'flex min-h-9 items-center gap-2.5 rounded-md px-2.5 text-sm transition-colors',
collapsed && 'justify-center px-0',
active
? 'bg-accent-soft font-medium text-accent-soft-ink'
: 'text-ink-muted hover:bg-surface-sunken hover:text-ink',
)}
>
<Icon className="size-4 shrink-0" aria-hidden="true" />
{!collapsed ? <span className="truncate">{item.label}</span> : null}
</Link>
)
return (
<li key={item.href}>
{collapsed ? (
<Tooltip content={item.label} side="right">
{link}
</Tooltip>
) : (
link
)}
</li>
)
})}
</ul>
</nav>
<div className="border-t border-line-subtle p-2">
<button
type="button"
onClick={() => setCollapsed((value) => !value)}
aria-expanded={!collapsed}
className={cn(
'flex min-h-9 w-full items-center gap-2.5 rounded-md px-2.5 text-sm text-ink-muted transition-colors hover:bg-surface-sunken hover:text-ink',
collapsed && 'justify-center px-0',
)}
>
<ChevronLeft
className={cn('size-4 shrink-0 transition-transform', collapsed && 'rotate-180')}
aria-hidden="true"
/>
{!collapsed ? <span>Collapse</span> : <span className="sr-only">Expand sidebar</span>}
</button>
</div>
</aside>
<div className="min-w-0 flex-1 p-6">
<h2 className="text-lg font-semibold text-ink-strong">Overview</h2>
<p className="mt-2 max-w-prose text-sm text-ink-muted">
Collapsing the rail removes the labels from the DOM, so each item takes an{' '}
<code className="font-mono text-xs">aria-label</code> and a tooltip. Try it with the
keyboard.
</p>
</div>
</div>
)
}
components/ui/tooltip.tsx
'use client'
import { cloneElement, useId, useRef, useState, type ReactElement, type ReactNode } from 'react'
import { cn } from '@/lib/cn'
/**
* Tooltip
*
* Supplementary text on hover *and* on keyboard focus — a tooltip that only
* appears on hover is invisible to keyboard users, which is the single most
* common tooltip bug.
*
* Escape dismisses it (WCAG 1.4.13), a short open delay stops it firing while a
* pointer merely crosses the control, and the content is wired with
* `aria-describedby` rather than replacing the control's accessible name.
*
* A tooltip must never hold the only copy of important information, and must
* never contain interactive content — reach for Popover instead.
*/
interface TriggerProps {
'aria-describedby'?: string
}
export interface TooltipProps {
content: ReactNode
children: ReactElement<TriggerProps>
side?: 'top' | 'bottom' | 'left' | 'right'
delay?: number
className?: string
}
const sides = {
top: 'bottom-full left-1/2 -translate-x-1/2 mb-1.5',
bottom: 'top-full left-1/2 -translate-x-1/2 mt-1.5',
left: 'right-full top-1/2 -translate-y-1/2 mr-1.5',
right: 'left-full top-1/2 -translate-y-1/2 ml-1.5',
} as const
export function Tooltip({ content, children, side = 'top', delay = 120, className }: TooltipProps) {
const [open, setOpen] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const id = useId()
const tooltipId = `${id}-tooltip`
const show = () => {
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(() => setOpen(true), delay)
}
const hide = () => {
if (timer.current) clearTimeout(timer.current)
setOpen(false)
}
// The trigger receives only `aria-describedby`; the listeners live on the
// wrapper. Focus and blur bubble in React, so keyboard focus still opens it,
// and cloneElement never carries a function that reads a ref.
const trigger = cloneElement(children, {
'aria-describedby': open ? tooltipId : undefined,
})
return (
<span
className={cn('relative inline-flex', className)}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={() => setOpen(true)}
onBlur={hide}
onKeyDown={(event) => {
if (event.key === 'Escape') hide()
}}
>
{trigger}
{open ? (
<span
id={tooltipId}
role="tooltip"
className={cn(
'animate-fade-in pointer-events-none absolute z-50 w-max max-w-56 rounded-md bg-surface-inverse px-2 py-1 text-xs leading-snug text-ink-inverse shadow-md',
sides[side],
)}
>
{content}
</span>
) : null}
</span>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
For tools where screen width is contested. Collapsing is not purely visual: the labels leave the DOM, so each item gains an `aria-label` and a Tooltip — otherwise the rail becomes a column of unnamed buttons.
- Persist the collapsed state in real products; the demo keeps it local so each preview starts predictably.
- Width transitions animate; icon positions do not shift, so the eye keeps its anchor.
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.
- Expanded
- Collapsed to icons
- Tooltip labelling
- Active state
Accessibility
- Names survive collapse
- Every collapsed item carries an `aria-label` matching the removed text.
- Tooltips on focus
- Tooltips appear on keyboard focus, not only hover.
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 navigationDashboard shell
Top bar plus persistent sidebar, with the same destinations rendered into a drawer below lg.
intermediateFeatured4 variantsTooltip
Supplementary text on hover and on keyboard focus, dismissible with Escape.
starter3 variantsAdmin starter
An internal console: overview, a filterable member table, orders, analytics and settings — all at compact density on the same components as the marketing starters.
advancedFeatured4 variants