Skip to content

Collapsible sidebar

An icon rail that expands to labels — and re-labels itself with tooltips when collapsed.

Shellsintermediatesidebarrailcollapseapplicationtooltip

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