Qure UI
Components

Dialog

An overlay that interrupts the workflow to ask for a decision.

'use client'import { Button } from '@/registry/qure/ui/button'import {  Dialog, DialogClose, DialogContent, DialogDescription,  DialogFooter, DialogHeader, DialogTitle, DialogTrigger,} from '@/registry/qure/ui/dialog'export default function DialogDemo() {  return (    <Dialog>      <DialogTrigger render={<Button variant="secondary">Sign report</Button>} />      <DialogContent>        <DialogHeader>          <DialogTitle>Sign this report?</DialogTitle>          <DialogDescription>            Signing sends the report to the referring clinician and locks it from further edits.          </DialogDescription>        </DialogHeader>        <DialogFooter>          <DialogClose render={<Button variant="tertiary">Cancel</Button>} />          <DialogClose render={<Button>Sign and send</Button>} />        </DialogFooter>      </DialogContent>    </Dialog>  )}

Installation

npx shadcn@latest add https://qure-ui.qure.ai/r/dialog.json

Usage

import {
  Dialog, DialogClose, DialogContent, DialogDescription,
  DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
} from '@/components/ui/dialog'
<Dialog>
  <DialogTrigger render={<Button variant="secondary">Sign report</Button>} />
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Sign this report?</DialogTitle>
      <DialogDescription>Signing locks the report from further edits.</DialogDescription>
    </DialogHeader>
    <DialogFooter>
      <DialogClose render={<Button variant="tertiary">Cancel</Button>} />
      <DialogClose render={<Button>Sign and send</Button>} />
    </DialogFooter>
  </DialogContent>
</Dialog>

A dialog stops the work in front of it. That is its whole value, and the reason to spend it carefully: a reader who is dismissing dialogs on reflex is no longer reading them. Reach for one when a decision genuinely cannot wait — signing, cancelling an order, discarding an unsaved draft. Anything that only adds information alongside the page wants a Popover; anything the reader can act on later wants an inline panel or an Alert.

Composition

<Dialog>                  {/* owns the open state */}
  <DialogTrigger>         {/* renders your button, via render= */}
  <DialogContent>         {/* portal + backdrop + popup, and the ✕ */}
    <DialogHeader>        {/* title and description, grouped for spacing */}
      <DialogTitle>       {/* becomes aria-labelledby */}
      <DialogDescription> {/* becomes aria-describedby */}
    </DialogHeader>
{/* the body: yours */}
    <DialogFooter>        {/* right-aligned actions */}
      <DialogClose>       {/* dismisses without an onClick */}
    </DialogFooter>
  </DialogContent>
</Dialog>

render replaces the primitive's element rather than wrapping it, so the trigger is your button — one element, one set of handlers, no nested interactive nodes. The same applies to DialogClose, which is how the footer buttons dismiss with no state of their own.

DialogContent bundles the portal, the backdrop and the popup. Everything you pass lands inside the popup, so the ✕ in the corner sits above your content regardless of what that content is.

What you get for free

Focus moves into the dialog on open and returns to the trigger on close. Focus is trapped while it is open. Escape dismisses. The rest of the page is inert to a screen reader. Scroll is locked. aria-labelledby and aria-describedby are wired from DialogTitle and DialogDescription.

That list is the reason not to hand-roll a modal.

Always include a DialogTitle. Without it the dialog has no accessible name, and a screen reader announces an unlabelled group. Use showCloseButton={false} if the corner ✕ is wrong for your layout — never drop the title instead.

Widths

'use client'import { Button } from '@/registry/qure/ui/button'import {  Dialog, DialogClose, DialogContent, DialogDescription,  DialogFooter, DialogHeader, DialogTitle, DialogTrigger,} from '@/registry/qure/ui/dialog'const widths = [  { label: 'Narrow', className: 'max-w-sm', body: 'One decision, one line of consequence. 384px.' },  { label: 'Default', className: undefined, body: 'The 460px the stylesheet sets. Fits a short form.' },  { label: 'Wide', className: 'max-w-2xl', body: 'A table, a diff of two reports, or a preview. 672px.' },]export default function DialogWidth() {  return (    <div className="flex flex-wrap gap-2">      {widths.map(({ label, className, body }) => (        <Dialog key={label}>          <DialogTrigger render={<Button variant="secondary">{label}</Button>} />          <DialogContent className={className}>            <DialogHeader>              <DialogTitle>{label} dialog</DialogTitle>              <DialogDescription>{body}</DialogDescription>            </DialogHeader>            <DialogFooter>              <DialogClose render={<Button variant="tertiary">Close</Button>} />            </DialogFooter>          </DialogContent>        </Dialog>      ))}    </div>  )}

