Overlays

Dialog

A dialog that presents itself for the screen: a centered dialog on wider screens and a bottom sheet you can drag away on phones (below 640px). It handles focus, Escape, outside presses, scroll locking and a close button.

A centered dialog from 640px up, a sheet you can drag away below it.

In Spoonjoy

Spoonjoy, a recipe app, is built with Ouro HX (how).

In its source: app/components/moderation/ReportOrBlockButton.tsx · app/components/notifications-section.tsx · app/components/recipe/ForkRecipeButton.tsx

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/dialog#v1.2.13

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/dialog

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/dialog#v1.2.13 ourostack/ouro-ui/dialog-tests#v1.2.13
import { Dialog } from "@/components/ouro/dialog/dialog";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<Dialog title="Edit recipe" trigger={<Button>Edit</Button>}>
  <RecipeForm />
</Dialog>
<Dialog
  title="Save to cookbook"
  open={open}
  onOpenChange={setOpen}
  footer={<Button onClick={() => setOpen(false)}>Done</Button>}
>
  <CookbookList />
</Dialog>

Usage

Use trigger for a dialog a button opens, or open with onOpenChange to control it.

It stacks above the page at --ouro-layer, a z-index of 50 by default (from ouro-base.css). If your app has fixed bars above z-50, raise it in a :root rule of your own after the Ouro tokens, outside any @layer: :root { --ouro-layer: 60; }. Don't edit the --ouro-layer: 50 line shadcn wrote; shadcn add resets it.

Testing accessibility: in WebKit, Base UI's focus guards take role="button" without a name so VoiceOver's cursor stays trapped. Exclude them: new AxeBuilder({ page }).exclude("[data-base-ui-focus-guard]").

Use instead when

  • Use ConfirmDialog instead to confirm an action, and Sheet instead when the panel should slide in from an edge at every width.

Props

The props the modes share. Type: DialogProps.

children (required)
Type: ReactNode
The body. It scrolls when the dialog is taller than the screen.
className
Type: string
Layout only, applied to the panel: margins (mt-4). Use size for width. Your classes win over Ouro UI's. Appearance classes warn in development.
closeLabel
Type: string
Default: "Close"
The label of the close button. A centered dialog shows it as an icon button in the top corner; as a sheet it is hidden until it has keyboard focus. Screen-reader users on phones (who have no Escape key) can always reach it.
description
Type: ReactNode
A short line under the title, read out when the dialog opens.
footer
Type: ReactNode
The actions row: a row at the end on wider screens, stacked full width on phones. Put the primary action last, so it sits on the right on wider screens and at the bottom, nearest the thumb, on phones: footer={<><Button variant="outline" tone="secondary">Cancel</Button><Button>Save</Button></>}.
hideTitle
Type: boolean
Hides the title visually. It still names the dialog for screen readers.
presentation
Type: "auto" | "dialog" | "sheet"
Default: "auto"
auto renders a centered dialog from the sm breakpoint (640px) up and a bottom sheet you can drag away below it. The server renders the dialog form and corrects it on hydration. A dialog that is closed at load shows nothing until it opens, so nothing shifts; one that is open at load (a route such as ?edit=1) shows as the centered dialog on a phone until hydration swaps it to the sheet.
size
Type: "sm" | "md" | "lg"
Default: "md"
The centered dialog's maximum width: sm 24rem, md 32rem, lg 40rem.
title (required)
Type: string
Names the dialog for screen readers and shows at its top. Always required; hide it with hideTitle.

Modes

DialogProps is a union: pass the props of exactly one mode. Mixing them is a type error.

Controlled: DialogControlledProps

A controlled Dialog: you own open and update it in onOpenChange.

onOpenChange (required)
Type: (open: boolean) => void
Called with false when the user dismisses the dialog (close button, Escape, outside press, swipe).
open (required)
Type: boolean
Whether the dialog is open. When a touch or pen tap opened it (on your own button, say), the centered dialog focuses itself rather than its first input, so no software keyboard pops up; after a key press it focuses the first control.

