Skip to content

Drawer

An edge-anchored modal — left, right or bottom — sharing the dialog’s focus and scroll contract.

Overlayintermediatedrawersheetmobile-navfiltersoverlay

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

'use client'

import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/choice'
import { Drawer } from '@/components/ui/drawer'
import { Fieldset } from '@/components/ui/field'
import { DemoRow, DemoStage } from './_kit'

export default function DrawerDemo() {
  const [side, setSide] = useState<'left' | 'right' | 'bottom' | null>(null)

  return (
    <DemoStage>
      <DemoRow label="Edges" description="Same modal contract as Dialog, anchored to an edge.">
        <Button variant="outline" onClick={() => setSide('left')}>
          From the left
        </Button>
        <Button variant="outline" onClick={() => setSide('right')}>
          From the right
        </Button>
        <Button variant="outline" onClick={() => setSide('bottom')}>
          From the bottom
        </Button>
      </DemoRow>

      <Drawer
        open={side !== null}
        onClose={() => setSide(null)}
        side={side ?? 'right'}
        title="Filter deployments"
        description="Filters apply as you change them."
        footer={
          <>
            <Button variant="ghost" size="sm" onClick={() => setSide(null)}>
              Reset
            </Button>
            <Button size="sm" className="ml-auto" onClick={() => setSide(null)}>
              Apply
            </Button>
          </>
        }
      >
        <div className="flex flex-col gap-5 p-4">
          <Fieldset legend="Status" name="drawer-status">
            <Checkbox
              id="drawer-success"
              name="drawer-status"
              value="success"
              defaultChecked
              label="Succeeded"
            />
            <Checkbox
              id="drawer-failed"
              name="drawer-status"
              value="failed"
              defaultChecked
              label="Failed"
            />
            <Checkbox id="drawer-queued" name="drawer-status" value="queued" label="Queued" />
          </Fieldset>
          <Fieldset legend="Environment" name="drawer-env">
            <Checkbox
              id="drawer-prod"
              name="drawer-env"
              value="prod"
              defaultChecked
              label="Production"
            />
            <Checkbox id="drawer-staging" name="drawer-env" value="staging" label="Staging" />
          </Fieldset>
        </div>
      </Drawer>
    </DemoStage>
  )
}

components/demos/drawer.tsx

'use client'

import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/choice'
import { Drawer } from '@/components/ui/drawer'
import { Fieldset } from '@/components/ui/field'
import { DemoRow, DemoStage } from './_kit'

export default function DrawerDemo() {
  const [side, setSide] = useState<'left' | 'right' | 'bottom' | null>(null)

  return (
    <DemoStage>
      <DemoRow label="Edges" description="Same modal contract as Dialog, anchored to an edge.">
        <Button variant="outline" onClick={() => setSide('left')}>
          From the left
        </Button>
        <Button variant="outline" onClick={() => setSide('right')}>
          From the right
        </Button>
        <Button variant="outline" onClick={() => setSide('bottom')}>
          From the bottom
        </Button>
      </DemoRow>

      <Drawer
        open={side !== null}
        onClose={() => setSide(null)}
        side={side ?? 'right'}
        title="Filter deployments"
        description="Filters apply as you change them."
        footer={
          <>
            <Button variant="ghost" size="sm" onClick={() => setSide(null)}>
              Reset
            </Button>
            <Button size="sm" className="ml-auto" onClick={() => setSide(null)}>
              Apply
            </Button>
          </>
        }
      >
        <div className="flex flex-col gap-5 p-4">
          <Fieldset legend="Status" name="drawer-status">
            <Checkbox
              id="drawer-success"
              name="drawer-status"
              value="success"
              defaultChecked
              label="Succeeded"
            />
            <Checkbox
              id="drawer-failed"
              name="drawer-status"
              value="failed"
              defaultChecked
              label="Failed"
            />
            <Checkbox id="drawer-queued" name="drawer-status" value="queued" label="Queued" />
          </Fieldset>
          <Fieldset legend="Environment" name="drawer-env">
            <Checkbox
              id="drawer-prod"
              name="drawer-env"
              value="prod"
              defaultChecked
              label="Production"
            />
            <Checkbox id="drawer-staging" name="drawer-env" value="staging" label="Staging" />
          </Fieldset>
        </div>
      </Drawer>
    </DemoStage>
  )
}

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>
  )
}

hooks/use-focus-trap.ts

'use client'

import { useEffect, type RefObject } from 'react'

const FOCUSABLE = [
  'a[href]',
  'button:not([disabled])',
  'input:not([disabled]):not([type="hidden"])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  '[tabindex]:not([tabindex="-1"])',
  '[contenteditable="true"]',
].join(',')

