Dashboard shell
Top bar plus persistent sidebar, with the same destinations rendered into a drawer below lg.
Live preview
Source
This exact file renders the preview above.
'use client'
import { useState } from 'react'
import Link from 'next/link'
import {
BarChart3,
Bell,
ChevronDown,
LayoutDashboard,
Menu,
Settings,
ShoppingCart,
Users,
} from 'lucide-react'
import { cn } from '@/lib/cn'
import { BrandMark } from '@/components/library/brand'
import { Avatar } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Drawer } from '@/components/ui/drawer'
import { Dropdown } from '@/components/ui/dropdown'
import { SearchField } from '@/components/ui/search-field'
/**
* Dashboard shell navigation
*
* Top bar plus persistent sidebar on desktop; the same sidebar becomes a
* drawer below `lg`. Both render from one array, so a new destination appears
* in both places or neither — the class of bug where mobile navigation
* silently falls behind desktop simply cannot occur.
*/
const items = [
{ label: 'Overview', href: '/starters/admin/preview', icon: LayoutDashboard },
{ label: 'Users', href: '/starters/admin/preview/users', icon: Users, badge: '18' },
{ label: 'Orders', href: '/starters/admin/preview/orders', icon: ShoppingCart, badge: '4' },
{ label: 'Analytics', href: '/starters/admin/preview/analytics', icon: BarChart3 },
{ label: 'Settings', href: '/starters/admin/preview/settings', icon: Settings },
]
function NavList({ activeHref }: { activeHref: string }) {
return (
<ul className="flex flex-col gap-0.5">
{items.map((item) => {
const Icon = item.icon
const active = item.href === activeHref
return (
<li key={item.href}>
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex min-h-9 items-center gap-2.5 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',
)}
>
<Icon className="size-4 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.badge ? <Badge size="sm">{item.badge}</Badge> : null}
</Link>
</li>
)
})}
</ul>
)
}
export default function DashboardNavigation() {
const [drawerOpen, setDrawerOpen] = useState(false)
const activeHref = '/starters/admin/preview/users'
return (
<div className="flex min-h-96 w-full flex-col bg-canvas">
<header className="flex h-14 items-center gap-3 border-b border-line bg-surface px-4">
<button
type="button"
onClick={() => setDrawerOpen(true)}
aria-expanded={drawerOpen}
className="flex size-9 items-center justify-center rounded-md text-ink-muted hover:bg-surface-sunken lg:hidden"
>
<Menu className="size-5" aria-hidden="true" />
<span className="sr-only">Open navigation</span>
</button>
<Link href="/starters/admin/preview" className="flex items-center gap-2 text-ink-strong">
<BrandMark className="size-5 text-accent" />
<span className="display-type text-sm font-semibold">Console</span>
</Link>
<div className="ml-auto hidden w-64 md:block">
<SearchField
fieldSize="sm"
placeholder="Search records"
aria-label="Search records"
shortcutHint="⌘K"
/>
</div>
<button
type="button"
className="relative flex size-9 shrink-0 items-center justify-center rounded-md text-ink-muted hover:bg-surface-sunken"
>
<Bell className="size-4.5" aria-hidden="true" />
<span
className="absolute top-1.5 right-1.5 size-2 rounded-full bg-danger"
aria-hidden="true"
/>
<span className="sr-only">Notifications, 3 unread</span>
</button>
<Dropdown
align="end"
label="Account"
groups={[
{
items: [
{ id: 'profile', label: 'Profile', href: '/starters/admin/preview/settings' },
{ id: 'prefs', label: 'Preferences', href: '/starters/admin/preview/settings' },
],
},
{ items: [{ id: 'signout', label: 'Sign out', href: '/starters/saas/preview/login' }] },
]}
trigger={(props) => (
<button
type="button"
className="flex shrink-0 items-center gap-1.5 rounded-md p-0.5 hover:bg-surface-sunken"
{...props}
>
<Avatar name="Priya Raman" size="sm" decorative />
<ChevronDown className="size-3.5 text-ink-subtle" aria-hidden="true" />
<span className="sr-only">Account menu</span>
</button>
)}
/>
</header>
<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">
<nav aria-label="Dashboard">
<NavList activeHref={activeHref} />
</nav>
</aside>
<div className="min-w-0 flex-1 p-6">
<h2 className="text-lg font-semibold text-ink-strong">Users</h2>
<p className="mt-2 max-w-prose text-sm text-ink-muted">
The sidebar and the mobile drawer render from the same array, so a new destination can
never appear in one and not the other.
</p>
</div>
</div>
<Drawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
side="left"
size="sm"
title="Navigation"
>
<nav aria-label="Dashboard, mobile" className="p-3">
<NavList activeHref={activeHref} />
</nav>
</Drawer>
</div>
)
}
components/blocks/navigation/dashboard.tsx
'use client'
import { useState } from 'react'
import Link from 'next/link'
import {
BarChart3,
Bell,
ChevronDown,
LayoutDashboard,
Menu,
Settings,
ShoppingCart,
Users,
} from 'lucide-react'
import { cn } from '@/lib/cn'
import { BrandMark } from '@/components/library/brand'
import { Avatar } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Drawer } from '@/components/ui/drawer'
import { Dropdown } from '@/components/ui/dropdown'
import { SearchField } from '@/components/ui/search-field'
/**
* Dashboard shell navigation
*
* Top bar plus persistent sidebar on desktop; the same sidebar becomes a
* drawer below `lg`. Both render from one array, so a new destination appears
* in both places or neither — the class of bug where mobile navigation
* silently falls behind desktop simply cannot occur.
*/
const items = [
{ label: 'Overview', href: '/starters/admin/preview', icon: LayoutDashboard },
{ label: 'Users', href: '/starters/admin/preview/users', icon: Users, badge: '18' },
{ label: 'Orders', href: '/starters/admin/preview/orders', icon: ShoppingCart, badge: '4' },
{ label: 'Analytics', href: '/starters/admin/preview/analytics', icon: BarChart3 },
{ label: 'Settings', href: '/starters/admin/preview/settings', icon: Settings },
]
function NavList({ activeHref }: { activeHref: string }) {
return (
<ul className="flex flex-col gap-0.5">
{items.map((item) => {
const Icon = item.icon
const active = item.href === activeHref
return (
<li key={item.href}>
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex min-h-9 items-center gap-2.5 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',
)}
>
<Icon className="size-4 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.badge ? <Badge size="sm">{item.badge}</Badge> : null}
</Link>
</li>
)
})}
</ul>
)
}
export default function DashboardNavigation() {
const [drawerOpen, setDrawerOpen] = useState(false)
const activeHref = '/starters/admin/preview/users'
return (
<div className="flex min-h-96 w-full flex-col bg-canvas">
<header className="flex h-14 items-center gap-3 border-b border-line bg-surface px-4">
<button
type="button"
onClick={() => setDrawerOpen(true)}
aria-expanded={drawerOpen}
className="flex size-9 items-center justify-center rounded-md text-ink-muted hover:bg-surface-sunken lg:hidden"
>
<Menu className="size-5" aria-hidden="true" />
<span className="sr-only">Open navigation</span>
</button>
<Link href="/starters/admin/preview" className="flex items-center gap-2 text-ink-strong">
<BrandMark className="size-5 text-accent" />
<span className="display-type text-sm font-semibold">Console</span>
</Link>
<div className="ml-auto hidden w-64 md:block">
<SearchField
fieldSize="sm"
placeholder="Search records"
aria-label="Search records"
shortcutHint="⌘K"
/>
</div>
<button
type="button"
className="relative flex size-9 shrink-0 items-center justify-center rounded-md text-ink-muted hover:bg-surface-sunken"
>
<Bell className="size-4.5" aria-hidden="true" />
<span
className="absolute top-1.5 right-1.5 size-2 rounded-full bg-danger"
aria-hidden="true"
/>
<span className="sr-only">Notifications, 3 unread</span>
</button>
<Dropdown
align="end"
label="Account"
groups={[
{
items: [
{ id: 'profile', label: 'Profile', href: '/starters/admin/preview/settings' },
{ id: 'prefs', label: 'Preferences', href: '/starters/admin/preview/settings' },
],
},
{ items: [{ id: 'signout', label: 'Sign out', href: '/starters/saas/preview/login' }] },
]}
trigger={(props) => (
<button
type="button"
className="flex shrink-0 items-center gap-1.5 rounded-md p-0.5 hover:bg-surface-sunken"
{...props}
>
<Avatar name="Priya Raman" size="sm" decorative />
<ChevronDown className="size-3.5 text-ink-subtle" aria-hidden="true" />
<span className="sr-only">Account menu</span>
</button>
)}
/>
</header>
<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">
<nav aria-label="Dashboard">
<NavList activeHref={activeHref} />
</nav>
</aside>
<div className="min-w-0 flex-1 p-6">
<h2 className="text-lg font-semibold text-ink-strong">Users</h2>
<p className="mt-2 max-w-prose text-sm text-ink-muted">
The sidebar and the mobile drawer render from the same array, so a new destination can
never appear in one and not the other.
</p>
</div>
</div>
<Drawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
side="left"
size="sm"
title="Navigation"
>
<nav aria-label="Dashboard, mobile" className="p-3">
<NavList activeHref={activeHref} />
</nav>
</Drawer>
</div>
)
}
components/ui/drawer.tsx
'use client'
import { useId, useRef, type ReactNode } from 'react'
import { X } from 'lucide-react'
import { cn } from '@/lib/cn'
import { useFocusTrap } from '@/hooks/use-focus-trap'
import { useDismiss, useScrollLock } from '@/hooks/use-dismiss'
import { Portal } from './portal'
import { Button } from './button'
/**
* Drawer
*
* The same modal contract as Dialog — trap, Escape, scroll lock, focus return
* — but anchored to an edge. Used for mobile navigation, filter panels and
* record detail views where the underlying context should stay visible.
*/
export interface DrawerProps {
open: boolean
onClose: () => void
title: string
description?: string
side?: 'left' | 'right' | 'bottom'
size?: 'sm' | 'md' | 'lg'
children?: ReactNode
footer?: ReactNode
/** Renders no header chrome — the caller supplies its own. */
bare?: boolean
}
const sideClasses = {
left: 'inset-y-0 left-0 h-full animate-slide-left border-r',
right: 'inset-y-0 right-0 h-full animate-slide-right border-l',
bottom: 'inset-x-0 bottom-0 max-h-[85vh] w-full animate-slide-up border-t rounded-t-xl',
} as const
const sizeClasses = {
sm: 'w-full max-w-xs',
md: 'w-full max-w-sm',
lg: 'w-full max-w-md',
} as const
export function Drawer({
open,
onClose,
title,
description,
side = 'right',
size = 'md',
children,
footer,
bare = false,
}: DrawerProps) {
const panelRef = useRef<HTMLDivElement>(null)
const uid = useId()
const titleId = `${uid}-title`
const descriptionId = description ? `${uid}-description` : undefined
useFocusTrap(panelRef, open)
useScrollLock(open)
useDismiss([panelRef], open, onClose)
if (!open) return null
return (
<Portal>
<div className="fixed inset-0 z-[100]">
<div className="animate-fade-in absolute inset-0 bg-scrim" aria-hidden="true" />
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
tabIndex={-1}
className={cn(
'absolute flex flex-col border-line bg-surface-raised shadow-overlay',
sideClasses[side],
side !== 'bottom' && sizeClasses[size],
)}
>
<div
className={cn(
'flex items-start justify-between gap-4 border-b border-line-subtle px-4 py-3',
bare && 'sr-only',
)}
>
<div className="min-w-0">
<h2 id={titleId} className="text-sm font-semibold text-ink-strong">
{title}
</h2>
{description ? (
<p id={descriptionId} className="mt-0.5 text-xs text-ink-muted">
{description}
</p>
) : null}
</div>
{!bare ? (
<Button variant="ghost" size="icon-sm" onClick={onClose} aria-label="Close panel">
<X className="size-4" aria-hidden="true" />
</Button>
) : null}
</div>
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto">{children}</div>
{footer ? (
<div className="flex items-center gap-2 border-t border-line-subtle bg-surface-sunken px-4 py-3">
{footer}
</div>
) : null}
</div>
</div>
</Portal>
)
}
components/ui/dropdown.tsx
'use client'
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
import Link from 'next/link'
import { cn } from '@/lib/cn'
import { useDismiss } from '@/hooks/use-dismiss'
/**
* Dropdown menu
*
* The ARIA menu-button pattern: the trigger owns `aria-haspopup="menu"` and
* `aria-expanded`, the panel is a `role="menu"` and roving focus moves between
* `role="menuitem"` children with the arrow keys.
*
* Opening with ArrowUp focuses the last item, opening with ArrowDown or Enter
* focuses the first — the small detail that makes a menu feel native.
*/
export interface DropdownItem {
id: string
label: ReactNode
href?: string
onSelect?: () => void
icon?: ReactNode
/** Right-aligned hint, e.g. a keyboard shortcut. */
meta?: ReactNode
disabled?: boolean
tone?: 'default' | 'danger'
}
export interface DropdownGroup {
label?: string
items: DropdownItem[]
}
export interface DropdownProps {
trigger: (props: {
'aria-expanded': boolean
'aria-haspopup': 'menu'
'aria-controls': string
onClick: () => void
onKeyDown: (event: KeyboardEvent) => void
ref: React.Ref<HTMLButtonElement>
}) => ReactNode
groups: DropdownGroup[]
align?: 'start' | 'end'
label?: string
className?: string
menuClassName?: string
}
export function Dropdown({
trigger,
groups,
align = 'start',
label = 'Menu',
className,
menuClassName,
}: DropdownProps) {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const itemRefs = useRef<Array<HTMLAnchorElement | HTMLButtonElement | null>>([])
const uid = useId()
const menuId = `${uid}-menu`
const flatItems = groups.flatMap((group) => group.items).filter((item) => !item.disabled)
const close = (focusTrigger = true) => {
setOpen(false)
if (focusTrigger) triggerRef.current?.focus()
}
useDismiss([rootRef], open, () => close(false))
const openAt = (position: 'first' | 'last') => {
setOpen(true)
requestAnimationFrame(() => {
const index = position === 'first' ? 0 : flatItems.length - 1
itemRefs.current[index]?.focus()
})
}
const onTriggerKeyDown = (event: KeyboardEvent) => {
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openAt('first')
} else if (event.key === 'ArrowUp') {
event.preventDefault()
openAt('last')
}
}
const onItemKeyDown = (event: KeyboardEvent, index: number) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
itemRefs.current[(index + 1) % flatItems.length]?.focus()
} else if (event.key === 'ArrowUp') {
event.preventDefault()
itemRefs.current[(index - 1 + flatItems.length) % flatItems.length]?.focus()
} else if (event.key === 'Home') {
event.preventDefault()
itemRefs.current[0]?.focus()
} else if (event.key === 'End') {
event.preventDefault()
itemRefs.current[flatItems.length - 1]?.focus()
} else if (event.key === 'Tab') {
close(false)
}
}
// Index every enabled item up front. Mutating a counter while rendering
// would work today and break the moment rendering is interrupted.
const itemIndex = new Map<string, number>()
flatItems.forEach((item, index) => itemIndex.set(item.id, index))
return (
<div ref={rootRef} className={cn('relative inline-block', className)}>
{trigger({
'aria-expanded': open,
'aria-haspopup': 'menu',
'aria-controls': menuId,
onClick: () => (open ? close() : openAt('first')),
onKeyDown: onTriggerKeyDown,
ref: triggerRef,
})}
{open ? (
<div
id={menuId}
role="menu"
aria-label={label}
className={cn(
'animate-scale-in absolute z-50 mt-1.5 min-w-56 rounded-lg border border-line bg-surface-raised p-1 shadow-md',
align === 'end' ? 'right-0' : 'left-0',
menuClassName,
)}
>
{groups.map((group, groupIndex) => (
<div
key={group.label ?? `group-${groupIndex}`}
role="group"
aria-label={group.label}
className={cn(groupIndex > 0 && 'mt-1 border-t border-line-subtle pt-1')}
>
{group.label ? (
<p className="label-caps px-2.5 py-1.5 text-ink-subtle">{group.label}</p>
) : null}
{group.items.map((item) => {
const index = itemIndex.get(item.id) ?? -1
const itemClass = cn(
'flex w-full items-center gap-2.5 rounded-sm px-2.5 py-1.5 text-left text-sm transition-colors duration-150',
item.disabled
? 'cursor-not-allowed text-ink-subtle opacity-60'
: item.tone === 'danger'
? 'text-danger hover:bg-danger-soft'
: 'text-ink hover:bg-surface-sunken',
)
if (item.href && !item.disabled) {
return (
<Link
key={item.id}
href={item.href}
role="menuitem"
tabIndex={-1}
ref={(el) => {
itemRefs.current[index] = el
}}
onKeyDown={(event) => onItemKeyDown(event, index)}
onClick={() => close(false)}
className={itemClass}
>
{item.icon}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.meta ? (
<span className="shrink-0 font-mono text-2xs text-ink-subtle">
{item.meta}
</span>
) : null}
</Link>
)
}
return (
<button
key={item.id}
type="button"
role="menuitem"
tabIndex={-1}
disabled={item.disabled}
ref={(el) => {
if (!item.disabled) itemRefs.current[index] = el
}}
onKeyDown={(event) => onItemKeyDown(event, index)}
onClick={() => {
item.onSelect?.()
close()
}}
className={itemClass}
>
{item.icon}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.meta ? (
<span className="shrink-0 font-mono text-2xs text-ink-subtle">
{item.meta}
</span>
) : null}
</button>
)
})}
</div>
))}
</div>
) : null}
</div>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
The application shell used by the Admin starter. Desktop and mobile navigation render from one array, so the class of bug where mobile silently falls behind desktop cannot occur.
- The notification dot is paired with a visually hidden count, never left as colour alone.
- The drawer closes on navigation; the Drawer primitive handles focus return.
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.
- Desktop sidebar
- Mobile drawer
- Notification indicator
- Account menu
Accessibility
- One source, two renderers
- A shared `NavList` guarantees parity between the sidebar and the drawer.
- Modal drawer
- Focus trap, Escape and scroll lock come from the Drawer primitive.
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 navigationAdmin 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 variantsCollapsible sidebar
An icon rail that expands to labels — and re-labels itself with tooltips when collapsed.
intermediate4 variantsDashboard metric row
Four KPI tiles, two of them with inverted trend semantics.
starterFeatured3 variants