Dialog
A modal with focus trapping, scroll locking, Escape dismissal and focus return — in four sizes.
Live preview
Source
The exact file rendered in the preview above.
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog } from '@/components/ui/dialog'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { DemoRow, DemoStage } from './_kit'
export default function DialogDemo() {
const [basic, setBasic] = useState(false)
const [form, setForm] = useState(false)
const [scroll, setScroll] = useState(false)
return (
<DemoStage>
<DemoRow
label="Variants"
description="Focus is trapped, Escape closes, and focus returns to the trigger."
>
<Button variant="outline" onClick={() => setBasic(true)}>
Message dialog
</Button>
<Button variant="outline" onClick={() => setForm(true)}>
Form dialog
</Button>
<Button variant="outline" onClick={() => setScroll(true)}>
Scrolling body
</Button>
</DemoRow>
<Dialog
open={basic}
onClose={() => setBasic(false)}
title="Invitation sent"
description="Priya Raman will receive an email with a join link."
size="sm"
footer={<Button onClick={() => setBasic(false)}>Done</Button>}
>
<p className="text-ink-muted">
The link expires in seven days. You can revoke it at any time from the Team page.
</p>
</Dialog>
<Dialog
open={form}
onClose={() => setForm(false)}
title="Create workspace"
description="Workspaces isolate projects, members and billing."
footer={
<>
<Button variant="outline" onClick={() => setForm(false)}>
Cancel
</Button>
<Button onClick={() => setForm(false)}>Create workspace</Button>
</>
}
>
<div className="flex flex-col gap-4">
<Field name="dialog-name" label="Workspace name" required>
{(field) => <Input {...field} placeholder="Acme Platform" />}
</Field>
<Field name="dialog-slug" label="Slug" hint="Used in URLs. Lowercase and hyphens only.">
{(field) => <Input {...field} placeholder="acme-platform" />}
</Field>
</div>
</Dialog>
<Dialog
open={scroll}
onClose={() => setScroll(false)}
title="Terms of the demo"
size="xl"
footer={<Button onClick={() => setScroll(false)}>Close</Button>}
>
<div className="flex flex-col gap-3 text-ink-muted">
{Array.from({ length: 12 }).map((_, index) => (
<p key={index}>
Section {index + 1}. Foundry is a template demonstration. Nothing on this page submits
data, and the body of a dialog scrolls independently while the header and footer stay
pinned.
</p>
))}
</div>
</Dialog>
</DemoStage>
)
}
components/demos/dialog.tsx
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog } from '@/components/ui/dialog'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { DemoRow, DemoStage } from './_kit'
export default function DialogDemo() {
const [basic, setBasic] = useState(false)
const [form, setForm] = useState(false)
const [scroll, setScroll] = useState(false)
return (
<DemoStage>
<DemoRow
label="Variants"
description="Focus is trapped, Escape closes, and focus returns to the trigger."
>
<Button variant="outline" onClick={() => setBasic(true)}>
Message dialog
</Button>
<Button variant="outline" onClick={() => setForm(true)}>
Form dialog
</Button>
<Button variant="outline" onClick={() => setScroll(true)}>
Scrolling body
</Button>
</DemoRow>
<Dialog
open={basic}
onClose={() => setBasic(false)}
title="Invitation sent"
description="Priya Raman will receive an email with a join link."
size="sm"
footer={<Button onClick={() => setBasic(false)}>Done</Button>}
>
<p className="text-ink-muted">
The link expires in seven days. You can revoke it at any time from the Team page.
</p>
</Dialog>
<Dialog
open={form}
onClose={() => setForm(false)}
title="Create workspace"
description="Workspaces isolate projects, members and billing."
footer={
<>
<Button variant="outline" onClick={() => setForm(false)}>
Cancel
</Button>
<Button onClick={() => setForm(false)}>Create workspace</Button>
</>
}
>
<div className="flex flex-col gap-4">
<Field name="dialog-name" label="Workspace name" required>
{(field) => <Input {...field} placeholder="Acme Platform" />}
</Field>
<Field name="dialog-slug" label="Slug" hint="Used in URLs. Lowercase and hyphens only.">
{(field) => <Input {...field} placeholder="acme-platform" />}
</Field>
</div>
</Dialog>
<Dialog
open={scroll}
onClose={() => setScroll(false)}
title="Terms of the demo"
size="xl"
footer={<Button onClick={() => setScroll(false)}>Close</Button>}
>
<div className="flex flex-col gap-3 text-ink-muted">
{Array.from({ length: 12 }).map((_, index) => (
<p key={index}>
Section {index + 1}. Foundry is a template demonstration. Nothing on this page submits
data, and the body of a dialog scrolls independently while the header and footer stay
pinned.
</p>
))}
</div>
</Dialog>
</DemoStage>
)
}
components/ui/dialog.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'
/**
* Dialog
*
* A modal built on the ARIA dialog pattern rather than `<dialog>`, because the
* native element still varies too much across engines in how it handles
* scroll locking, backdrop styling and Escape inside nested layers.
*
* The contract implemented here: focus moves in on open, is trapped while
* open, Escape and backdrop press close, body scroll is locked, and focus
* returns to the trigger on close.
*/
export interface DialogProps {
open: boolean
onClose: () => void
title: string
description?: string
children?: ReactNode
footer?: ReactNode
size?: 'sm' | 'md' | 'lg' | 'xl'
/** Hides the corner close button when the dialog demands an explicit choice. */
hideClose?: boolean
/** Blocks backdrop dismissal for destructive confirmations. */
staticBackdrop?: boolean
}
const sizes = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-2xl',
} as const
export function Dialog({
open,
onClose,
title,
description,
children,
footer,
size = 'md',
hideClose = false,
staticBackdrop = false,
}: DialogProps) {
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, { outside: !staticBackdrop })
if (!open) return null
return (
<Portal>
<div className="fixed inset-0 z-[100] flex items-end justify-center p-4 sm:items-center">
<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(
'animate-scale-in relative flex max-h-[85vh] w-full flex-col overflow-hidden rounded-xl border border-line bg-surface-raised shadow-overlay',
sizes[size],
)}
>
<div className="flex items-start justify-between gap-4 border-b border-line-subtle px-5 py-4">
<div className="min-w-0">
<h2 id={titleId} className="text-md font-semibold text-ink-strong">
{title}
</h2>
{description ? (
<p id={descriptionId} className="mt-1 text-sm text-ink-muted">
{description}
</p>
) : null}
</div>
{!hideClose ? (
<Button variant="ghost" size="icon-sm" onClick={onClose} aria-label="Close dialog">
<X className="size-4" aria-hidden="true" />
</Button>
) : null}
</div>
{children ? (
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto px-5 py-4 text-sm">
{children}
</div>
) : null}
{footer ? (
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-line-subtle bg-surface-sunken px-5 py-3">
{footer}
</div>
) : null}
</div>
</div>
</Portal>
)
}
export interface ConfirmDialogProps {
open: boolean
onClose: () => void
onConfirm: () => void
title: string
description: string
confirmLabel?: string
cancelLabel?: string
tone?: 'danger' | 'accent'
loading?: boolean
/** Requires typing this exact string before confirming — for irreversible acts. */
confirmPhrase?: string
}
/**
* ConfirmDialog
*
* A destructive-action guard. The backdrop is static and the confirm button is
* never the initially focused element, so a stray Enter cannot delete anything.
*/
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
tone = 'danger',
loading = false,
}: ConfirmDialogProps) {
return (
<Dialog
open={open}
onClose={onClose}
title={title}
description={description}
size="sm"
staticBackdrop
footer={
<>
<Button variant="outline" onClick={onClose} disabled={loading}>
{cancelLabel}
</Button>
<Button
variant={tone === 'danger' ? 'destructive' : 'primary'}
onClick={onConfirm}
loading={loading}
>
{confirmLabel}
</Button>
</>
}
/>
)
}
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])
}
components/ui/portal.tsx
'use client'
import { useSyncExternalStore, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
/**
* Portal
*
* Renders children into `document.body` so overlays escape any ancestor that
* creates a stacking or containing block — a transformed card, a `contain`ed
* preview frame, an `overflow: hidden` shell.
*
* Returns `null` until mounted, which keeps the server and first client render
* identical and avoids a hydration mismatch.
*/
const subscribe = () => () => {}
export function Portal({ children }: { children: ReactNode }) {
// `useSyncExternalStore` is the idiomatic "am I hydrated" check: the server
// snapshot is false and the client snapshot is true, with no effect and no
// extra render pass.
const mounted = useSyncExternalStore(
subscribe,
() => true,
() => false,
)
if (!mounted) return null
return createPortal(children, document.body)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
For a decision or a short task that must be completed before continuing. Built on the ARIA dialog pattern rather than `<dialog>`, because engines still differ too much on scroll locking, backdrop styling and Escape in nested layers.
- Only the body scrolls; the header and footer stay pinned so the primary action is always reachable.
- Use `staticBackdrop` when an accidental outside click would lose work.
- Do not nest dialogs. If a dialog needs a dialog, it needs a page.
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.
- Message
- Form
- Scrolling body with pinned header and footer
- Static backdrop
- Four widths
Accessibility
- Focus
- Focus moves in on open, is trapped while open, and returns to the trigger on close.
- Naming
- `aria-modal`, `aria-labelledby` and `aria-describedby` are wired from the title and description props.
- Scroll lock
- The body is locked with scrollbar-width compensation, so the page behind does not shift.
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 componentsDrawer
An edge-anchored modal — left, right or bottom — sharing the dialog’s focus and scroll contract.
intermediate5 variantsConfirmation
A destructive-action guard with a static backdrop, a pending state and a confirm button that is never focused first.
starter4 variantsPopover
A non-modal layer with six CSS placements — the page behind stays interactive.
intermediate3 variants