Dialog

Use Dialog for a focused task that needs the user's input, such as editing a profile or choosing a plan, without leaving the page. To ask the user to confirm or cancel one action, such as deleting a project, use Alert Dialog instead.

Installation

bash
npx @smicolon/fasla-ui@latest add dialog

Run init once per project before adding components. See Installation.

Dialog is built on Base UI, so it needs the @base-ui/react package, and its close and back buttons use button. add installs both with the component, and so does the shadcn CLI.

Preview

Press the button to open the dialog. Focus moves to the first field and Tab stays inside. Escape, the close button, Cancel or a press on the overlay closes it.

Sizes

size on DialogContent sets the width: lg, the default, is 512px, xl is 672px and 2xl is 1148px. Every size is 634px tall, whatever its content. On a smaller screen it keeps 16px from every edge. Choose by content: lg for a short form, 2xl for a table or an editor. Press a button to open that size.

Header

DialogHeader holds the title and an optional description 4px under it, with a line below them. Each stays on one line and ends with an ellipsis when it runs longer, so keep both short. The close button sits at the end of the title's line; showCloseButton on DialogContent turns it off. For a step in a flow, pass onBack to DialogHeader: a back button sits before the title. Its arrow points to the start of the line, so it turns round on an Arabic page.

Actions

Put Buttons in DialogFooter, and wrap Cancel in DialogClose so it closes the dialog. In a row, the default, write Cancel, then the main action: they sit at the end, 8px apart. A third action written before them, such as Skip, goes to the start of the row. layout="stacked" makes each action full width, one under the other, so write the main action first. For an action that can't be undone, give the main Button variant="destructive".

Long content

The dialog is 634px tall whatever its content. When the content is taller, the header and the footer stay where they are and only what sits between them scrolls: scroll inside the dialog below to read on. On a shorter screen the dialog keeps 16px from the top and the bottom and scrolls the same way. A field reached with Tab scrolls into view clear of the header and the footer.

Accessibility

  • The dialog has the dialog role. Its title names it and its description describes it, so a screen reader reads both when it opens.
  • When the dialog opens, focus moves to its first control, such as the first field. Tab and Shift+Tab stay inside the dialog, and focus returns to the trigger when it closes.
  • Escape, the close button, a DialogClose button and a press on the overlay all close the dialog.
  • The close button is named Close and the back button Back, or إغلاق and رجوع on an Arabic page. Their icons are hidden from screen readers.
  • While the dialog is open, the page behind it can't be reached with the keyboard or the pointer, and it doesn't scroll.
  • The dialog fades in as it grows from 98%. When reduced motion is on, it only fades.
  • The dialog takes the page's direction, language and font, though it renders at the end of body. On an Arabic page the whole layout mirrors, and the back arrow turns round. Direction needs no prop.

Props

Dialog

PropTypeDefaultDescription
openboolean—Shows or hides the dialog, when you control it.
defaultOpenbooleanfalseShows the dialog on first render.
onOpenChange(open, details) => void—Called when the dialog opens or closes.
modalboolean | "trap-focus"trueWhile the dialog is open, keeps focus inside it and stops the page scrolling. Pass false to let focus leave the dialog and the page scroll, or "trap-focus" to keep focus inside without locking the scroll.
childrenReactNode—The trigger and the content.

DialogTrigger

PropTypeDefaultDescription
renderReactElement—Renders your own element in place of the plain button, such as a Button.
disabledbooleanfalseStops this trigger opening the dialog.
childrenReactNode—The trigger's label.
classNamestring—Extra classes for the trigger.

DialogContent

PropTypeDefaultDescription
size"lg" | "xl" | "2xl""lg"The width: lg is 512px, xl 672px, 2xl 1148px.
showCloseButtonbooleantrueShows the close button at the end of the title's line.
closeLabelstring"Close"The close button's name for screen readers. Defaults to Close, or إغلاق on an Arabic page.
containerHTMLElement—Where the dialog renders. Defaults to body.
childrenReactNode—The header, the content and the footer.
classNamestring—Extra classes for the dialog box.

