Skip to content

Dashboard shell

Top bar plus persistent sidebar, with the same destinations rendered into a drawer below lg.

ShellsintermediateFeatureddashboardshelldrawerapplicationsearch

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 {
  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.