EXEPERTAI LAB

Research alpha

Time Machine
EXEPERTAI LAB
GALLERY / COLLECTION
← Browse Astryx gallery
Dialog·component·@astryxdesign/core

Dialog

Modal dialog using the native <dialog> element. Modal and inline content starts with theme body text defaults. Ancestor surface/group membership ends as a whole, including group-owned state. Explicit props and unrelated contexts remain unchanged. Place intentional groups and complete required providers inside the dialog.

Open in Playground @astryxdesign/core/Dialog

Showcases and examples

7 documented examples

Dialog — Adaptive presentation

Opt-in recipe for an AdaptiveDialog wrapper: Dialog remains the default everywhere, while touchPresentation="bottom-sheet" switches only at lg and below when pointer is coarse and hover is unavailable. Includes a deterministic presentation override for tests/unusual environments and notes that BottomSheet purpose controls swipe and scrim dismissal. Usage examples: touchPresentation="dialog" keeps Dialog, "fullscreen" chooses fullscreen Dialog, and "bottom-sheet" chooses BottomSheet only for the touch-oriented range. Keep presentation as the deterministic override. Do not use this by default for AlertDialog or destructive confirmations.

Preview loads on approachPreview loads on approach
Exact source · dialog-adaptive-presentation
// Copyright (c) Meta Platforms, Inc. and affiliates.

'use client';

import {useState, type ReactNode} from 'react';
import {BottomSheet} from '@astryxdesign/core/BottomSheet';
import {Button} from '@astryxdesign/core/Button';
import {
  Dialog,
  DialogHeader,
  type DialogPurpose,
} from '@astryxdesign/core/Dialog';
import {Heading} from '@astryxdesign/core/Heading';
import {
  HStack,
  Layout,
  LayoutContent,
  LayoutFooter,
  VStack,
} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {TextArea} from '@astryxdesign/core/TextArea';
import {TextInput} from '@astryxdesign/core/TextInput';
import {useMediaQuery} from '@astryxdesign/core/hooks';

const TOUCH_ORIENTED_LG_QUERY =
  '(max-width: 1024px) and (pointer: coarse) and (hover: none)';

type AdaptivePresentation = 'dialog' | 'fullscreen' | 'bottom-sheet';

type AdaptiveDialogProps = {
  isOpen: boolean;
  onOpenChange: (isOpen: boolean) => void;
  title: string;
  children: ReactNode;
  footer?: ReactNode;
  purpose?: DialogPurpose;
  width?: number | string;
  maxHeight?: number | string;
  touchPresentation?: AdaptivePresentation;
  presentation?: AdaptivePresentation;
  bottomSheetHeight?: 'hug' | 'capped' | 'tall' | number | string;
};

function AdaptiveDialog({
  isOpen,
  onOpenChange,
  title,
  children,
  footer,
  purpose = 'info',
  width = 480,
  maxHeight = '75dvh',
  touchPresentation = 'dialog',
  presentation,
  bottomSheetHeight = 'capped',
}: AdaptiveDialogProps) {
  const isTouchOrientedLargeOrBelow = useMediaQuery(TOUCH_ORIENTED_LG_QUERY);
  const resolvedPresentation =
    presentation ??
    (isTouchOrientedLargeOrBelow ? touchPresentation : 'dialog');

  if (resolvedPresentation === 'bottom-sheet') {
    return (
      <BottomSheet
        isOpen={isOpen}
        onOpenChange={onOpenChange}
        label={title}
        purpose={purpose}
        height={bottomSheetHeight}>
        <VStack gap={4} style={{padding: 'var(--spacing-4)'}}>
          <Heading level={3}>{title}</Heading>
          {children}
          {footer}
        </VStack>
      </BottomSheet>
    );
  }

  const dialogContent = (
    <Layout
      header={<DialogHeader title={title} onOpenChange={onOpenChange} />}
      content={<LayoutContent>{children}</LayoutContent>}
      footer={footer ? <LayoutFooter>{footer}</LayoutFooter> : undefined}
    />
  );

  return (
    <Dialog
      isOpen={isOpen}
      onOpenChange={onOpenChange}
      purpose={purpose}
      width={width}
      maxHeight={maxHeight}
      variant={
        resolvedPresentation === 'fullscreen' ? 'fullscreen' : 'standard'
      }>
      {dialogContent}
    </Dialog>
  );
}