DialogHeader

PropTypeDefaultDescription
onBack(event) => void—Shows a back button before the title, and is called when it is pressed.
backLabelstring"Back"The back button's name for screen readers. Defaults to Back, or رجوع on an Arabic page.
childrenReactNode—The title and the description.
classNamestring—Extra classes for the header.

DialogTitle

PropTypeDefaultDescription
childrenReactNode—What the dialog is for.
classNamestring—Extra classes for the title.

DialogDescription

PropTypeDefaultDescription
childrenReactNode—A line on what to do in the dialog.
classNamestring—Extra classes for the description.

DialogFooter

PropTypeDefaultDescription
layout"horizontal" | "stacked""horizontal"horizontal puts the actions in a row at the end. stacked makes each one full width, one under the other.
showCloseButtonbooleanfalseAdds an Outline Close button that closes the dialog.
childrenReactNode—The actions: Buttons, with Cancel in a DialogClose.
classNamestring—Extra classes for the footer.

DialogClose

PropTypeDefaultDescription
renderReactElement—Renders your own element in place of the plain button, such as a Button.
onClick(event) => void—Runs before the dialog closes.
disabledbooleanfalseDisables the button.
childrenReactNode—The button's label, such as Cancel.
classNamestring—Extra classes for the button.

Usage

Edit a profile

tsx
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

export function EditProfile() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline" />}>Edit profile</DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Edit profile</DialogTitle>
          <DialogDescription>
            Update your name and photo.
          </DialogDescription>
        </DialogHeader>
        <Input label="Email address" type="email" placeholder="Type here" />
        <DialogFooter>
          <DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
          <Button type="submit">Save changes</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

Go back a step

tsx
"use client"

import * as React from "react"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

export function NewProject() {
  const [step, setStep] = React.useState(1)

  return (
    <Dialog onOpenChange={(open) => open && setStep(1)}>
      <DialogTrigger render={<Button />}>New project</DialogTrigger>
      <DialogContent>
        {step === 1 ? (
          <DialogHeader>
            <DialogTitle>Name your project</DialogTitle>
            <DialogDescription>Step 1 of 3.</DialogDescription>
          </DialogHeader>
        ) : (
          <DialogHeader onBack={() => setStep(1)}>
            <DialogTitle>Pick a workspace</DialogTitle>
            <DialogDescription>Step 2 of 3.</DialogDescription>
          </DialogHeader>
        )}
        {step === 1 ? (
          <Input label="Project name" placeholder="Orbit" />
        ) : (
          <Input label="Workspace name" placeholder="Design team" />
        )}
        <DialogFooter>
          <DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
          {step === 1 ? (
            <Button onClick={() => setStep(2)}>Next</Button>
          ) : (
            <DialogClose render={<Button />}>Create project</DialogClose>
          )}
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

Close after saving

tsx
"use client"

import * as React from "react"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

export function EditProfileAndSave({ onSave }: { onSave: (email: string) => Promise<void> }) {
  const [open, setOpen] = React.useState(false)
  const [pending, setPending] = React.useState(false)
  const [email, setEmail] = React.useState("")

  return (
    <Dialog open={open} onOpenChange={(next) => !pending && setOpen(next)}>
      <DialogTrigger render={<Button variant="outline" />}>Edit profile</DialogTrigger>
      <DialogContent>
        <form
          className="contents"
          onSubmit={async (event) => {
            event.preventDefault()
            setPending(true)
            try {
              await onSave(email)
              setOpen(false)
            } finally {
              setPending(false)
            }
          }}
        >
          <DialogHeader>
            <DialogTitle>Edit profile</DialogTitle>
          </DialogHeader>
          <Input
            label="Email address"
            type="email"
            value={email}
            onChange={(event) => setEmail(event.target.value)}
          />
          <DialogFooter>
            <DialogClose disabled={pending} render={<Button variant="outline" />}>
              Cancel
            </DialogClose>
            <Button type="submit" loading={pending} loadingLabel="Saving">
              Save changes
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  )
}