The stylesheet sets max-width: 460px, which fits a question and two buttons. Change it with a max-w-* on DialogContent rather than a new component: a dialog holding a table needs the room, and a dialog asking one question is harder to read at that width, not easier. The popup is always calc(100vw - 32px) at most, so none of these overflow a phone.

A modal with a rail

'use client'import { useState } from 'react'import { BellIcon, KeyRoundIcon, PlugIcon, SlidersHorizontalIcon, UserIcon, XIcon } from 'lucide-react'import { ActionBar, ActionBarActions, ActionBarNote } from '@/registry/qure/ui/action-bar'import { Button } from '@/registry/qure/ui/button'import {  Dialog, DialogClose, DialogContent, DialogTitle, DialogTrigger,} from '@/registry/qure/ui/dialog'import { HeaderBar, HeaderBarActions, HeaderBarTitle } from '@/registry/qure/ui/header-bar'import { NavList, NavListGroup, NavListItem } from '@/registry/qure/ui/nav-list'import { StatusBar, StatusBarText } from '@/registry/qure/ui/status-bar'const SECTIONS = [  { id: 'profile', label: 'Profile', icon: <UserIcon />, group: 'Account' },  { id: 'security', label: 'Security', icon: <KeyRoundIcon />, group: 'Account' },  { id: 'reading', label: 'Reading defaults', icon: <SlidersHorizontalIcon />, group: 'Workspace' },  { id: 'notifications', label: 'Notifications', icon: <BellIcon />, group: 'Workspace' },  { id: 'integrations', label: 'Integrations', icon: <PlugIcon />, group: 'Workspace' },]export default function DialogNavigation() {  const [current, setCurrent] = useState('reading')  const section = SECTIONS.find((s) => s.id === current)!  const item = (s: (typeof SECTIONS)[number]) => (    <NavListItem      key={s.id}      href="#"      icon={s.icon}      active={current === s.id}      onClick={(e) => {        e.preventDefault()        setCurrent(s.id)      }}    >      {s.label}    </NavListItem>  )  return (    <Dialog>      <DialogTrigger render={<Button variant="secondary">Open settings</Button>} />      <DialogContent        showCloseButton={false}        className="flex h-[560px] max-w-[840px] flex-row overflow-hidden p-0"      >        <NavList aria-label="Settings sections" size="sm" className="w-60 shrink-0 overflow-y-auto py-2">          <NavListGroup label="Account">{SECTIONS.filter((s) => s.group === 'Account').map(item)}</NavListGroup>          <NavListGroup label="Workspace">{SECTIONS.filter((s) => s.group === 'Workspace').map(item)}</NavListGroup>        </NavList>        <div className="flex min-w-0 flex-1 flex-col">          <HeaderBar size="md" divider>            {/* The panel title is the dialog's accessible name, so it renders                as DialogTitle rather than sitting beside one. */}            <HeaderBarTitle render={<DialogTitle />}>{section.label}</HeaderBarTitle>            <HeaderBarActions>              <DialogClose                render={                  <Button variant="tertiary" size="icon-sm" aria-label="Close">                    <XIcon />                  </Button>                }              />            </HeaderBarActions>          </HeaderBar>          <div className="flex-1 overflow-y-auto p-4">            {current === 'reading' ? (              <StatusBar status="attention" className="mb-4">                <StatusBarText>                  Changes here apply to every study you open, including ones already in your                  worklist.                </StatusBarText>              </StatusBar>            ) : null}            <p className="text-body-small text-muted-foreground">              Settings for {section.label.toLowerCase()}. The rail keeps its own scroll, so a long              list of sections never pushes the actions off the bottom of the panel.            </p>          </div>          <ActionBar>            <ActionBarNote>Saved to your account.</ActionBarNote>            <ActionBarActions>              <DialogClose render={<Button variant="tertiary">Cancel</Button>} />              <Button>Save changes</Button>            </ActionBarActions>          </ActionBar>        </div>      </DialogContent>    </Dialog>  )}

Figma's "Modal — Navigation" (node 3336:6038) is 840×560 with a 240px rail beside a panel. Like the other three modal frames it is a composition, not a component: a NavList, a Header Bar, a body and an Action Bar, inside a DialogContent with p-0.

Two things are easy to get wrong here.

The panel title has to be the DialogTitle. A dialog takes its accessible name from that element, and a rail modal has an obvious-looking heading that is not it — so the dialog opens announced as "dialog" and nothing else. Render one through the other rather than having both:

<HeaderBarTitle render={<DialogTitle />}>{section.label}</HeaderBarTitle>

Scroll the rail and the body separately. One scroll container around the pair means a long list of sections pushes the actions off the bottom, and the way back to Save is to scroll a list you were not reading. Give each overflow-y-auto and leave the popup itself fixed.

