Dialog
Primitive

Dialog

Headless modal dialog primitives with focus containment, dismissal, accessible naming, and compound state management.

Live behavior

Dialog in motion

Interactive
Atom behavior · App-owned appearance

Open the dialog, move through its focus scope, then close it.

waiting for input

When to Use

Use Dialog when a task or piece of information must appear above the page and the user should finish or dismiss it before returning. Use AlertDialog for a short, urgent decision such as confirming deletion. Use Drawer when the same modal behavior should be identified as a side sheet, and Popover for a small non-modal layer attached to a control.

Features

The behavior Atom owns before your product adds appearance.

  • Supports controlled and uncontrolled open state with close reasons.
  • Traps focus while open, restores focus after close, and locks page scrolling.
  • Supports Escape and backdrop dismissal independently.
  • Closes nested dismissable layers before their parent dialog.
  • Registers portalled descendant layers in the dialog focus scope.
  • Supports keep-mounted content for consumer-owned exit animation.
  • Provides generated title and description relationships.

Import

tsx
import { Dialog } from "@flowstack-ui/atom";

Anatomy

tsx
<Dialog.Root>
  <Dialog.Trigger />
  <Dialog.Portal>
    <Dialog.Overlay />
    <Dialog.Content>
      <Dialog.Title />
      <Dialog.Description />
      <Dialog.Close />
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>

API Reference

Root

Owns open state, generated IDs, dismissal options, and the trigger reference. It renders no wrapper element.

PropTypeDefault
openboolean-
defaultOpenbooleanfalse
onOpenChange(open: boolean, reason?: ModalCloseReason) => void-
closeOnEscapebooleantrue
closeOnBackdropClickbooleantrue
disabledbooleanfalse
keepMountedbooleanfalse

Close reasons include "backdropClick", "closeClick", and "escapeKeyDown" for Dialog interactions.

Trigger

Opens the dialog and receives the generated relationship to Content. It is a native button by default; custom elements receive button semantics.

PropTypeDefault
asChildbooleanfalse
renderRenderProp-
ARIA attributeValues
role"button" for custom elements
aria-haspopup"dialog"
aria-expandedCurrent open state
aria-controlsContent ID while open
aria-disabled"true" when Root is disabled
Data attributeValues
[data-slot]"dialog-trigger"
[data-state]"open" | "closed"
[data-disabled]Present when disabled

Portal

Moves its children to document.body by default without adding a wrapper.

PropTypeDefault
containerHTMLElement | nulldocument.body
disabledbooleanfalse

The container must be an HTMLElement in the current document. ShadowRoot, DocumentFragment, and cross-document containers are unsupported. Atom keeps the ancestor paths to separate Overlay and Content portals, inline Content, nested same-document containers, and registered branches active while making background subtrees inert.

Overlay

Creates the backdrop and requests a backdropClick close when clicked. Its own disabled prop suppresses that request without disabling the whole Dialog. Overlay and Content must be siblings: Content nested beneath Overlay would be inside an aria-hidden subtree and Atom rejects that composition. Clicks that bubble from Overlay descendants do not dismiss the Dialog; only a click whose target is Overlay itself is a backdrop click. Separate portals are valid when the committed Content DOM is outside Overlay.

PropTypeDefault
disabledbooleanfalse
ARIA attributeValues
aria-hidden"true"
Data attributeValues
[data-slot]"dialog-overlay"
[data-state]"open" | "closed"
[data-positioned]Present after the opening frames

Content

Renders the focus-trapped dialog panel as a div. Registered Title and Description parts provide generated relationships unless explicit native ARIA is supplied.

PropTypeDefault
aria-labelstring-
aria-labelledbystringgenerated from Title when present
aria-describedbystring | undefinedgenerated from Description when present
ariaLabelstringcompatibility fallback
initialFocusModalFocusTarget<ModalInitialFocusDetails>interaction-aware default
finalFocusModalFocusTarget<ModalFinalFocusDetails>prior focus, then Trigger
role"dialog" | "alertdialog""dialog"
ARIA attributeValues
roleValue from role
aria-modal"true" while open
aria-hidden"true" while retained only for exit presence
inertPresent while retained only for exit presence
aria-labelExplicit native value, otherwise ariaLabel compatibility value
aria-labelledbyExplicit native value, otherwise registered Title ID
aria-describedbyExplicit native value, otherwise registered Description ID
Data attributeValues
[data-slot]"dialog-content"
[data-state]"open" | "closed"
[data-positioned]Present after the opening frames

With keepMounted, closed Content remains inside a hidden, aria-hidden wrapper and does not expose aria-modal.

If an exit animation keeps Content present after open becomes false, Content immediately becomes inert and accessibility-hidden and loses aria-modal while the visual exit completes. Background isolation, active focus containment, and scroll ownership end at close rather than at animation completion.

Title

Supplies the heading referenced by Content. It renders an h2 by default and accepts native heading props.

PropTypeDefault
as"h1" | "h2" | "h3" | "h4" | "h5" | "h6""h2"
Data attributeValues
[data-slot]"dialog-title"

Description

Supplies the explanatory text referenced by Content. It renders a p and accepts native paragraph props.

Data attributeValues
[data-slot]"dialog-description"

Close

Closes Root with reason "closeClick". It renders a native button by default and supports custom composition.

