Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.

Responsive Dialog

One dialog that suits the device: a centred Dialog on a desktop, a bottom sheet you can swipe away on a phone. Write the parts once — trigger, content, header, footer, close — and the viewport picks the branch. For the edit forms, pickers and short questions a Comitor app opens from anywhere.

Import
import { ResponsiveDialog, ResponsiveDialogContent, … } from '@comitor/ui' — the main entry. No optional peer: vaul, which the Drawer branch runs on, is an ordinary dependency of the package.
Runtime
A client component — it reads the viewport with matchMedia. On the server there is no viewport, so the server HTML is always the Dialog branch.
Origin
Ported in 2.1.0 from Dice UI (MIT, © 2024 Sadman Sakib) onto the package’s own Dialog and Drawer, so their sizes, labels, height caps and grab handle apply unchanged. The same release changed the Drawer’s focus default — see What the port guarantees and the changelog.

Anatomy

The parts mirror Dialog’s — all but DialogBody, which works as it is (see below). Each renders its Dialog… twin from the breakpoint up and its Drawer… twin below it, so anything you already know about those two holds — including that only the Dialog has a corner ✕.

anatomy.tsx
import {
  ResponsiveDialog, ResponsiveDialogClose, ResponsiveDialogContent, ResponsiveDialogDescription,
  ResponsiveDialogFooter, ResponsiveDialogHeader, ResponsiveDialogTitle, ResponsiveDialogTrigger,
} from '@comitor/ui'

<ResponsiveDialog breakpoint={768}>             // Dialog from 768px up, a vaul Drawer below
  <ResponsiveDialogTrigger asChild>…</ResponsiveDialogTrigger>
  <ResponsiveDialogContent>                     // portal + overlay + panel — size, showCloseButton and
                                                // closeLabel reach the Dialog branch only
    <ResponsiveDialogHeader>
      <ResponsiveDialogTitle>…</ResponsiveDialogTitle>          // names the dialog in both branches
      <ResponsiveDialogDescription>…</ResponsiveDialogDescription>
    </ResponsiveDialogHeader>
    …                                           // long content? DialogBody scrolls in both branches
    <ResponsiveDialogFooter>
      <ResponsiveDialogClose asChild>…</ResponsiveDialogClose>  // the Drawer has no corner ✕ — always add one
    </ResponsiveDialogFooter>
  </ResponsiveDialogContent>
</ResponsiveDialog>

// Each part renders its Dialog… or Drawer… twin and carries data-variant="dialog" | "drawer".

An edit form

The read-out above the button uses the same media query as the component. Open the form on a wide window, then narrow the window below 768px — or open this page on a phone — and the same parts come up as a sheet with a grab handle. The open state is controlled here, and the draft lives above the dialog: resize across the breakpoint while it is open and your typing survives.

Dialogviewport … — the Drawer takes over below 768px

open = false · saved: Nguyễn Thu Hà, Product designer

On open, focus goes to Display name with its text selected, in both branches. The footer’s two data-[variant=drawer]: classes are the only branch-specific styling on this page: the sheet already pads its sides by 16px and DrawerFooter adds 16px more, which would leave the buttons narrower than the fields, and the column is reversed so Save sits on top — the order DialogFooter uses on narrow screens. When something else needs to know the branch, the package’s own hook is internal; read the same query:

use-drawer-branch.ts
'use client'

import { useSyncExternalStore } from 'react'

// ResponsiveDialog's own hook is internal. This reads the same query (breakpoint 768 →
// max-width: 767px) the same way: useSyncExternalStore has the answer on the first client
// render, where useState + useEffect would render the wrong branch for one frame.
const QUERY = '(max-width: 767px)'

function subscribe(onChange: () => void) {
  const mql = window.matchMedia(QUERY)
  mql.addEventListener('change', onChange)
  return () => mql.removeEventListener('change', onChange)
}

export function useDrawerBranch() {
  return useSyncExternalStore(subscribe, () => window.matchMedia(QUERY).matches, () => false)
}

