Drawer
An edge-anchored modal — left, right or bottom — sharing the dialog’s focus and scroll contract.
Live preview
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.
Related
All componentsDialog
A modal with focus trapping, scroll locking, Escape dismissal and focus return — in four sizes.
advancedFeatured5 variantsMobile-first tab bar
A bottom tab bar with safe-area padding, promoted to a horizontal top bar at md.
starter3 variantsPopover
A non-modal layer with six CSS placements — the page behind stays interactive.
intermediate3 variants