Note also showCloseButton={false} — the header bar carries its own close, and the default one would land on top of it.

A form inside a dialog

'use client'import * as React from 'react'import { Button } from '@/registry/qure/ui/button'import {  Dialog, DialogClose, DialogContent, DialogDescription,  DialogFooter, DialogHeader, DialogTitle, DialogTrigger,} from '@/registry/qure/ui/dialog'import { Field, FieldDescription, FieldError, FieldLabel } from '@/registry/qure/ui/field'import { Input } from '@/registry/qure/ui/input'import { Label } from '@/registry/qure/ui/label'import {  Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/registry/qure/ui/select'import { Textarea } from '@/registry/qure/ui/textarea'const reasons = {  protocol: 'Wrong protocol',  motion: 'Motion artefact',  coverage: 'Incomplete coverage',}export default function DialogForm() {  const [open, setOpen] = React.useState(false)  return (    <Dialog open={open} onOpenChange={setOpen}>      <DialogTrigger render={<Button variant="secondary">Request a repeat scan</Button>} />      <DialogContent className="max-w-lg">        <DialogHeader>          <DialogTitle>Request a repeat scan</DialogTitle>          <DialogDescription>            The request goes to the modality worklist and the referrer is notified.          </DialogDescription>        </DialogHeader>        {/* A real form element, so Enter submits and the browser validates. */}        <form          id="repeat-scan"          className="flex flex-col gap-4"          onSubmit={event => {            event.preventDefault()            setOpen(false)          }}        >          <Field            name="accession"            validate={value => (/^ACC-\d{6}$/.test(String(value ?? '')) ? null : 'Six digits, prefixed ACC-.')}            validationMode="onBlur"          >            <FieldLabel>Accession number</FieldLabel>            <Input placeholder="ACC-000000" defaultValue="ACC-4471902" />            <FieldError />          </Field>          <div className="flex flex-col gap-2">            <Label htmlFor="repeat-reason">Reason</Label>            <Select items={reasons} defaultValue="motion">              <SelectTrigger id="repeat-reason" className="w-full">                <SelectValue />              </SelectTrigger>              <SelectContent>                {Object.entries(reasons).map(([value, label]) => (                  <SelectItem key={value} value={value}>{label}</SelectItem>                ))}              </SelectContent>            </Select>          </div>          <Field name="note">            <FieldLabel>Note for the radiographer</FieldLabel>            <Textarea rows={3} placeholder="Respiratory motion through the upper lobes." />            <FieldDescription>Appears on the modality worklist entry.</FieldDescription>          </Field>        </form>        <DialogFooter>          <DialogClose render={<Button variant="tertiary">Cancel</Button>} />          <Button type="submit" form="repeat-scan">Send request</Button>        </DialogFooter>      </DialogContent>    </Dialog>  )}

Two details make this work rather than merely render. The <form> carries an id and the submit button in the footer carries form="…", so the button can live outside the form element and still submit it — and Enter in any text field submits, which is what people expect of a form. And the dialog is controlled, so it closes on a successful submit rather than on the click: a DialogClose here would dismiss the form before validation had a say.

Give the first field no autoFocus. Base UI already moves focus to the first tabbable element, and a second focus call fights it.

Long content

'use client'import { Button } from '@/registry/qure/ui/button'import {  Dialog, DialogClose, DialogContent, DialogDescription,  DialogFooter, DialogHeader, DialogTitle, DialogTrigger,} from '@/registry/qure/ui/dialog'import { ScrollArea } from '@/registry/qure/ui/scroll-area'const entries = [  ['09:14', 'Study received from CT-2', 'HL7 ORM'],  ['09:15', 'Auto-routed to the chest worklist', 'Rule CHEST-01'],  ['09:31', 'Opened by A. Rahman', 'Viewer'],  ['09:48', 'Priors retrieved: 2 studies', 'Q/R from archive'],  ['10:02', 'Draft report saved', 'Reporting'],  ['10:06', 'Finding added: right upper lobe nodule, 8mm', 'Reporting'],  ['10:19', 'Draft report saved', 'Reporting'],  ['11:40', 'Second read requested from S. Iyer', 'Worklist'],  ['13:05', 'Second read agreed', 'Worklist'],  ['13:22', 'Report signed', 'Reporting'],  ['13:22', 'Report sent to the referring clinician', 'HL7 ORU'],  ['13:23', 'Study marked final', 'Worklist'],]export default function DialogScroll() {  return (    <Dialog>      <DialogTrigger render={<Button variant="secondary">Audit trail</Button>} />      <DialogContent className="max-w-xl">        <DialogHeader>          <DialogTitle>Audit trail — ACC-4471902</DialogTitle>          <DialogDescription>            Every event recorded against this study. The list scrolls; the header and the            footer do not.          </DialogDescription>        </DialogHeader>        {/* The scroll lives on an inner region, so the title stays put and the            close button never scrolls out of reach. min-h-0 is what lets a            flex child shrink below its content. */}        <ScrollArea className="-mx-2 h-64 min-h-0 px-2">          <ol className="flex flex-col gap-3 pr-2 text-sm">            {entries.map(([time, event, source], i) => (              <li key={i} className="flex gap-3">                <span className="text-muted-foreground w-12 shrink-0 tabular-nums">{time}</span>                <span className="flex-1">                  {event}                  <span className="text-muted-foreground block text-xs">{source}</span>                </span>              </li>            ))}          </ol>        </ScrollArea>        <DialogFooter>          <DialogClose render={<Button variant="tertiary">Close</Button>} />          <Button>Export as CSV</Button>        </DialogFooter>      </DialogContent>    </Dialog>  )}

The popup is capped at calc(100vh - 64px) and scrolls as a whole. That is fine for a wall of consent text, and wrong for anything with a footer — the buttons scroll away with the content. Put a ScrollArea around the body instead, and the header and the actions stay where the reader left them.

Controlled

'use client'import * as React from 'react'import { Button } from '@/registry/qure/ui/button'import {  Dialog, DialogClose, DialogContent, DialogDescription,  DialogFooter, DialogHeader, DialogTitle,} from '@/registry/qure/ui/dialog'export default function DialogControlled() {  const [open, setOpen] = React.useState(false)  const [pending, setPending] = React.useState(false)  const [sent, setSent] = React.useState(false)  async function send() {    setPending(true)    await new Promise(resolve => setTimeout(resolve, 900))    setPending(false)    setSent(true)    setOpen(false)  }  return (    <div className="flex items-center gap-3">      <Button variant="secondary" onClick={() => setOpen(true)}>Send to referrer</Button>      {sent ? <span className="text-muted-foreground text-sm">Sent 13:22</span> : null}      <Dialog open={open} onOpenChange={setOpen}>        <DialogContent>          <DialogHeader>            <DialogTitle>Send report for ACC-4471902?</DialogTitle>            <DialogDescription>              The signed report is delivered over HL7. Delivery cannot be recalled.            </DialogDescription>          </DialogHeader>          <DialogFooter>            <DialogClose render={<Button variant="tertiary" disabled={pending}>Cancel</Button>} />            {/* Not DialogClose: the dialog has to stay open until the request                comes back, and Close dismisses on click. */}            <Button disabled={pending} onClick={send}>{pending ? 'Sending…' : 'Send'}</Button>          </DialogFooter>        </DialogContent>      </Dialog>    </div>  )}

Pass open and onOpenChange when something other than the trigger has to open or close it: a keyboard shortcut, a route, or — as here — an action that must finish before the dialog goes away. Note the confirm button is a plain Button rather than a DialogClose; Close dismisses on click, which would hide the pending state and the failure with it.

Confirming something destructive

'use client'import {  AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,  AlertDialogDescription, AlertDialogFooter, AlertDialogHeader,  AlertDialogTitle, AlertDialogTrigger,} from '@/registry/qure/ui/alert-dialog'import { Button } from '@/registry/qure/ui/button'export default function AlertDialogDemo() {  return (    <AlertDialog>      <AlertDialogTrigger render={<Button variant="destructive">Delete study</Button>} />      <AlertDialogContent>        <AlertDialogHeader>          <AlertDialogTitle>Delete CT Thorax, ACC-100482?</AlertDialogTitle>          <AlertDialogDescription>            The study, its 214 images and the unsigned report are removed from the archive.            The audit entry stays.          </AlertDialogDescription>        </AlertDialogHeader>        <AlertDialogFooter>          <AlertDialogCancel render={<Button variant="tertiary">Keep study</Button>} />          <AlertDialogAction render={<Button variant="destructive">Delete</Button>} />        </AlertDialogFooter>      </AlertDialogContent>    </AlertDialog>  )}

Use Alert Dialog rather than this component when the answer matters and the wrong answer cannot be undone. It reports itself as role="alertdialog", it focuses the safe option rather than the first one, and its buttons are named for what they do. Escape still dismisses it — that is the platform behaviour and we have not overridden it, so "are you sure" is a prompt, not a lock.

Keyboard

Tab cycles within the dialog and never escapes it. Escape closes. Shift+Tab from the first element wraps to the last. On close, focus returns to the element that opened it — which is why the trigger should be a real button and not a div with a handler.

API Reference

Everything Base UI's Dialog accepts. What we add on DialogContent:

Prop

Type

On Dialog itself:

Prop

Type

The layout parts — DialogHeader, DialogFooter — are plain divs and take any div prop.

On this page