A short question

Uncontrolled, with two props that exist only in the Dialog branch: size="sm" narrows the panel and showCloseButton={false} drops the corner ✕, since Stay does the same job. In the Drawer branch both are simply not passed on. Focus opens on Stay because it comes first in the DOM — the safe answer is the one under the finger.

You are a member of #design-reviews.

Leaving a channel can be undone, so a plain dialog is right. For something that destroys data, use Confirm Dialog on every screen size: it is an alert dialog that a stray tap on the overlay cannot dismiss, and it holds itself open while the request is in flight.

Long content, and where focus lands

There is no ResponsiveDialogBody, and none is needed: DialogBody is a plain min-h-0 flex-auto overflow-y-auto div, and both branches are height-capped flex columns — the Dialog at the viewport height minus 2rem, the Drawer at 80vh — so it scrolls between a pinned header and footer in either. This demo also moves the first focus: onOpenAutoFocus prevents the default and focuses the notes region, so the top of the notes stays in view, no phone keyboard appears, and the arrow keys scroll at once.

The same handler solves the classic phone problem with forms. By default focus goes to the first focusable element, and when that is a text field the keyboard slides up over half the sheet the moment it opens. Prevent the default and focus the panel itself — or go straight to the field the user came to change:

focus.tsx
// A form in a sheet: focusing the first field pops the phone keyboard over half the form.
// Keep it down by focusing the panel itself — Radix gives it tabIndex={-1}, so it can take focus.
const panelRef = useRef<HTMLDivElement>(null)

<ResponsiveDialogContent
  ref={panelRef}
  onOpenAutoFocus={(event) => {
    event.preventDefault() // no first-field focus…
    panelRef.current?.focus({ preventScroll: true }) // …but focus must still go INSIDE, never stay on the trigger
  }}
>

// Or open straight into the field the user came to change — "Edit" next to the email row:
<ResponsiveDialogContent
  onOpenAutoFocus={(event) => {
    event.preventDefault()
    emailRef.current?.focus({ preventScroll: true })
  }}
>

What the port guarantees

Behaviour you can rely on that the Dice UI original did not give you.

  • Focus goes inside the Drawer. vaul 1.1.2 defaults autoFocus to false and then cancels Radix’s open auto-focus, so focus stayed on the trigger behind the overlay; Radix’s focus trap only holds focus that is already inside, so it never engaged, and Tab walked the page behind a modal (WCAG 2.4.3) — measured at 375px while porting. Since 2.1.0 the package’s Drawer defaults autoFocus to true: focus moves to the first focusable element inside, Tab loops inside, and closing returns focus to the trigger — exactly what the Dialog branch does.
  • onOpenAutoFocus works in both branches. vaul calls your handler before its own check, so the same preventDefault() + focus() steers either branch. There is no autoFocus prop on ResponsiveDialog — one existed only while 2.1.0 was being prepared and never reached npm.
  • A phone never sees the Dialog first. The viewport is read with matchMedia through useSyncExternalStore, so the first client render already has the answer. Upstream started from useState(false) and corrected itself in an effect: a dialog mounted open on a phone flashed as a centred Dialog for one frame before turning into a sheet.
  • Dialog-only props stay in the Dialog. showCloseButton, size and closeLabel are split off before the Drawer branch renders. Upstream spread every content prop onto the Drawer, where React warned about unknown props and size="lg" became a real HTML attribute on the sheet.
  • The footer is a footer. Upstream’s footer took showCloseButton and handed it to shadcn’s DialogFooter, which draws a Close button of its own. The package’s DialogFooter has no such prop: the corner ✕ belongs to the content, and a Cancel in the footer is your ResponsiveDialogClose.
  • Both branches are the package’s primitives. The Dialog brings its sizes, its closeLabel from the language provider and its capped, scrolling panel; the Drawer brings the 80vh cap with overflow-y-auto and a grab handle painted in bg-control-edge, 3.83:1 in light and 4.94:1 in dark, because it is the only sign that says “drag here”.