Not in this mode (typed never):

  • trigger: Not with open. Remove open and onOpenChange to let a trigger open the dialog.

With trigger: DialogWithTriggerProps

An uncontrolled Dialog: trigger opens it.

trigger (required)
Type: ReactElement
The element that opens the dialog, usually a Button: trigger={<Button>Edit</Button>}.

Not in this mode (typed never):

  • onOpenChange: Not with trigger. Remove trigger to control the dialog with open and onOpenChange.
  • open: Not with trigger. Remove trigger to control the dialog with open and onOpenChange.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • isOpen: Not a prop. Ouro UI uses open with onOpenChange: <Dialog open={open} onOpenChange={setOpen}>.
  • onClose: Not a prop. onOpenChange(false) fires on close: <Dialog open={open} onOpenChange={setOpen}>.
  • position: Not a prop. A Dialog is centered, or a bottom sheet on phones. For another edge use <Sheet side="…">.
  • side: Not a prop. A Dialog is centered, or a bottom sheet on phones. For another edge use <Sheet side="…">.
  • snapPoints: Not a prop. A Dialog fits its content. For snap points use <Sheet snapPoints={[0.5, 1]}>.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2).

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: set alignment on a parent element.

Parts

When these props cannot express your layout, build it from the parts in @/components/ouro/dialog/dialog-parts. The nesting they need follows.

import {
  DialogBackdrop,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogPopup,
  DialogPortal,
  DialogRoot,
  DialogTitle,
  DialogTrigger,
  DialogViewport,
} from "@/components/ouro/dialog/dialog-parts";

Required nesting

<DialogRoot>
  <DialogTrigger render={<Button>Edit</Button>} />
  <DialogPortal>
    <DialogBackdrop />
    <DialogViewport>
      <DialogPopup size="sm">
        <DialogTitle>Edit recipe</DialogTitle>
        <DialogContent>…</DialogContent>
        <DialogFooter>
          <DialogClose render={<Button>Done</Button>} />
        </DialogFooter>
      </DialogPopup>
    </DialogViewport>
  </DialogPortal>
</DialogRoot>

Each part checks that it sits inside <DialogRoot>: outside it, a part throws in development with a message that shows this JSX shape.dialog-nesting

DialogBackdrop
What it is: The dimming layer behind the dialog. On iOS it stays anchored to the top of the screen on a scrolled page. A ref reaches the backdrop element.
DialogClose
What it is: Closes the dialog. Pass your button as render={<Button>Done</Button>}.
DialogContent
What it is: The dialog's body. It scrolls when the dialog is taller than the screen, and while it overflows it is a tab stop, so a keyboard can scroll it.
DialogDescription
What it is: A line under the title that describes the dialog.
DialogFooter
What it is: The actions row. A row at the end on wider screens; on phones the actions stack full width. Put the primary action last, so it sits at the bottom on phones, nearest the thumb.
DialogPopup
What it is: The dialog panel. It scales up from 0.96 and fades in over 300ms, and scales and fades out in 200ms; reduced motion keeps only the fade.
DialogPortal
What it is: Moves the dialog to the end of <body>, in its own stacking context at the --ouro-layer z-index (50 by default, from ouro-base.css). If your app has fixed bars above z-50, raise it in a :root rule of your own after the Ouro tokens, outside any @layer: :root { --ouro-layer: 60; }. Don't edit the line shadcn wrote; shadcn add resets it.
DialogRoot
What it is: The state owner of a centered dialog built from parts. Use the Dialog component instead unless its props cannot express your layout; Dialog also becomes a bottom sheet on phones, which parts do not.
DialogTitle
What it is: The dialog's title, which names it. Every dialog needs one.
DialogTrigger
What it is: The button that opens the dialog. Pass your button as render={<Button>Open</Button>}.
DialogViewport
What it is: The full-screen layer that centers the popup and keeps it clear of the screen edges.