Components · Overlays
ResponsiveDialog
A Dialog on wide screens and a bottom sheet on phones, switched in CSS.
Invite form
import { Button, DialogBody, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Field, FieldLabel, Input, ResponsiveDialog, ResponsiveDialogClose, ResponsiveDialogContent, ResponsiveDialogTrigger } from '@rookcom/monarch-react';
<ResponsiveDialog>
<ResponsiveDialogTrigger asChild>
<Button>Invite people</Button>
</ResponsiveDialogTrigger>
<ResponsiveDialogContent closeOnOutsideClick={false}>
<DialogHeader>
<DialogTitle>Invite people</DialogTitle>
<DialogDescription>They get an email with a link to join.</DialogDescription>
</DialogHeader>
<DialogBody>
<Field>
<FieldLabel>Email address</FieldLabel>
<Input type="email" placeholder="[email protected]" />
</Field>
</DialogBody>
<DialogFooter>
<ResponsiveDialogClose asChild>
<Button>Cancel</Button>
</ResponsiveDialogClose>
<Button variant="primary">Send invite</Button>
</DialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>Usage examples
The example at the top of the page is the only one for this component.
Purpose
One dialog that suits both screens: centred with a margin on desktop, rising from the bottom edge on a phone where the thumb can reach its actions. Same content, same focus trap, same Escape.
Use when
- A short form or picker used on both desktop and phones.
- Public or mobile-first pages where a centred dialog feels cramped on a phone.
Avoid when
- Desktop-only admin screens: use Dialog.
- A panel that should always come from a side: use Sheet.
- A confirmation: use ConfirmDialog.
Design principles
- One API and one DOM for both layouts; nothing is rendered twice.
- The layout is chosen by a CSS media query, not by
matchMediain JavaScript, so the server render, the first client render and every later render agree: no hydration mismatch and no flash of the wrong layout. - Behaviour never changes with the layout: the same focus trap, Escape, scroll lock and focus return in both.
API reference
import { ResponsiveDialog, ResponsiveDialogClose, ResponsiveDialogContent, ResponsiveDialogTrigger } from '@rookcom/monarch-react';| Export | Kind | Rendering | Root class |
|---|---|---|---|
ResponsiveDialog | component | client | .mn-responsive-dialog |
ResponsiveDialogClose | component | client | .mn-responsive-dialog |
ResponsiveDialogContent | component | client | .mn-responsive-dialog |
ResponsiveDialogTrigger | component | client | .mn-responsive-dialog |
ResponsiveDialog, ResponsiveDialogTrigger and ResponsiveDialogClose are Dialog, DialogTrigger and DialogClose.
ResponsiveDialogContent is DialogContent with the mn-responsive-dialog class, sheetBelow and a grab handle.
Compose the content with the Dialog parts: DialogHeader, DialogTitle, DialogDescription, DialogBody and DialogFooter.
Generated from the TypeScript declarations of packages/react/src/components/overlays/responsive-dialog.
Properties
Every DialogContent prop is accepted (size, footer, busy, closeOnOutsideClick, escapeGuard, closeLabel) except fullScreenOnPhone.
size applies to the dialog layout only; the sheet always spans the screen width.
ResponsiveDialog
A centred Dialog on wide screens and a bottom sheet on narrow ones, from one API and one DOM. The switch is pure CSS (a media query), not `matchMedia`: the server and the first client render are identical, so there is no hydration mismatch and no flash of the wrong layout. Focus trap, Escape and focus return are the Dialog's in both layouts. Compose it with the Dialog parts: DialogHeader, DialogTitle, DialogDescription, DialogBody and DialogFooter.
| Prop | Type | Description |
|---|---|---|
children | React.ReactNode |
ResponsiveDialogClose
| Prop | Type | Description |
|---|---|---|
asChild | boolean | |
children | ReactNode | |
className | string | |
disabled | boolean | |
id | string |
ResponsiveDialogContent
| Prop | Type | Description |
|---|---|---|
asChild | boolean | |
busy | boolean | An action is in progress: Escape, outside clicks and the built-in close button no longer close the dialog, and the content is `aria-busy`. Disable your own Cancel button too. |
children | ReactNode | |
className | string | |
closeLabel | string | null | Accessible label of the built-in close button. Pass null to hide the button. |
closeOnOutsideClick | boolean | Whether a click outside the dialog closes it. Default `true`; turn it off for forms with typed values so a stray click does not lose them. Escape and the close button still work. |
escapeGuard | boolean | Ignore Escape while focus is on a control whose own popup is open (a combobox or menu button with `aria-expanded="true"`), so that press only closes the popup. Default `true`. |
footer | ReactNode | Actions rendered in a DialogFooter after the children. The footer stays visible while DialogBody scrolls. |
handle | boolean | Show the grab handle in the bottom-sheet layout. Decorative; default `true`. |
id | string | |
sheetBelow | "sm" | "md" | The viewport width below which it becomes a bottom sheet: `sm` (640px, default) or `md` (768px). |
size | "sm" | "md" | "lg" | "xl" | Width preset: 24rem, 32rem (default), 44rem or 60rem, capped at the viewport minus 2rem. |
ResponsiveDialogTrigger
| Prop | Type | Description |
|---|---|---|
asChild | boolean | |
children | ReactNode | |
className | string | |
disabled | boolean | |
id | string |
Variants
| Prop | Values | Description |
|---|---|---|
sheetBelow | 'sm''md' | Below 640px (sm, default) or 768px (md) it is a bottom sheet. Exposed as data-sheet-below. |
size | 'sm''md''lg''xl' | Dialog width on wide screens, as on Dialog. |
handle | 'true''false' | Decorative grab handle in the sheet layout; default true. |
States
| State | Exposed as | Behaviour |
|---|---|---|
| Open / closed | data-state | From Radix. The dialog fades and scales in; the sheet slides up. |
| Busy | data-busy, aria-busy | Nothing dismisses it while an action runs. |
Interaction behaviour
- Opening moves focus inside; Tab and Shift+Tab stay inside; the page does not scroll.
- Escape, the close button, a ResponsiveDialogClose or an overlay click closes it, and focus returns to the trigger.
closeOnOutsideClick={false}keeps forms with typed values open on a stray tap.- Rotating or resizing the screen switches the layout immediately, without re-mounting or losing input.
Keyboard support
| Keys | Action |
|---|---|
| Enter / Space | On the trigger, opens it. |
| Tab / Shift+Tab | Cycles focus within it. |
| Escape | Closes it and returns focus to the trigger. |
Accessibility
- Role
- dialog (aria-modal)
- Accessible name
- DialogTitle; DialogDescription becomes the accessible description.
- The role and name are the same in both layouts, so screen reader users get one consistent experience.
- The grab handle is
aria-hiddenand is not a control.
Responsive behaviour
- At and above the breakpoint: the centred Dialog,
min(size, 100vw - 2rem)wide. - Below it: full width, anchored to the bottom, rounded top corners, at most the screen height minus 2rem and the top safe area.
- The sheet pads for the home bar and side notches (
env(safe-area-inset-*)). - Swipe-to-dismiss is not implemented; the handle is a visual cue only.
Theme support
- Uses the Dialog surface, border, overlay and shadow tokens; light, dark and more-contrast themes apply automatically.
- Both entrance animations use duration tokens, which are 0ms under
prefers-reduced-motion.
Do and don't
Do
- Keep the content short enough to read on a phone.
- Put actions in DialogFooter so they sit at the bottom of the sheet, within thumb reach.
Don't
- Do not switch between Dialog and Sheet yourself with
matchMedia: it renders the wrong layout on the server and flashes on hydration. - Do not rely on the handle for dismissal.
Related components
- Dialog: The wide-screen layout and the parts used inside.
- Sheet: A panel from any edge, at every width.
- ConfirmDialog: Confirmations with pending and error states.
Dependencies
| Kind | Depends on |
|---|---|
| npm packages | react |
| Monarch components | Dialog |
Testing coverage
- Opens as a dialog, traps focus, closes on Escape and restores focus; axe passes on the open content.
- Passes Dialog options through (
sheetBelow,size,busy,footer). - Renders a decorative,
aria-hiddenhandle, removable withhandle={false}. - Controlled open state;
closeOnOutsideClick={false}keeps it open on an overlay click. - Never calls
matchMedia; the stylesheet holds the breakpoints. - Renders on the server and hydrates with no recoverable errors and no console errors.
- Renders open on the server without touching browser globals.
| Check | Covered |
|---|---|
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: gallery/responsive-dialog, overlays/responsive-dialog-light (open, 1280, dialog layout) and overlays/responsive-dialog-light-390 (open, phone, sheet layout); Cross-browser axe: /components/responsive-dialog |
Tests: packages/react/src/components/overlays/responsive-dialog