Language

Responsive Dialog has no namespace of its own and exactly one built-in string: the screen-reader name of the Dialog branch’s corner ✕, closeLabel in the dialog namespace — “Close”, or “Đóng” from comitorMessagesVi.dialog. The closeLabel prop beats the nearest ComitorLocaleProvider, which beats English. The Drawer branch has no corner button, so it has no string at all. Switch the language, open the dialog, and read the name back from the DOM:

Everything else in the panel is yours and follows your own translations. Where the provider goes: Get started → Set the language.

Props

ResponsiveDialog takes Dialog’s root props plus breakpoint, and forwards them to whichever branch renders.

PropKiểuMặc địnhMô tả
breakpointnumber768Viewport width in CSS pixels below which the Drawer renders — the query is (max-width: breakpoint − 1px), so 768 itself is a Dialog. The window, not the container.
openboolean—Controlled open state. It survives a switch between branches: the state lives in the root, not in the Dialog or the Drawer.
defaultOpenbooleanfalseUncontrolled initial state.
onOpenChange(open: boolean) => void—Every open and close — trigger, Close parts, Escape, a click on the overlay, and in the Drawer branch a swipe down.
modalbooleantrueForwarded to whichever branch renders. false leaves the page behind interactive and stops trapping focus, in both branches.
childrenbắt buộcReactNode—The parts. Drawer-only options — direction, snapPoints, dismissible — are not part of this type.

ResponsiveDialogContent — typed as DialogContent’s props; everything not listed goes to the panel in both branches.

PropKiểuMặc địnhMô tả
showCloseButtonbooleantrueDialog branch only: the corner ✕. The Drawer branch never has one.
size'sm' | 'md' | 'lg' | 'xl''md'Dialog branch only: the panel's max width from the sm breakpoint up. The Drawer is always full width.
closeLabelstringdialog.closeLabelDialog branch only: the screen-reader name of the corner ✕ — 'Close' in English, 'Đóng' in Vietnamese. Say what it closes.
onOpenAutoFocus(event: Event) => void—Both branches. Call event.preventDefault() and focus something yourself to choose where focus lands; otherwise it goes to the first focusable element inside.
onCloseAutoFocus(event: Event) => void—Both branches, and it runs before any built-in return: call event.preventDefault() and focus something yourself — the next row when the opener is gone after a delete, for instance. Opened without a ResponsiveDialogTrigger, it is also how the Drawer branch gets focus back at all; see Known limitations.
onEscapeKeyDown / onInteractOutside(event) => void—Both branches — Radix Dialog sits under the Drawer too. Prevent the event to keep the panel open.
classNamestring—Merged onto the panel. In the Drawer branch the component puts px-4 pb-4 before it; target a branch with data-[variant=drawer]: or data-[variant=dialog]:.
refRef<HTMLDivElement>—The panel — role dialog, tabIndex -1 — so .focus() on it keeps focus inside without choosing a control.

The other parts. Each takes the props of its Dialog twin and className.

PropKiểuMặc địnhMô tả
ResponsiveDialogTriggerDialogTrigger | DrawerTrigger—Opens it. Use asChild with your own Button.
ResponsiveDialogHeaderDialogHeader | DrawerHeader—Dialog: gap-2, left-aligned from sm. Drawer: its own p-4, centred text on a bottom sheet.
ResponsiveDialogTitleDialogTitle | DrawerTitle—The dialog's accessible name in both branches — required. text-lg in the Dialog, the inherited body size in the Drawer.
ResponsiveDialogDescriptionDialogDescription | DrawerDescription—Linked with aria-describedby. Omit it only together with aria-describedby={undefined} on the content.
ResponsiveDialogFooterDialogFooter | DrawerFooter—Dialog: a right-aligned row from sm up. Drawer: a column with its own p-4, pushed to the bottom with mt-auto. No close-button prop.
ResponsiveDialogCloseDialogClose | DrawerClose—Closes it. The only visible way out of the Drawer branch for a keyboard or switch user besides Escape.
ResponsiveDialogOverlay / PortalDialogOverlay | DrawerOverlay, …Portal—For a hand-built shell. ResponsiveDialogContent already renders its own portal and overlay — adding these next to it doubles them.
data-variant'dialog' | 'drawer'—Set on every part. Style a part with data-[variant=drawer]:…; style its children through a named group, group/panel and group-data-[variant=drawer]/panel:…