export function getFocusable(container: HTMLElement): HTMLElement[] {
  return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
    (el) => el.offsetParent !== null || el.getClientRects().length > 0,
  )
}

/**
 * Traps Tab focus inside `containerRef` while `active`, and returns focus to
 * whatever was focused before activation on teardown.
 *
 * Both halves matter: trapping without restoring strands keyboard users at the
 * top of the document every time a dialog closes.
 */
export function useFocusTrap(
  containerRef: RefObject<HTMLElement | null>,
  active: boolean,
  options: { initialFocus?: RefObject<HTMLElement | null>; returnFocus?: boolean } = {},
): void {
  const { initialFocus, returnFocus = true } = options

  useEffect(() => {
    if (!active) return
    const container = containerRef.current
    if (!container) return

    const previouslyFocused = document.activeElement as HTMLElement | null

    const focusFirst = () => {
      const target = initialFocus?.current ?? getFocusable(container)[0] ?? container
      target.focus({ preventScroll: true })
    }

    // Defer one frame so the element is painted and measurable before focusing.
    const raf = requestAnimationFrame(focusFirst)

    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key !== 'Tab') return
      const focusable = getFocusable(container)
      if (focusable.length === 0) {
        event.preventDefault()
        container.focus({ preventScroll: true })
        return
      }
      const first = focusable[0]
      const last = focusable[focusable.length - 1]
      if (!first || !last) return
      const activeEl = document.activeElement

      if (event.shiftKey && (activeEl === first || !container.contains(activeEl))) {
        event.preventDefault()
        last.focus()
      } else if (!event.shiftKey && activeEl === last) {
        event.preventDefault()
        first.focus()
      }
    }

    document.addEventListener('keydown', onKeyDown, true)

    return () => {
      cancelAnimationFrame(raf)
      document.removeEventListener('keydown', onKeyDown, true)
      if (returnFocus && previouslyFocused && document.contains(previouslyFocused)) {
        previouslyFocused.focus({ preventScroll: true })
      }
    }
  }, [active, containerRef, initialFocus, returnFocus])
}

hooks/use-dismiss.ts

'use client'

import { useEffect, type RefObject } from 'react'

/**
 * Closes a layer on Escape or on a pointer press outside it.
 *
 * `pointerdown` is used rather than `click` so that dragging a text selection
 * out of a popover does not dismiss it, and Escape is captured on the document
 * so it works even when focus has moved into a nested portal.
 */
export function useDismiss(
  refs: Array<RefObject<HTMLElement | null>>,
  active: boolean,
  onDismiss: () => void,
  options: { escape?: boolean; outside?: boolean } = {},
): void {
  const { escape = true, outside = true } = options

  useEffect(() => {
    if (!active) return

    const onKeyDown = (event: KeyboardEvent) => {
      if (!escape) return
      if (event.key === 'Escape') {
        event.stopPropagation()
        onDismiss()
      }
    }

    const onPointerDown = (event: PointerEvent) => {
      if (!outside) return
      const target = event.target as Node | null
      if (!target) return
      const inside = refs.some((ref) => ref.current?.contains(target))
      if (!inside) onDismiss()
    }

    document.addEventListener('keydown', onKeyDown)
    document.addEventListener('pointerdown', onPointerDown)
    return () => {
      document.removeEventListener('keydown', onKeyDown)
      document.removeEventListener('pointerdown', onPointerDown)
    }
    // `refs` is a stable-length array supplied by the caller at each layer.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [active, escape, outside, onDismiss])
}

/** Locks body scroll while a modal layer is open, without layout shift. */
export function useScrollLock(active: boolean): void {
  useEffect(() => {
    if (!active) return
    const { body, documentElement } = document
    const scrollBarWidth = window.innerWidth - documentElement.clientWidth
    const previousOverflow = body.style.overflow
    const previousPadding = body.style.paddingRight

    body.style.overflow = 'hidden'
    if (scrollBarWidth > 0) body.style.paddingRight = `${scrollBarWidth}px`

    return () => {
      body.style.overflow = previousOverflow
      body.style.paddingRight = previousPadding
    }
  }, [active])
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

When the underlying context should stay visible: mobile navigation, filter panels, record detail. The bottom variant is the mobile sheet used throughout the starters.

  • Filters belong in a drawer on mobile and inline on desktop — the same state, two presentations.
  • `bare` hides the header chrome while keeping the accessible name, for custom navigation panels.
  • Bottom drawers cap at 85vh so the page behind is never fully hidden.

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.

  • Left
  • Right
  • Bottom sheet
  • Bare — caller supplies its own chrome
  • Three widths

Accessibility

Modality
Same contract as Dialog: trap, Escape, scroll lock, focus return.
Naming
The title is always present in the accessibility tree, even in `bare` mode.

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.