Skip to content

Dialog

Dialog

<DialogTrigger>
<Button>Edit profile</Button>
<Dialog title="Edit profile" description="Update your visible information.">
{({ close }) => (
<Stack gap={4}>
<TextInput label="Name" />
<Stack direction="row" justify="end" gap={2}>
<Button variant="ghost" tone="neutral" onPress={close}>Cancel</Button>
<Button onPress={close}>Save</Button>
</Stack>
</Stack>
)}
</Dialog>
</DialogTrigger>

Install: @helixui/core

import { Dialog, DialogTrigger } from '@helixui/core'

status: stable · since: 0.1.0

Tags: modal, blocking, confirm, focus-trap, dismissable

Anatomy

┌─ overlay (full screen, dimmed) ────────────────────────┐
│ │
│ ┌── dialog panel ─────────────────────────┐ │
│ │ Title (h2 slot) │ │
│ │ Description │ │
│ │ │ │
│ │ <children — usually a Stack form> │ │
│ │ │ │
│ │ [ Cancel ] [ Confirm ] ← actions row │ │
│ └──────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────┘
↑ trapped focus, dismiss on overlay-click + Escape (when isDismissable)

Layout

  • displayportal
  • widthfixed:380/540/720px (sm/md/lg)
  • heightcontent
  • intrinsicSizecentered, max-w by size, max-h ~80vh, scrolls inside
  • stackablefalse
  • fullBleedfalse

Visual

A centered surface (radius.xl) on a dimmed overlay. The panel uses surface.default with a strong shadow (shadow.lg) and ample padding (space.6). Title is large semibold (font.size.xl + font.weight.semibold), description in muted secondary text below. Mounts with a subtle scale-and-fade (respects prefers-reduced-motion). Body scrolls internally when it overflows; header and footer remain pinned only if you compose them with Stack.

Props

NameTypeDefaultDescription
titleReactNodeDialog title — rendered as <h2 slot="title"> for screen readers.
descriptionReactNodeLead paragraph below the title.
size'sm' | 'md' | 'lg'mdMax width — 380 / 540 / 720 px.
isDismissablebooleantrueAllow close via overlay click and Escape.
isOpenbooleanControlled open state.
defaultOpenbooleanfalseUncontrolled initial open.
onOpenChange(open: boolean) => voidOpen change handler.

Slots

  • DialogTrigger child — the button that opens the dialog
  • Dialog children — content; render-prop receives { close } to close imperatively

Tokens used

color.bg.surface.default, color.text.primary, color.text.secondary, radius.xl, space.4, space.6, space.12, font.family.sans, font.size.sm, font.size.xl, font.weight.semibold, font.lineHeight.tight, shadow.lg

Accessibility

Role: dialog

Keyboard

KeyAction
TabCycles through focusable content. Focus is trapped.
EscapeCloses the dialog when isDismissable.

Notes

  • Always provide title (or set aria-label on the Dialog) so screen readers announce the dialog’s purpose.
  • Animations respect prefers-reduced-motion.

Composes with

ComponentRelationNote
DialogTriggerparentRequired wrapper that provides the open trigger element.
ButtontriggerMost common trigger inside DialogTrigger.
StackchildRecommended layout for the dialog body and its actions row.
FormchildForms inside dialogs submit and then close via the render-prop close().
SheetalternativeOn small screens prefer Sheet (side=‘bottom’) for the same task.
PopoveralternativeUse Popover for non-blocking, anchored interactions.

Prompt examples

These are the AI prompt → JSX mappings used by the helixui prompt DSL and integrations like Cursor / Claude Code.

destructive confirmation

“delete account confirmation modal”

<DialogTrigger>
<Button tone="danger">Delete account</Button>
<Dialog title="Delete account?" description="This cannot be undone.">
{({ close }) => (
<Stack direction="row" justify="end" gap={2}>
<Button variant="ghost" onPress={close}>Cancel</Button>
<Button tone="danger" onPress={close}>Delete</Button>
</Stack>
)}
</Dialog>
</DialogTrigger>

small form in a modal

“modal to rename a project”

<DialogTrigger>
<Button variant="soft">Rename</Button>
<Dialog title="Rename project" size="sm">
{({ close }) => (
<Stack gap={3}>
<TextInput label="Name" />
<Stack direction="row" justify="end" gap={2}>
<Button variant="ghost" onPress={close}>Cancel</Button>
<Button onPress={close}>Save</Button>
</Stack>
</Stack>
)}
</Dialog>
</DialogTrigger>

non-dismissable required choice

“accept terms blocking modal that cannot be dismissed”

<Dialog isOpen title="Accept the terms" isDismissable={false}>
{({ close }) => (
<Stack gap={4}>
<Text>You must accept the terms to continue.</Text>
<Button onPress={close}>I agree</Button>
</Stack>
)}
</Dialog>

controlled open/close

“dialog whose open state lives in my component”

const [open, setOpen] = useState(false);
<Dialog isOpen={open} onOpenChange={setOpen} title="Edit">
...
</Dialog>