Known limitations

  • Crossing the breakpoint remounts everything inside. The open state lives in the root and survives, but the panel is a new tree: uncontrolled field values, scroll position and focus start over. Turning an iPad mini (744px wide upright, 1133px on its side) is enough to cross 768. Keep form state above the dialog, as the edit form does.
  • Without a Trigger, only the Dialog branch returns focus. Opened from state — a table row, a menu item — the Dialog branch hands focus back to whatever had it when it opened, as Dialog does since 3.3.0. The Drawer branch is vaul’s DrawerContent, which has no such return, so below the breakpoint closing drops focus on <body>. Keep a ResponsiveDialogTrigger, or return focus in onCloseAutoFocus — which works in both branches, as the job rows of the Mobile pattern do.
  • Always a bottom sheet. direction, snapPoints, dismissible and the other vaul options are not in the props — the type is Dialog’s root. For a side sheet or snap points, compose Dialog and Drawer yourself with the query above.
  • No corner ✕ in the Drawer branch. Swiping down and tapping the overlay are pointer-only and Escape needs a keyboard; a ResponsiveDialogClose in the footer is the only way out for everyone else.
  • Anything you drag inside the sheet drags the sheet. vaul listens for pointer moves across the whole panel, so a downward drag on a cropper, a canvas or a sortable list also pulls the Drawer shut. Mark those elements data-vaul-no-drag — the Cropper page shows it.
  • The viewport decides, not the container. A narrow side panel on a wide screen still gets the Dialog, and the server always renders the Dialog branch — the switch to a sheet on a phone happens as the page hydrates, which only shows if a dialog is already open at load.
  • The branch is not exported. Only the parts know it, through data-variant; anything outside reads the media query itself.

Accessibility

  • Both branches are a Radix Dialog underneath — vaul builds the Drawer on it — so both render role="dialog" labelled by the title and described by the description, and hide the rest of the page from assistive technology while open. Always include a title; without a description, pass aria-describedby={undefined} or Radix warns in development.
  • On open, focus goes to the first focusable element inside (links are skipped, and a text field has its text selected); Tab and Shift Tab loop inside; Esc closes; focus returns to the trigger. Identical in both branches since 2.1.0 — as long as there is a ResponsiveDialogTrigger; opened from state, see Known limitations.
  • Choose the first focus on purpose with onOpenAutoFocus — the panel, a specific field or a scroll region — but always somewhere inside. Leaving focus on the trigger behind the overlay is the bug this release fixed.
  • Long content needs a keyboard way to scroll: give the scrolling region tabIndex and a name, as the release notes do. Safari does not make scroll containers focusable on its own.
  • Ways out: Esc and a click on the overlay in both branches, the corner ✕ in the Dialog branch (named by closeLabel — say what it closes, it is read out of context), a swipe in the Drawer branch. The grab handle is decoration and never takes focus.
  • Forms inside behave like any form. A submit button in the footer reaches the form through form="…", which also keeps implicit submission working: Enter in a field submits, in both branches.
  • The trigger stays a real button: use asChild with a Button, never an onClick on a div.

Related

  • Mobile — the phone layouts where the sheet branch matters, next to the bottom Sheet they use for detail.
  • Dialog and Drawer — the two branches on their own, with every prop they take.
  • Confirm Dialog — for destructive questions, on every screen size.
  • Sheet — a panel from any edge that stays a panel, without the drag gesture.
  • Cropper — the crop step that usually lives inside one of these.