Dialogs, modal and drawers
Focus-aware overlays, confirmation flows and logical-edge sheets.
Quick start
After the application setup, copy this component into your application. It imports only public packages; the complete live demo later on this page also uses documentation layout helpers.
'use client';
import { useState } from 'react';
import { Dialog } from '@fayegram-ai/faye-ui/overlays/dialog';
import { Button } from '@fayegram-ai/faye-ui/controls/button';
export function PlanDialog() {
const [open, setOpen] = useState(false);
return <Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger render={<Button />}>Review your plan</Dialog.Trigger>
<Dialog.Portal><Dialog.Backdrop /><Dialog.Viewport><Dialog.Popup>
<Dialog.Header>
<Dialog.Title>Your next small adventure</Dialog.Title>
<Dialog.Close aria-label="Close plan review" />
<Dialog.Description>Check the details before continuing.</Dialog.Description>
</Dialog.Header>
<Dialog.Body>Your itinerary goes here.</Dialog.Body>
<Dialog.Actions><Dialog.Close render={<Button />}>Done</Dialog.Close></Dialog.Actions>
</Dialog.Popup></Dialog.Viewport></Dialog.Portal>
</Dialog.Root>;
}API reference: Dialog, Button.
Usage contract
Use a title and description appropriate to the task. The theme-aware portal transports visual scope. Do not add another focus trap around Base UI dialogs. Drawer is a positioned modal-sheet composition. SwipeDrawer is a separate Base UI drawer adapter for swipe-to-dismiss, snap points, swipe areas, and optional software-keyboard handling. Its physical swipe direction matches its placement; use Drawer when logical start/end edges without gestures are enough.
Gesture behavior
The gesture implementation belongs to Base UI, not a second Faye UI engine. Keep a visible close control so dragging is never the only way to dismiss. Use Content around selectable text, and data-base-ui-swipe-ignore on interactive descendants that must not participate. Bottom sheets with fields can use VirtualKeyboardProvider. Actual React/Next integration is exercised by the mandatory browser gates. Physical touch-device and assistive-technology qualification still require genuine reviewer evidence.
Customization
Use semantic tokens for visual changes, public component parts for structural customization, and explicit props for instance behavior. The surrounding site lets you compare light and dark, different brands and density.
Source and verification
The examples below import actual public package entries. Full source is downloadable. Source coverage does not establish keyboard, hydration or browser compatibility; those require the application test suite.
Long content and compact screens
Compose Dialog.Header, Dialog.Body and Dialog.Actions inside Dialog.Popup. The popup is capped to the viewport; only the body scrolls, so the title and final action remain visible. Dialog.Viewport centers the popup and handles surrounding space. The long review example demonstrates wheel scrolling inside the body on desktop and narrow screens. Unstructured popup content can still scroll inside the popup. Dialog and AlertDialog lock the background while retaining the existing native scrollbar track and content width, share nested locks per document, and restore the background position on close.
Floating menu, tooltip, popover, preview-card, select, combobox and autocomplete surfaces cap their preferred width against the positioner's available width and the viewport gutter. A wide trigger no longer forces a popup outside the available area. CSS geometry checks use supplied positioner variables; actual collision behavior still requires the React/browser tests.
Modal accepts closeLabel for localization, including through code-bound modal/root settings. Its description and footer retain zero-valued React content rather than placing a bare zero outside their semantic wrappers.
Trigger association and anchors
Modal.trigger accepts the opening control as a React element. Its Dialog.Trigger association supplies opening-event ownership and focus restoration while open/onOpenChange remain controlled. Use compound Dialog triggers or handles for more complex trigger layouts.
Drawer.Popup.edge supports start, end, top and bottom. Logical side edges follow direction. Dialog modal/nonmodal settings, keep-mounted portals, focus and dismissal remain available through the compound parts. Permanent or content-pushing navigation is composed in AppShell/layout; it is not a built-in persistent drawer mode.
Dismiss controls and persistent navigation
Close parts default to an accessible X icon. Supply children/render to keep explicit text actions, and override aria-label for the task. Dialog, alert-dialog and SwipeDrawer.Header title/close frames align on the first text line; descriptions use their own row. Modal.closeProps exposes the same customization. NavigationDrawer provides content-pushing and mini desktop navigation, switching to a modal sheet on narrow viewports. See consumer interactions and navigation.
Coordinated swipe drawers
SwipeDrawer uses the shared document scroll lock while Base UI owns focus, gestures and dismissal. Provider, IndentBackground, and Indent coordinate a background response across drawers. The nested example retains the parent's draft and lock when the child closes. SwipeArea adds a local gesture target alongside the ordinary opening button.
Place VirtualKeyboardProvider inside the relevant SwipeDrawer.Root, surrounding its viewport composition. It requires that drawer's context. Desktop and touch-emulated checks do not certify physical-device keyboard behavior.
Live examples
Try the controls below, then read or copy the complete example to adapt it for your application.
Composed dialog
Scrollable dialog body
Nested scheme and portal inheritance
Alert dialog and convenience modal
Drawers
Gesture and snap-point drawer
Coordinated drawers and swipe activation
The visible buttons provide keyboard and pointer alternatives to swiping. Virtual-keyboard behavior still needs real-device qualification.
Read the complete example
'use client';
import { useState } from 'react';
import { Dialog } from '@fayegram-ai/faye-ui/overlays/dialog';
import { AlertDialog } from '@fayegram-ai/faye-ui/overlays/alert-dialog';
import { Modal } from '@fayegram-ai/faye-ui/overlays/modal';
import { SwipeDrawer } from '@fayegram-ai/faye-ui/overlays/swipe-drawer';
import { Drawer } from '@fayegram-ai/faye-ui/overlays/drawer';
import { ThemeScope } from '@fayegram-ai/faye-ui/provider/provider';
import { Button } from '@fayegram-ai/faye-ui/controls/button';
import { TextField } from '@fayegram-ai/faye-ui/controls/text-field';
import { Inline, Stack } from '@fayegram-ai/faye-ui/layout/layout';
import { Section, Result } from '../example';
export default function Dialogs() {
const [snapPoint, setSnapPoint] = useState<string | number | null>('180px');
const [modal, setModal] = useState(false), [removed, setRemoved] = useState(false);
return <><Section title="Composed dialog"><Dialog.Root><Dialog.Trigger render={<Button />}>Open dialog</Dialog.Trigger><Dialog.Portal keepMounted><Dialog.Backdrop /><Dialog.Popup><Stack gap="4"><Dialog.Title>Workspace details</Dialog.Title><Dialog.Description>Focus remains in the dialog until it closes.</Dialog.Description><TextField label="Name" defaultValue="Northstar"/><Dialog.Close render={<Button />}>Done</Dialog.Close></Stack></Dialog.Popup></Dialog.Portal></Dialog.Root></Section><Section title="Scrollable dialog body"><Dialog.Root><Dialog.Trigger render={<Button />}>Open long dialog</Dialog.Trigger><Dialog.Portal><Dialog.Backdrop /><Dialog.Viewport data-testid="long-dialog-viewport"><Dialog.Popup><Dialog.Header><Dialog.Title data-testid="long-dialog-title">Review every section</Dialog.Title><Dialog.Description>The body scrolls independently while the title and action stay in view.</Dialog.Description></Dialog.Header><Dialog.Body data-testid="long-dialog-body"><TextField label="Reviewer" defaultValue="Design systems"/>{Array.from({ length: 24 }, (_, index) => <p key={index}>Section {index + 1}: Content remains in reading order. Review this item before continuing.</p>)}</Dialog.Body><Dialog.Actions><Dialog.Close render={<Button />} data-testid="long-dialog-close">Finish review</Dialog.Close></Dialog.Actions></Dialog.Popup></Dialog.Viewport></Dialog.Portal></Dialog.Root></Section><Section title="Nested scheme and portal inheritance"><ThemeScope scheme="dark" data-testid="dark-dialog-scope" style={{ padding: '1rem', background: 'var(--faye-ui-color-surface)', color: 'var(--faye-ui-color-text)' }}><Dialog.Root><Dialog.Trigger render={<Button />}>Open dark-scope dialog</Dialog.Trigger><Dialog.Portal><Dialog.Backdrop /><Dialog.Popup data-testid="dark-dialog-popup"><Dialog.Title>Still dark inside the portal</Dialog.Title><Dialog.Description>The portal carries the originating visual scope.</Dialog.Description><Dialog.Close render={<Button />}>Close scoped dialog</Dialog.Close></Dialog.Popup></Dialog.Portal></Dialog.Root></ThemeScope></Section><Section title="Alert dialog and convenience modal"><Inline wrap><AlertDialog.Root><AlertDialog.Trigger render={<Button tone="danger"/>}>Remove local draft</AlertDialog.Trigger><AlertDialog.Portal><AlertDialog.Backdrop /><AlertDialog.Popup><Stack gap="4"><AlertDialog.Title>Remove this example draft?</AlertDialog.Title><AlertDialog.Description>This only changes state in the preview.</AlertDialog.Description><Inline><AlertDialog.Close render={<Button />}>Cancel</AlertDialog.Close><AlertDialog.Close render={<Button tone="danger" variant="solid"/>} onClick={() => setRemoved(true)}>Remove draft</AlertDialog.Close></Inline></Stack></AlertDialog.Popup></AlertDialog.Portal></AlertDialog.Root><Button onClick={() => setModal(true)}>Open modal</Button></Inline><Modal open={modal} onOpenChange={setModal} title="A composed modal" description="A convenience API over Dialog." footer={<Button onClick={() => setModal(false)}>Save and close</Button>}><TextField label="Project title" defaultValue="A quiet interface"/></Modal><Result>{removed ? 'Draft removed locally.' : 'Draft retained.'}</Result></Section><Section title="Drawers"><Inline wrap>{(['start', 'end', 'top', 'bottom'] as const).map(edge => <Drawer.Root key={edge}><Drawer.Trigger render={<Button />}>Open {edge} drawer</Drawer.Trigger><Drawer.Portal><Drawer.Backdrop /><Drawer.Popup edge={edge}><Drawer.Header><Drawer.Title>{edge} drawer</Drawer.Title><Drawer.Close aria-label={`Close ${edge} drawer`}/><Drawer.Description>Logical edges follow text direction.</Drawer.Description></Drawer.Header><Drawer.Body><TextField label="Drawer details" defaultValue="Room for a longer form"/></Drawer.Body></Drawer.Popup></Drawer.Portal></Drawer.Root>)}</Inline></Section><Section title="Gesture and snap-point drawer"><SwipeDrawer.Root swipeDirection="down" snapPoints={['180px', 1]} snapPoint={snapPoint} onSnapPointChange={setSnapPoint}><SwipeDrawer.Trigger render={<Button />}>Open swipe drawer</SwipeDrawer.Trigger><SwipeDrawer.Portal><SwipeDrawer.Backdrop /><SwipeDrawer.Viewport><SwipeDrawer.Popup><SwipeDrawer.Content><Stack gap="4"><SwipeDrawer.Header><SwipeDrawer.Title>Move between snap points</SwipeDrawer.Title><SwipeDrawer.Close aria-label="Close swipe drawer"/><SwipeDrawer.Description>Use the button or drag vertically to resize. The close icon stays in the title row.</SwipeDrawer.Description></SwipeDrawer.Header><Inline><Button onClick={() => setSnapPoint(snapPoint === 1 ? '180px' : 1)}>{snapPoint === 1 ? 'Collapse drawer' : 'Expand drawer'}</Button></Inline><TextField label="Drawer note" defaultValue="A small, useful surface"/></Stack></SwipeDrawer.Content></SwipeDrawer.Popup></SwipeDrawer.Viewport></SwipeDrawer.Portal></SwipeDrawer.Root></Section><CoordinatedDrawers/></>;
}
function CoordinatedDrawers() {
return <Section title="Coordinated drawers and swipe activation" note="The visible buttons provide keyboard and pointer alternatives to swiping. Virtual-keyboard behavior still needs real-device qualification.">
<SwipeDrawer.Provider>
<div className="docs-drawer-stage"><SwipeDrawer.IndentBackground className="docs-drawer-stage-background"/><SwipeDrawer.Indent className="docs-drawer-stage-content">
<SwipeDrawer.Root><SwipeDrawer.VirtualKeyboardProvider>
<SwipeDrawer.Trigger render={<Button/>}>Open collection drawer</SwipeDrawer.Trigger>
<SwipeDrawer.SwipeArea className="docs-drawer-swipe-area" aria-label="Swipe up to open collection drawer"><span aria-hidden="true">Swipe up from here</span></SwipeDrawer.SwipeArea>
<SwipeDrawer.Portal><SwipeDrawer.Backdrop/><SwipeDrawer.Viewport><SwipeDrawer.Popup><SwipeDrawer.Content><Stack gap="4">
<SwipeDrawer.Header><SwipeDrawer.Title>Collection details</SwipeDrawer.Title><SwipeDrawer.Close aria-label="Close collection drawer"/><SwipeDrawer.Description>Edit a local draft or open its sharing options.</SwipeDrawer.Description></SwipeDrawer.Header>
<TextField label="Collection drawer title" defaultValue="Weekend places"/>
<SwipeDrawer.Root><Inline><SwipeDrawer.Trigger render={<Button/>}>Open sharing drawer</SwipeDrawer.Trigger></Inline><SwipeDrawer.Portal><SwipeDrawer.Backdrop/><SwipeDrawer.Viewport><SwipeDrawer.Popup><SwipeDrawer.Content><Stack gap="4"><SwipeDrawer.Header><SwipeDrawer.Title>Sharing options</SwipeDrawer.Title><SwipeDrawer.Close aria-label="Close sharing drawer"/></SwipeDrawer.Header><TextField label="Sharing note" placeholder="Add a note"/></Stack></SwipeDrawer.Content></SwipeDrawer.Popup></SwipeDrawer.Viewport></SwipeDrawer.Portal></SwipeDrawer.Root>
</Stack></SwipeDrawer.Content></SwipeDrawer.Popup></SwipeDrawer.Viewport></SwipeDrawer.Portal>
</SwipeDrawer.VirtualKeyboardProvider></SwipeDrawer.Root>
</SwipeDrawer.Indent></div>
</SwipeDrawer.Provider>
</Section>;
}