Skip to content

Dialog

A modal with focus trapping, scroll locking, Escape dismissal and focus return — in four sizes.

OverlayadvancedFeaturedmodaloverlayfocus-trapdialog

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