Skip to content

Dialog

Focus on a short task in a modal window that keeps keyboard focus inside.

import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } from '@youj/react'

Installation & usage

npm i @youj/react

For an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.

Use Dialog for a short task that needs attention, such as renaming a project or inviting a teammate. It blocks the rest of the page, so keep it focused. Use a Sheet for more space, a Drawer for swipe dismissal, or a Popover for controls that leave the page usable. Confirm destructive actions with an Alert Dialog.

In the Next.js App Router, render this from a Client Component, a file that starts with 'use client': it takes functions (onOpenChange), and a Server Component can't pass those. Server rendering has the rest.

import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@youj/react'

export function InviteDialog() {
  return (
    <Dialog>
      <DialogTrigger render={<Button />}>Invite</DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Invite to Orion</DialogTitle>
          <DialogDescription>They can see and deploy every branch of the project.</DialogDescription>
        </DialogHeader>
        <InviteForm id="invite" />
        <DialogFooter>
          <DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
          <Button type="submit" form="invite">
            Send invite
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

DialogContent renders the whole overlay: a portal, the scrim, a viewport that centers the popup, and the popup with a × button in its top corner. DialogTrigger and DialogClose take a render prop, so any Button can open or close the dialog. Dialog is Base UI's root, so open, defaultOpen, onOpenChange, modal and disablePointerDismissal work as Base UI documents them.

Reference

PropsshowCloseButton, presentation (2)

Props

DialogContent adds two props to Base UI's Dialog.Popup: showCloseButton and presentation.

DialogContent props
PropType
showCloseButtonboolean

Renders the × button in the top corner.

default true

true
presentationDialogPresentation
'auto''sheet''dialog'

auto (the default) is a centered dialog under a pointer and a bottom sheet on touch; dialog or sheet forces one.

default 'auto'

'auto'
Other props pass through: extends DialogPrimitive.Popup.Props.

Dialog (Root) passes its props through to Base UI and adds sound: with sound on (YoujProvider’s sound) opening plays the open cue and closing the close cue, and sound={false} keeps that dialog silent. The other parts add styling only and pass their props through to Base UI: DialogTrigger, DialogClose, DialogTitle, DialogDescription, DialogOverlay (Backdrop) and DialogPortal. DialogHeader and DialogFooter are plain divs. The footer stacks its actions with the primary one on top on narrow screens, and lines them up at the end from the sm breakpoint; in a touch sheet they always stack at full width.

TouchOn a touch device the dialog is a bottom sheet

Touch

On a touch device the dialog is a bottom sheet. It rises from the bottom of the screen over the scrim, spans the screen's width up to 36rem and has a grab handle. Pull it down to close it, alongside Escape, the × and a tap on the scrim: a quick flick closes it from a short pull, and a short, slow pull springs back. Focus returns to the trigger, as it does for the centered dialog. Content taller than 90% of the screen scrolls inside the sheet, and a pull only moves the sheet once that content is scrolled to the top. The sheet clears the home indicator and moves up to stay above the on-screen keyboard.

The footer's actions stack at full width with the primary one on top, each at least 44px tall, and the × gets a 44px target. Opened by a tap, focus goes to the sheet rather than its first field, so the keyboard comes up only when someone taps the field. A dialog opened from a sheet is a sheet too, and the one below recedes. A dialog that refuses to close, say while it saves, springs back when pulled. A control that handles its own drag, such as a crop area or a slider, should carry data-sheet-no-drag (an element with touch-action: none is skipped too), so dragging it never moves the sheet.

presentation on DialogContent chooses: auto (the default) is the centered dialog under a mouse or trackpad and a sheet on touch, sheet always uses the sheet, and dialog keeps the centered dialog on touch too. Touch follows the device, not the screen width, and YoujProvider's device can force it for a part of the page. For a panel that is a swipeable sheet everywhere, use Drawer.

MotionEnter, exit, scrim in… · reduced motion

Motion

Motion tokens
MomentTokens
Enterrises 6px and scales up from .96 as it fades in (yj-dialog)
duration.baseease.enter

220ms · cubic-bezier(0.16, 1, 0.3, 1)

220mscubic-bezier(0.16, 1, 0.3, 1)
Exitthe same path back, faster
duration.fastease.exit

140ms · cubic-bezier(0.4, 0, 1, 1)

140mscubic-bezier(0.4, 0, 1, 1)
Scrim inopacity (yj-backdrop)
duration.baseease.standard

220ms · cubic-bezier(0.2, 0, 0, 1)

220mscubic-bezier(0.2, 0, 0, 1)
Scrim out
duration.fastease.standard

140ms · cubic-bezier(0.2, 0, 0, 1)

140mscubic-bezier(0.2, 0, 0, 1)
Parent recedes0.875rem down and 6% smaller per nested level, under a dimming wash
duration.baseease.standard

220ms · cubic-bezier(0.2, 0, 0, 1)

220mscubic-bezier(0.2, 0, 0, 1)
Parent returnswhen the nested dialog closes
duration.baseease.enter

220ms · cubic-bezier(0.16, 1, 0.3, 1)

220mscubic-bezier(0.16, 1, 0.3, 1)
Touch: enterthe sheet slides up from the bottom edge (yj-sheet-bottom)
duration.slowease.enter

340ms · cubic-bezier(0.16, 1, 0.3, 1)

340mscubic-bezier(0.16, 1, 0.3, 1)
Touch: pullthe sheet follows the finger and the scrim thins
no token

1:1

1:1
Touch: releasepast a quarter of its height or on a flick it leaves (a flick twice as fast); otherwise it springs back
duration.baseease.exit

220ms · cubic-bezier(0.4, 0, 1, 1)

220mscubic-bezier(0.4, 0, 1, 1)

Reduced motion

Opens and closes instantly. A parent under a nested dialog takes its receded place and dims at once. On touch the sheet appears and leaves at once, and a pull still tracks the finger.

The presets run on CSS transitions, not keyframes, so closing a dialog halfway through its entrance reverses from where it is. The popup is centered by the viewport's layout, not by a -50% translate, so its text stays sharp at every height.

Reduced motion

The dialog and its scrim appear and disappear at once, with no rise, scale or fade. Focus handling and the scrim are unchanged, so the dialog behaves the same, only without motion. Portaled content follows the preview's Reduced motion switch because DialogContent spreads useYoujScope() on its portal.

AccessibilityKeyboard, ARIA, focus

Accessibility

  • The popup has role="dialog" and aria-modal. DialogTitle names it and DialogDescription describes it, through aria-labelledby and aria-describedby. Always render a title, even a visually hidden one.
  • Focus moves into the dialog when it opens: to the first field or button, or to the dialog itself when that control is below the fold or the dialog was opened by a tap. Pass initialFocus to choose another element.
  • Focus is trapped while the dialog is open. Tab and Shift+Tab cycle through its controls, and the page behind is inert.
  • Escape, a press on the scrim and the × button close it. Focus then returns to the trigger, or to the element that had it before the dialog opened.
  • The × button is named "Close" for screen readers.