export default function DialogAdaptivePresentation() {
  const [isOpen, setIsOpen] = useState(false);
  const [name, setName] = useState('Ruby Cheung');
  const [email, setEmail] = useState('ruby@example.com');
  const [notes, setNotes] = useState('');

  return (
    <>
      <Button label="Edit profile" onClick={() => setIsOpen(true)} />
      {/*
        touchPresentation examples:
        - "dialog" keeps Dialog even in touch-oriented <=lg contexts.
        - "fullscreen" uses fullscreen Dialog there.
        - "bottom-sheet" uses BottomSheet there.
        presentation="dialog" | "fullscreen" | "bottom-sheet" overrides
        the media query for tests and unusual environments.
      */}
      <AdaptiveDialog
        isOpen={isOpen}
        onOpenChange={setIsOpen}
        title="Edit profile"
        purpose="form"
        touchPresentation="bottom-sheet"
        bottomSheetHeight="tall"
        footer={
          <HStack gap={2} hAlign="end" wrap="wrap">
            <Button
              label="Cancel"
              variant="secondary"
              onClick={() => setIsOpen(false)}
            />
            <Button
              label="Save profile"
              variant="primary"
              onClick={() => setIsOpen(false)}
            />
          </HStack>
        }>
        <VStack gap={4}>
          <Text type="supporting" color="secondary">
            Dialog remains the default presentation. This example explicitly
            opts into a Bottom Sheet only at lg and below when the device has a
            coarse pointer and no hover. Pass the presentation prop to make
            tests or unusual environments deterministic.
          </Text>
          <Text type="supporting" color="secondary">
            In Bottom Sheet presentation, purpose="form" blocks scrim clicks and
            swipe dismissal while preserving Escape. Use this opt-in only when
            that contract is acceptable; keep AlertDialog/destructive
            confirmations on Dialog unless a product deliberately chooses
            otherwise.
          </Text>
          <TextInput label="Name" value={name} onChange={setName} />
          <TextInput
            label="Email"
            type="email"
            value={email}
            onChange={setEmail}
          />
          <TextArea label="Notes" rows={6} value={notes} onChange={setNotes} />
        </VStack>
      </AdaptiveDialog>
    </>
  );
}

Usage

Dialog displays a modal overlay that blocks interaction with the page until the user responds. Use it for delete confirmations, edit forms, terms acceptance, or any decision that should not be skipped.

For cases where you want to show a dialog without managing open state, use the useImperativeDialog hook: call dialog.show(content) and render dialog.element in your tree.

  • Choose the right purpose: info for dismissable content, form to prevent accidental backdrop dismissal, required when the user must respond.
  • Include a clear title in the header so users immediately understand what the dialog is asking.
  • Use purpose="form" for dialogs with inputs so the user can't accidentally lose data by clicking the backdrop.
  • Keep dialogs focused on a single task; if the content grows beyond what fits, consider a full page instead.
  • Use a dialog for simple messages that could be shown inline or as a toast notification.
  • Nest dialogs inside other dialogs; restructure the flow into steps within a single dialog instead.
  • Use the fullscreen variant for simple confirmations; it is meant for complex content like editors or long forms.

Typed props

PropType and behavior
isOpenboolean · required
Whether the dialog is open.
onOpenChange(isOpen: boolean) => unknown · required
Callback when dialog visibility changes.
childrenReactNode · required
Dialog content.
widthnumber | string · default 400
Preferred width of the dialog in pixels or any CSS value. Standard dialogs clamp to their container and the dynamic viewport with spacing-token gutters so narrow viewports keep content on screen.
maxHeightnumber | string · default '75dvh'
Maximum height of the dialog. Defaults to a dynamic viewport value so browser UI changes are reflected where supported.
positionDialogPosition
Static position for the dialog; centered by default when omitted. Use logical start/end for inline offsets so positioned dialogs mirror correctly under RTL.
variant'standard' | 'fullscreen' · default 'standard'
Dialog variant: fullscreen expands to fill the entire viewport.
purpose'required' | 'form' | 'info' · default 'info'
Controls dismissal behavior: required disables Escape and backdrop click; form disables backdrop click after interaction; info allows both.
padding0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10
Internal padding of the dialog using the spacing scale step.
isInlineboolean · default false
Renders dialog content inline without the <dialog> element, backdrop, or modal behavior. For documentation previews and showcases only.

Anatomy

Header · required

Title, optional subtitle, and close button. The title receives focus on open and labels the dialog via aria-labelledby.

Body · required

The main content area: text, forms, lists, or any layout.

Footer · optional

Action buttons like Save/Cancel or Accept/Decline, aligned to the end.

Backdrop · required

Semi-transparent overlay behind the dialog that blocks page interaction.

Theming

Container theming is enabled.

Targets

astryx-dialog

Visual props: variant

astryx-dialog-header
astryx-dialog-header-start-content
astryx-dialog-header-title-block
astryx-dialog-header-end-content
astryx-dialog-header-close-icon

Variables

--_dialog-radius · private

Border radius of the dialog

Default: var(--radius-container)

Derived properties

borderRadius

Uses --_dialog-radius.

padding

Expands: container