FairGarden Design

Dialog

A modal panel for a focused sub-task: a titled top bar, a scrolling body and an action bar, framed and never dimmed.

import {
  Dialog,
  DialogActions,
  DialogBody,
  DialogClose,
  DialogDescription,
  DialogEyebrow,
  DialogPopup,
  DialogTitle,
  DialogTopBar,
  DialogTrigger,
} from '@fairgarden-private/design/components/Dialog'

The panel renders in a portal as a white scope that follows the page mode, whatever the trigger's ground. Its --ds-stroke-3 frame carries the boundary in both modes; there is no tinted backdrop, so the page stays visible, inert and scroll-locked. Below 768 px the panel is a full-screen sheet with the actions docked at its foot; from 768 px it is centered. It opens instantly, or with a clip reveal where motion is allowed. Focus moves to the first field in the body. An open dialog prints alone.

Use a dialog for a short form or a detail view, never for marketing or exit-intent prompts. Put the solid action first, then its outline twin.

A focused task

A focused task

The top bar holds an optional eyebrow, the title (the dialog's accessible name) and the close X. DialogClose also closes from an action: pass the action Button as render.

Current name: Ridge Loop

DialogBasic.tsx
'use client'

import * as React from 'react'
import { Button } from '@fairgarden-private/design/components/Button'
import {
  Dialog,
  DialogActions,
  DialogBody,
  DialogClose,
  DialogDescription,
  DialogEyebrow,
  DialogPopup,
  DialogTitle,
  DialogTopBar,
  DialogTrigger,
} from '@fairgarden-private/design/components/Dialog'
import styles from './basic.module.css'

export function DialogBasic() {
  const [name, setName] = React.useState('Ridge Loop')
  const [draft, setDraft] = React.useState(name)

  return (
    <div className={styles.stack}>
      <Dialog
        onOpenChange={(open) => {
          if (open) setDraft(name)
        }}
      >
        <DialogTrigger variant="outline">Rename Trail</DialogTrigger>
        <DialogPopup>
          <DialogTopBar>
            <DialogEyebrow>Trail 12</DialogEyebrow>
            <DialogTitle>Rename Trail</DialogTitle>
            <DialogClose />
          </DialogTopBar>
          <DialogBody>
            <DialogDescription>
              The new name shows on the map, the trailhead sign list and the printed guide.
            </DialogDescription>
            <label className={styles.field}>
              <span className={styles.label}>Trail Name</span>
              <input
                className={styles.input}
                value={draft}
                onChange={(event) => setDraft(event.target.value)}
              />
            </label>
          </DialogBody>
          <DialogActions>
            <DialogClose
              render={
                <Button variant="solid" onClick={() => setName(draft.trim() || name)}>
                  Save Name
                </Button>
              }
            />
            <DialogClose render={<Button variant="outline">Cancel</Button>} />
          </DialogActions>
        </DialogPopup>
      </Dialog>
      <p className={styles.status} aria-live="polite">
        Current name: {name}
      </p>
    </div>
  )
}

Wide, with a solid cover

Wide, with a solid cover

wide sets the detail-view width. cover names the page ground's partner preset (paper → forest, white → night); from 768 px it renders as a fully opaque Ground that hides the page, never a tint. When the body overflows, a rule marks each edge with hidden content.

Not in your trip yet.

DialogCover.tsx
'use client'

import * as React from 'react'
import { Button } from '@fairgarden-private/design/components/Button'
import {
  Dialog,
  DialogActions,
  DialogBody,
  DialogClose,
  DialogDescription,
  DialogEyebrow,
  DialogPopup,
  DialogTitle,
  DialogTopBar,
  DialogTrigger,
} from '@fairgarden-private/design/components/Dialog'
import styles from './cover.module.css'

const stops = [
  ['Trailhead kiosk', 'Maps, a water tap and the day’s closures.'],
  ['Alder crossing', 'A plank bridge over the creek; slippery after rain.'],
  ['Meadow overlook', 'Bench and interpretive sign on the spring bird count.'],
  ['Old orchard', 'Heritage apples, fenced from deer since 2019.'],
  ['Ridge saddle', 'The high point at 412 m, with a view to the reservoir.'],
  ['Fern gully', 'Steep steps down to the spring; hold the rail.'],
  ['Beaver pond', 'Quiet water; dogs stay on the boardwalk.'],
  ['Return junction', 'Left for the car park, right for the long loop.'],
] as const

export function DialogCover() {
  const [added, setAdded] = React.useState(false)

  return (
    <div className={styles.stack}>
      <Dialog>
        <DialogTrigger variant="outline" icon="zoom_in">
          Open Trail Guide
        </DialogTrigger>
        <DialogPopup wide cover="forest">
          <DialogTopBar>
            <DialogEyebrow>Field Guide</DialogEyebrow>
            <DialogTitle>Ridge Loop, Stop by Stop</DialogTitle>
            <DialogClose />
          </DialogTopBar>
          <DialogBody>
            <DialogDescription>
              Eight stops over 6.4 km. Scroll the list: a rule marks each edge with hidden content.
            </DialogDescription>
            <ol className={styles.list}>
              {stops.map(([name, note]) => (
                <li key={name} className={styles.stop}>
                  <strong className={styles.name}>{name}</strong>
                  <span>{note}</span>
                </li>
              ))}
            </ol>
          </DialogBody>
          <DialogActions>
            <DialogClose
              render={
                <Button variant="solid" onClick={() => setAdded(true)}>
                  Add to Trip
                </Button>
              }
            />
            <DialogClose render={<Button variant="outline">Close Guide</Button>} />
          </DialogActions>
        </DialogPopup>
      </Dialog>
      <p className={styles.status} aria-live="polite">
        {added ? 'Ridge Loop is in your trip.' : 'Not in your trip yet.'}
      </p>
    </div>
  )
}

API Reference

Dialog and DialogTrigger

Groups the parts of a dialog (Base UI Dialog.Root). Modal by default: the page stays visible, inert and scroll-locked, never dimmed [D24 → D121].

Opens the dialog. Renders a Button (§9.2); author its label in title case [D160].

PropTypeDescription
butted
'start' | 'end' | null | undefined

Butts the button against an adjacent field on its start or end edge, as in the butted submit. Default: none.

handle
DialogHandle<unknown> | undefined

Associates a detached trigger with a Dialog created by Dialog.createHandle.

icon
| 'menu'
| 'search'
| 'circle'
| 'arrow_forward'
| 'arrow_upward'
| 'expand_more'
| 'close'
| 'remove'
| 'add'
| 'check'
| 'chevron_right'
| 'chevron_left'
| 'more_horiz'
| 'play_arrow'
| 'pause'
| 'download'
| 'zoom_in'
| 'zoom_out'
| 'recenter'
| 'help'
| 'mail'
| undefined

One optional functional glyph (§6.10), inline tier, FILL 0.

iconOnly
boolean | null | undefined

true hides the label and shows only icon. Default false.

iconPosition
'start' | 'end' | undefined

Which side of the label the glyph sits on (a text Button’s chevron trails).

onMedia
false | boolean | null | undefined

Only with iconOnly.

onPress
PressCallback | undefined

Called on click, after onClick.

payload
unknown | undefined

A payload handed to the Dialog’s children function when this trigger opens it.

primary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| null
| undefined

Primary Radix scale, from the primary roster: the outline edge, labels and focus ring. Never defaulted; omitted, it inherits the scope [D133].

secondary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| 'amber'
| 'blue'
| 'cyan'
| 'grass'
| 'green'
| 'jade'
| 'lime'
| 'mint'
| 'orange'
| 'sky'
| 'teal'
| 'yellow'
| null
| undefined

Secondary Radix scale: the solid fill and the text glyph. Omitted, solid falls back to the scope’s action scale.

size
'sm' | 'md' | 'lg' | 'xl' | null | undefined

Fixed height: sm 32 px (hit area extended to 44), md 40 px (default), lg 48 px, xl 56 px for the page’s single transactional action.

variant
'text' | 'solid' | 'outline' | null | undefined

outline (default) is the outline twin; solid is the page’s primary action, filled with the scope’s action scale; text is a text button whose glyph trails (§9.2).

children
React.ReactNode | undefined

The label: verb plus object, authored in title case [D160].

DialogPopup

The modal panel, rendered in its Base UI Portal as a nested white scope with the --ds-stroke-3 --primary12 frame. Below --md-n-above it is a full-screen opaque sheet; from --md-n-above it is centered, at least --ds-space-40 from every edge. It opens instantly or with a clip reveal, never a fade. Compose DialogTopBar (eyebrow, title, close), DialogBody and DialogActions inside it.

Focus moves to the first field in the body, else to Base UI’s default (the first tabbable element); pass initialFocus to override.

PropTypeDescription
cover
| 'pink'
| 'amber'
| 'forest'
| 'leaf'
| 'clay'
| 'royal'
| 'brick'
| undefined

The optional solid cover, from --md-n-above: the page ground’s companion field [D177] (paper, white, meadow and pollen → forest; tide and heather → royal; apricot and rose → brick; see companionField from Ground). It renders as a kind="field" Ground of that preset at full-viewport geometry with no edge, fully opaque, hiding the page [D88 → D121]. Omitted (default), no backdrop is painted.

primary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| null
| undefined

Primary Radix scale inside the panel’s white scope: text, frame and rules. Omitted, the white preset’s default; never the trigger’s [D133].

secondary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| 'amber'
| 'blue'
| 'cyan'
| 'grass'
| 'green'
| 'jade'
| 'lime'
| 'mint'
| 'orange'
| 'sky'
| 'teal'
| 'yellow'
| null
| undefined

Secondary Radix scale inside the panel: accents only. Omitted, the white preset’s default.

wide
boolean | null | undefined

true: the detail-view width, --ds-measure-reading, from --md-n-above. Default false.

container
| HTMLElement
| ShadowRoot
| React.RefObject<HTMLElement | ShadowRoot | null>
| null
| undefined

The element the portal renders into. Default: document.body.

keepMounted
boolean | undefined

Keeps the portal mounted while closed.

DialogTopBar, DialogEyebrow, DialogTitle and DialogClose

The titled top bar: an optional DialogEyebrow, the DialogTitle and the DialogClose X. At least --ds-size-control-xl tall; on the sheet a --ds-stroke-3 --primary12 rule runs under it.

An optional eyebrow above the title, in tracked caps (type-eyebrow); dropped below 360 px.

The dialog’s title and accessible name (Base UI Dialog.Title, an h2), in type-itemhead. Every dialog has one.

Closes the dialog. By default the top bar’s X: an icon-only Button with the inline-tier close glyph and a --ds-size-hit target (§9.2). Pass render (for example <Button variant="outline">Cancel</Button>) to close from an action instead; label is then ignored.

PropTypeDescription
label
string | undefined

The close X’s accessible name, visually hidden. Default “Close”.

DialogBody, DialogDescription and DialogActions

The scrolling body: description and content. While content is hidden above or below, a --border-size-2 --role-rule edge shows under the top bar or above the action bar (the §10.19 overflow edge), never a fade.

The dialog’s description (Base UI Dialog.Description), in type-body-ui --primary12.

The action bar: a solid Button first, then its outline twin, each hugging its label. Stacked and docked at the bottom of the sheet below --md-n-above; one row from it [D106].

Additional types

dialog
type dialog = dialog
DialogActionsProps

Props for DialogActions: div props and render.

type DialogActionsProps = useRender.ComponentProps<'div'>
dialogBackdrop

The Backdrop part: colorless by default; covered is the opaque solid cover (from --md-n-above).

type dialogBackdrop = dialogBackdrop
DialogBodyProps

Props for DialogBody: div props and render.

type DialogBodyProps = useRender.ComponentProps<'div'>
DialogCloseProps

Props for DialogClose: Base UI Dialog.Close props plus the X’s accessible name.

type DialogCloseProps = DialogCloseProps & { label?: string }
DialogDescriptionProps

Props for DialogDescription: Base UI Dialog.Description props.

type DialogDescriptionProps = DialogDescription.DialogDescriptionProps
DialogEyebrowProps

Props for DialogEyebrow: p props and render.

type DialogEyebrowProps = useRender.ComponentProps<'p'>
DialogPopupProps

Props for DialogPopup: Base UI Dialog.Popup props plus the panel axes, the cover and the portal options.

type DialogPopupProps = DialogPopupProps & {
  wide?: boolean | null;
  primary?:
    | 'ruby'
    | 'olive'
    | 'sage'
    | 'slate'
    | 'sand'
    | 'gray'
    | 'mauve'
    | 'brown'
    | 'bronze'
    | 'gold'
    | 'red'
    | 'crimson'
    | 'tomato'
    | 'pink'
    | 'plum'
    | 'indigo'
    | 'iris'
    | 'violet'
    | 'purple'
    | null;
  secondary?:
    | 'ruby'
    | 'olive'
    | 'sage'
    | 'slate'
    | 'sand'
    | 'gray'
    | 'mauve'
    | 'brown'
    | 'bronze'
    | 'gold'
    | 'red'
    | 'crimson'
    | 'tomato'
    | 'pink'
    | 'plum'
    | 'indigo'
    | 'iris'
    | 'violet'
    | 'purple'
    | 'amber'
    | 'blue'
    | 'cyan'
    | 'grass'
    | 'green'
    | 'jade'
    | 'lime'
    | 'mint'
    | 'orange'
    | 'sky'
    | 'teal'
    | 'yellow'
    | null;
  cover?: 'pink' | 'amber' | 'forest' | 'leaf' | 'clay' | 'royal' | 'brick';
  container?: HTMLElement | ShadowRoot | React.RefObject<HTMLElement | ShadowRoot | null> | null;
  keepMounted?: boolean;
}
DialogProps

Props for Dialog: Base UI Dialog.Root props (open, onOpenChange, modal …).

type DialogProps<Payload = unknown> = DialogRoot.Props<Payload>
DialogTitleProps

Props for DialogTitle: Base UI Dialog.Title props.

type DialogTitleProps = DialogTitle.DialogTitleProps
DialogTopBarProps

Props for DialogTopBar: div props and render.

type DialogTopBarProps = useRender.ComponentProps<'div'>
DialogTriggerProps

Props for DialogTrigger: Button props plus Base UI’s trigger handle and payload.

type DialogTriggerProps = (
  | {
      iconOnly?: false | null;
      onMedia?: false | null;
      size?: 'sm' | 'md' | 'lg' | 'xl' | null;
      icon?:
        | 'menu'
        | 'search'
        | 'circle'
        | 'arrow_forward'
        | 'arrow_upward'
        | 'expand_more'
        | 'close'
        | 'remove'
        | 'add'
        | 'check'
        | 'chevron_right'
        | 'chevron_left'
        | 'more_horiz'
        | 'play_arrow'
        | 'pause'
        | 'download'
        | 'zoom_in'
        | 'zoom_out'
        | 'recenter'
        | 'help'
        | 'mail';
      children?: React.ReactNode;
    }
  | {
      iconOnly: true;
      onMedia?: boolean | null;
      size?: 'sm' | 'md' | 'lg' | null;
      icon:
        | 'menu'
        | 'search'
        | 'circle'
        | 'arrow_forward'
        | 'arrow_upward'
        | 'expand_more'
        | 'close'
        | 'remove'
        | 'add'
        | 'check'
        | 'chevron_right'
        | 'chevron_left'
        | 'more_horiz'
        | 'play_arrow'
        | 'pause'
        | 'download'
        | 'zoom_in'
        | 'zoom_out'
        | 'recenter'
        | 'help'
        | 'mail';
      children: React.ReactNode;
    }
) & {
  /**
   * `outline` (default) is the outline twin; `solid` is the page's primary
   * action, filled with the scope's action scale; `text` is a text button
   * whose glyph trails (§9.2).
   */
  variant?: 'text' | 'solid' | 'outline' | null;
  /**
   * Butts the button against an adjacent field on its `start` or `end`
   * edge, as in the butted submit. Default: none.
   */
  butted?: 'start' | 'end' | null;
  /**
   * Primary Radix scale, from the primary roster: the outline edge, labels
   * and focus ring. Never defaulted; omitted, it inherits the scope [D133].
   */
  primary?:
    | 'ruby'
    | 'olive'
    | 'sage'
    | 'slate'
    | 'sand'
    | 'gray'
    | 'mauve'
    | 'brown'
    | 'bronze'
    | 'gold'
    | 'red'
    | 'crimson'
    | 'tomato'
    | 'pink'
    | 'plum'
    | 'indigo'
    | 'iris'
    | 'violet'
    | 'purple'
    | null;
  /**
   * Secondary Radix scale: the `solid` fill and the `text` glyph. Omitted,
   * `solid` falls back to the scope's action scale.
   */
  secondary?:
    | 'ruby'
    | 'olive'
    | 'sage'
    | 'slate'
    | 'sand'
    | 'gray'
    | 'mauve'
    | 'brown'
    | 'bronze'
    | 'gold'
    | 'red'
    | 'crimson'
    | 'tomato'
    | 'pink'
    | 'plum'
    | 'indigo'
    | 'iris'
    | 'violet'
    | 'purple'
    | 'amber'
    | 'blue'
    | 'cyan'
    | 'grass'
    | 'green'
    | 'jade'
    | 'lime'
    | 'mint'
    | 'orange'
    | 'sky'
    | 'teal'
    | 'yellow'
    | null;
  /** Which side of the label the glyph sits on (a `text` Button's chevron trails). */
  iconPosition?: 'start' | 'end';
  /**
   * Called on click, after `onClick`.
   * @deprecated Use `onClick`. Kept as an alias from the react-aria Button.
   */
  onPress?: PressCallback;
  /** Associates a detached trigger with a Dialog created by `Dialog.createHandle`. */
  handle?: DialogHandle<unknown>;
  /** A payload handed to the Dialog's children function when this trigger opens it. */
  payload?: unknown;
}

Specification: DESIGN-SYSTEM.md §10.14 (dialog), §10.1 (the overlay surface and backdrop) and §1.3.2 (no scrims).