PropTypeDefault
asChildbooleanfalse
renderRenderProp-
Data attributeValues
[data-slot]"dialog-close"

The Dialog entry point also exports useModalContext and useModalContent for advanced custom modal parts. Prefer the namespaced parts above for ordinary dialogs because they supply the complete focus and ARIA contract.

For consumer-owned third-party content portalled outside Content, wrap its top-level element in <Modal.Branch asChild> or configure the third-party portal to use the Content element as its container. Unregistered body portals are outside the dialog's focus and scroll ownership.

Global Toast and live-region containers are treated as background while the Dialog is open unless they are inside Content or registered with Modal.Branch.

Examples

Edit Profile

tsx
import { Dialog } from "@flowstack-ui/atom";

export function EditProfileDialog() {
  return (
    <Dialog.Root>
      <Dialog.Trigger>Edit profile</Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay />
        <Dialog.Content>
          <Dialog.Title>Edit profile</Dialog.Title>
          <Dialog.Description>Update your public account details.</Dialog.Description>
          <label>
            Display name
            <input name="displayName" />
          </label>
          <Dialog.Close>Done</Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Controlled Close Reasons

tsx
import { useState } from "react";
import { Dialog, type ModalCloseReason } from "@flowstack-ui/atom";

export function ControlledDialog() {
  const [open, setOpen] = useState(false);

  function handleOpenChange(nextOpen: boolean, reason?: ModalCloseReason) {
    setOpen(nextOpen);
    if (!nextOpen && reason) console.log(`Closed by ${reason}`);
  }

  return (
    <Dialog.Root open={open} onOpenChange={handleOpenChange}>
      <Dialog.Trigger>Show details</Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay />
        <Dialog.Content>
          <Dialog.Title>Account details</Dialog.Title>
          <Dialog.Description>Your current account information.</Dialog.Description>
          <Dialog.Close>Close</Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Accessibility

Dialog follows the WAI-ARIA Modal Dialog pattern. Always provide a clear Title or native aria-label; include Description when users need context before acting. ariaLabel remains a compatibility fallback, but native ARIA is preferred and takes precedence. Description is optional and Atom omits aria-describedby when none is registered. If Title or Description is hidden behind an opaque wrapper during server rendering, provide the native relationship explicitly. Focus moves inside when opened, remains within the dialog and its registered descendant portals, then returns to the prior focus target after close.

Nested dialogs share the Modal layer stack. Only the topmost dialog traps focus, handles Escape or Overlay dismissal, and owns active scroll containment. Long Content and registered portalled controls remain scrollable; background wheel and touch movement, including boundary chaining, is suppressed. Atom restores the page's prior body styles and scroll position after close.

On touch opening, the default initial target is Content rather than the first input, avoiding immediate virtual-keyboard activation. Native autoFocus and explicit initialFocus take precedence. Use finalFocus when the next logical workflow target differs from the opener; false suppresses either automatic focus step. The initial callback receives the opening interaction, while the final callback receives the closing interaction and close reason.

KeyDescription
EscapeCloses the topmost dialog when closeOnEscape is enabled.
TabMoves to the next focusable element in the dialog scope.
Shift+TabMoves to the previous focusable element in the dialog scope.
Enter / SpaceOpens from Trigger and activates native button controls.

Changelog

Unreleased

0.24.0

  • Added public Agent Knowledge for component selection, required composition, recurring mistakes, and validation.

0.20.3

  • Inherited document-only overflow locking so sticky application chrome remains anchored while Dialog is open at a nonzero page scroll position.

0.6.7

  • Inherited root/body overflow locking without fixed-body repositioning or unlock-time scroll restoration, avoiding iOS Safari browser-toolbar transitions.

0.3.4

  • Inherited corrected scroll-lock compensation so opening or closing Dialog no longer shifts pages that use scrollbar-gutter: stable.

0.3.2

  • Fixed nested Dialog isolation cleanup so closing the child and parent cannot leave the application root inert and unclickable.

0.3.1

  • Fixed exit-presence cleanup so closed Dialog Content and Overlay unmount after their CSS motion window even when no end event is emitted.

0.3.0

  • Fixed native Content ARIA precedence and optional Description relationships.
  • Added SSR-safe, hydration-stable Title and Description registration.
  • Added Content-level initialFocus and finalFocus, touch-safe default focus, and opening/closing interaction details.
  • Added shared top-layer ownership and Modal.Branch support for third-party dialog portals.
  • Preserved Menu, Select, Popover, and nested-dialog keyboard contracts through metadata-aware modal focus containment.
  • Added long-content and registered-portal scroll allowances with background wheel/touch boundary containment and exact body restoration.
  • Added stack-aware background isolation and same-document HTMLElement portal-container enforcement through the shared Modal foundation.
  • Established shared modal ownership before paint; exit-present Content is inert and accessibility-hidden, nested lock handoff is uninterrupted, and unavailable Tab candidates are skipped.
  • Rejected Content nested beneath Overlay and prevented bubbled descendant clicks from being treated as backdrop dismissal.

0.2.0

  • Added shared dismissable layer Escape handling so nested overlays close before the parent dialog closes.
  • Improved modal focus containment, including support for registered portalled layers owned by descendants inside the dialog.

0.1.0

  • Initial Atom release.