Modal Surfaces
The four modal surfaces that compose the shared overlay anatomy — a centred dialog, a confirmation alert dialog, an edge sheet and a bottom drawer.
Dialog — Modal dialog with focus trap, Escape-to-close, focus restoration, background inertness and a teleported overlay. Supports sm/md/lg/xl/full size presets, a scrollable body, sticky header/footer, layered Escape for nested content, and an opt-in subject-change announcement.
Preview
Edit profile
Make changes to your profile here. Click save when you're done.
<x-ui.dialog>
<x-ui.dialog.trigger class="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
{{ __('Edit profile') }}
</x-ui.dialog.trigger>
<x-ui.dialog.content>
<x-ui.dialog.close />
<x-ui.dialog.header>
<x-ui.dialog.title>{{ __('Edit profile') }}</x-ui.dialog.title>
<x-ui.dialog.description>{{ __("Make changes to your profile here. Click save when you're done.") }}</x-ui.dialog.description>
</x-ui.dialog.header>
<div class="mt-4 space-y-2">
<x-ui.label for="name">{{ __('Name') }}</x-ui.label>
<x-ui.input id="name" value="Ada Lovelace" />
</div>
<x-ui.dialog.footer>
<button type="button" @click="hide()" class="inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted">
{{ __('Cancel') }}
</button>
<button type="button" @click="hide()" class="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90">
{{ __('Save changes') }}
</button>
</x-ui.dialog.footer>
</x-ui.dialog.content>
</x-ui.dialog>
Installation
php artisan ui:add dialog
Note
This component ships an Alpine behavior module at
resources/js/ui/dialog.js. Import it once from your bundle so it registers on alpine:init:
import './dialog.js';
Registry contract
php artisan ui:add dialog
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/dialog.blade.php -
resources/views/components/ui/dialog/trigger.blade.php -
resources/views/components/ui/dialog/content.blade.php -
resources/views/components/ui/dialog/header.blade.php -
resources/views/components/ui/dialog/title.blade.php -
resources/views/components/ui/dialog/description.blade.php -
resources/views/components/ui/dialog/footer.blade.php -
resources/views/components/ui/dialog/close.blade.php -
resources/js/ui/dialog.js -
resources/js/ui/overlay-lifecycle.js
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Dialog (`dialog`)
Modal dialog with focus trap, Escape-to-close, focus restoration, background inertness and a teleported overlay. Supports sm/md/lg/xl/full size presets, a scrollable body, sticky header/footer, layered Escape for nested content, and an opt-in subject-change announcement.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add dialog
```
## Usage
```blade
<x-ui.dialog>
<x-ui.dialog.trigger class="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
{{ __('Edit profile') }}
</x-ui.dialog.trigger>
<x-ui.dialog.content>
<x-ui.dialog.close />
<x-ui.dialog.header>
<x-ui.dialog.title>{{ __('Edit profile') }}</x-ui.dialog.title>
<x-ui.dialog.description>{{ __("Make changes to your profile here. Click save when you're done.") }}</x-ui.dialog.description>
</x-ui.dialog.header>
<div class="mt-4 space-y-2">
<x-ui.label for="name">{{ __('Name') }}</x-ui.label>
<x-ui.input id="name" value="Ada Lovelace" />
</div>
<x-ui.dialog.footer>
<button type="button" @click="hide()" class="inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted">
{{ __('Cancel') }}
</button>
<button type="button" @click="hide()" class="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90">
{{ __('Save changes') }}
</button>
</x-ui.dialog.footer>
</x-ui.dialog.content>
</x-ui.dialog>
```
## Props
- `open` (bool, default `false`) — Initial open state.
- `noScriptMessage` (string, default `This dialog needs JavaScript. Use the page form or link for the primary action.`) — Declared by @props in the shipped Blade source.
- `hooks` (string|null, default `null`) — JavaScript object expression `{ beforeLeave, onOpen, onClose, afterLeave }` evaluated in the surrounding Alpine scope. beforeLeave may return a promise; the panel stays mounted until it settles. afterLeave runs once the leave transition has ended; open a follow-up dialog there.
- `name` (string|null, default `null`) — Opens and closes the dialog on a window open-dialog / close-dialog event whose detail is { name } (or the name): $this->dispatch("open-dialog", name: "invite") in Livewire, a command palette item or a shortcut. Null listens for nothing.
- `size` (sm|md|lg|xl|full, default `lg`) — Documented catalog control used by the preview workbench.
- `scrollable` (bool, default `false`) — Declared by @props in the registry Blade source.
- `overlay` (string, default `default`) — Declared by @props in the registry Blade source.
- `padding` (string, default `default`) — Declared by @props in the registry Blade source.
- `transition` (string, default `default`) — Declared by @props in the registry Blade source.
- `sticky` (bool, default `false`) — Declared by @props in the registry Blade source.
## Use when
- Use for short, self-contained work that benefits from preserving page context, or for interruptions that truly deserve focused attention.
- The user must complete or acknowledge a focused task before returning to the page.
- Confirming a consequential action or editing a compact form.
## Avoid when
- Do not use for long forms, multi-step tasks, or broad comparison work that needs more room and less interruption.
- Reserve for confirmations, critical warnings, or short blocking decisions that genuinely require focused attention.
- The content is non-modal contextual help; use popover.
- The task benefits from persistent edge-aligned space; use sheet.
- The workflow is a mobile-first drag-dismiss surface; use drawer.
## Anti-patterns
- Long forms in dialogs
- Multi-step or comparison-heavy modal workflows
## Rules
- Use the `<brok:dialog>` tag (or `<x-ui.dialog>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/dialog
- Registry JSON (files, props, contract): https://brokui.dev/r/open/dialog.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{-- One dialog hands off to the next: `afterLeave` runs once the first panel has finished leaving and focus is back, so the second dialog opens cleanly and takes focus. --}}
<div x-data="{ confirmOpen: false, handOff: false }">
<x-ui.dialog hooks="{ afterLeave: () => { if (handOff) { handOff = false; confirmOpen = true } } }">
<x-ui.dialog.trigger class="inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring">
{{ __('Archive project') }}
</x-ui.dialog.trigger>
<x-ui.dialog.content size="sm">
<x-ui.dialog.header>
<x-ui.dialog.title>{{ __('Archive project') }}</x-ui.dialog.title>
<x-ui.dialog.description>{{ __('Website relaunch has 3 open tasks. Review them before you archive the project.') }}</x-ui.dialog.description>
</x-ui.dialog.header>
<x-ui.dialog.footer>
<x-ui.button type="button" variant="outline" x-on:click="hide()">{{ __('Cancel') }}</x-ui.button>
<x-ui.button type="button" x-on:click="handOff = true; hide()">{{ __('Continue') }}</x-ui.button>
</x-ui.dialog.footer>
</x-ui.dialog.content>
</x-ui.dialog>
<x-ui.dialog x-model="confirmOpen">
<x-ui.dialog.content size="sm">
<x-ui.dialog.header>
<x-ui.dialog.title>{{ __('Archive with open tasks?') }}</x-ui.dialog.title>
<x-ui.dialog.description>{{ __('The 3 open tasks move to the archive with the project.') }}</x-ui.dialog.description>
</x-ui.dialog.header>
<x-ui.dialog.footer>
<x-ui.button type="button" variant="outline" x-on:click="hide()">{{ __('Keep project') }}</x-ui.button>
<x-ui.button type="button" x-on:click="hide()">{{ __('Archive') }}</x-ui.button>
</x-ui.dialog.footer>
</x-ui.dialog.content>
</x-ui.dialog>
</div>
Fullscreen
{{-- Full-screen dialog (size="full"): edge-to-edge on mobile, inset on larger
screens. The body scrolls between a pinned header and footer. --}}
<x-ui.dialog>
<x-ui.dialog.trigger class="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
{{ __('Create event') }}
</x-ui.dialog.trigger>
<x-ui.dialog.content size="full">
<x-ui.dialog.close sticky />
<x-ui.dialog.header sticky>
<x-ui.dialog.title>{{ __('Create event') }}</x-ui.dialog.title>
<x-ui.dialog.description>{{ __('Fill in the details below. Everything scrolls within the panel.') }}</x-ui.dialog.description>
</x-ui.dialog.header>
<div class="space-y-4">
<div class="space-y-2">
<x-ui.label for="event-name">{{ __('Event name') }}</x-ui.label>
<x-ui.input id="event-name" name="name" type="text" placeholder="{{ __('Quarterly planning') }}" />
</div>
@foreach (range(1, 8) as $i)
<div class="space-y-2">
<x-ui.label for="event-field-{{ $i }}">{{ __('Detail :n', ['n' => $i]) }}</x-ui.label>
<x-ui.input id="event-field-{{ $i }}" name="field_{{ $i }}" type="text" placeholder="{{ __('Optional') }}" />
</div>
@endforeach
</div>
<x-ui.dialog.footer sticky>
<button type="button" @click="hide()" class="inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted">
{{ __('Cancel') }}
</button>
<button type="button" @click="hide()" class="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90">
{{ __('Create event') }}
</button>
</x-ui.dialog.footer>
</x-ui.dialog.content>
</x-ui.dialog>
<x-ui.dialog :open="true">
<x-ui.dialog.trigger class="h-10 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring">
{{ __('Publish release') }}
</x-ui.dialog.trigger>
<x-ui.dialog.content>
<x-ui.dialog.close />
<x-ui.dialog.header>
<x-ui.dialog.title>{{ __('Publish release') }}</x-ui.dialog.title>
<x-ui.dialog.description>{{ __('The form stays available while the submission is in progress.') }}</x-ui.dialog.description>
</x-ui.dialog.header>
<div class="mt-4 rounded-md border border-border bg-muted p-4 text-sm text-muted-foreground">
{{ __('Release notes and package files are being verified.') }}
</div>
<x-ui.dialog.footer>
<x-ui.button variant="outline" type="button" disabled>{{ __('Cancel') }}</x-ui.button>
<x-ui.button type="button" :loading="true">{{ __('Publishing…') }}</x-ui.button>
</x-ui.dialog.footer>
</x-ui.dialog.content>
</x-ui.dialog>
Long Title
<x-ui.dialog :open="true">
<x-ui.dialog.trigger class="h-10 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring">
{{ __('Review workspace access') }}
</x-ui.dialog.trigger>
<x-ui.dialog.content>
<x-ui.dialog.close />
<x-ui.dialog.header>
<x-ui.dialog.title>{{ __('Review workspace access for the external research and customer-support collaboration group') }}</x-ui.dialog.title>
<x-ui.dialog.description>{{ __('Long titles wrap without hiding the close control or dialog actions.') }}</x-ui.dialog.description>
</x-ui.dialog.header>
<p class="mt-4 text-sm text-muted-foreground">
{{ __('Confirm that this group can read project notes and respond to support requests.') }}
</p>
<x-ui.dialog.footer>
<x-ui.button variant="outline" type="button" @click="hide()">{{ __('Cancel') }}</x-ui.button>
<x-ui.button type="button" @click="hide()">{{ __('Confirm access') }}</x-ui.button>
</x-ui.dialog.footer>
</x-ui.dialog.content>
</x-ui.dialog>
{{-- A named dialog has no trigger of its own: any control, a keyboard
shortcut or a Livewire $this->dispatch('open-dialog', name: 'invite')
opens it through a window event. Focus returns to the control that
had it. --}}
<div class="flex flex-wrap items-center gap-2">
<x-ui.button type="button" x-data x-on:click="$dispatch('open-dialog', { name: 'invite' })">
{{ __('Invite people') }}
</x-ui.button>
<x-ui.dialog name="invite">
<x-ui.dialog.content>
<x-ui.dialog.close />
<x-ui.dialog.header>
<x-ui.dialog.title>{{ __('Invite people') }}</x-ui.dialog.title>
<x-ui.dialog.description>{{ __('Opened by name through the open-dialog event.') }}</x-ui.dialog.description>
</x-ui.dialog.header>
<x-ui.dialog.footer>
<x-ui.button type="button" variant="outline" x-on:click="$dispatch('close-dialog', { name: 'invite' })">{{ __('Close') }}</x-ui.button>
</x-ui.dialog.footer>
</x-ui.dialog.content>
</x-ui.dialog>
</div>
Scrollable
{{-- Scrollable body with a pinned header and footer (sticky props). The middle
region scrolls while the title and actions stay visible. --}}
<x-ui.dialog>
<x-ui.dialog.trigger class="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
{{ __('Review terms') }}
</x-ui.dialog.trigger>
<x-ui.dialog.content scrollable>
<x-ui.dialog.close sticky />
<x-ui.dialog.header sticky>
<x-ui.dialog.title>{{ __('Terms of service') }}</x-ui.dialog.title>
<x-ui.dialog.description>{{ __('Please read the following before continuing.') }}</x-ui.dialog.description>
</x-ui.dialog.header>
<div class="space-y-4 text-sm text-muted-foreground">
@foreach (range(1, 10) as $section)
<p>
<span class="font-medium text-foreground">{{ __('Section :n.', ['n' => $section]) }}</span>
{{ __('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.') }}
</p>
@endforeach
</div>
<x-ui.dialog.footer sticky>
<button type="button" @click="hide()" class="inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted">
{{ __('Decline') }}
</button>
<button type="button" @click="hide()" class="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90">
{{ __('Accept') }}
</button>
</x-ui.dialog.footer>
</x-ui.dialog.content>
</x-ui.dialog>
{{-- Width presets: sm / md / lg / xl via the `size` prop on dialog.content. --}}
<div class="flex flex-wrap items-center gap-3">
@foreach (['sm' => __('Small'), 'md' => __('Medium'), 'lg' => __('Large'), 'xl' => __('Extra large')] as $size => $label)
<x-ui.dialog>
<x-ui.dialog.trigger class="inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
{{ $label }}
</x-ui.dialog.trigger>
<x-ui.dialog.content :size="$size">
<x-ui.dialog.close />
<x-ui.dialog.header>
<x-ui.dialog.title>{{ $label }} {{ __('dialog') }}</x-ui.dialog.title>
<x-ui.dialog.description>{{ __('This dialog uses the :size width preset.', ['size' => $size]) }}</x-ui.dialog.description>
</x-ui.dialog.header>
<x-ui.dialog.footer>
<button type="button" @click="hide()" class="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90">
{{ __('Got it') }}
</button>
</x-ui.dialog.footer>
</x-ui.dialog.content>
</x-ui.dialog>
@endforeach
</div>
Validation Error
<x-ui.dialog :open="true">
<x-ui.dialog.trigger class="h-10 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring">
{{ __('Invite teammate') }}
</x-ui.dialog.trigger>
<x-ui.dialog.content>
<x-ui.dialog.close />
<x-ui.dialog.header>
<x-ui.dialog.title>{{ __('Invite teammate') }}</x-ui.dialog.title>
<x-ui.dialog.description>{{ __('Correct the invalid value before you submit the form again.') }}</x-ui.dialog.description>
</x-ui.dialog.header>
<form class="mt-4 space-y-4" @submit.prevent>
<x-ui.field>
<x-ui.label for="dialog-invalid-email">{{ __('Email address') }}</x-ui.label>
<x-ui.input
id="dialog-invalid-email"
name="email"
type="email"
value="not-an-email"
aria-invalid="true"
aria-describedby="dialog-invalid-email-error"
/>
<x-ui.field.error id="dialog-invalid-email-error">{{ __('Enter a valid email address.') }}</x-ui.field.error>
</x-ui.field>
<x-ui.dialog.footer>
<x-ui.button variant="outline" type="button" @click="hide()">{{ __('Cancel') }}</x-ui.button>
<x-ui.button type="submit">{{ __('Send invite') }}</x-ui.button>
</x-ui.dialog.footer>
</form>
</x-ui.dialog.content>
</x-ui.dialog>
With Form
{{-- A dialog wrapping a real form. The submit button lives inside the form so
Enter submits; Cancel dismisses via hide(). --}}
<x-ui.dialog>
<x-ui.dialog.trigger class="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
{{ __('Invite teammate') }}
</x-ui.dialog.trigger>
<x-ui.dialog.content size="md">
<x-ui.dialog.close />
<x-ui.dialog.header>
<x-ui.dialog.title>{{ __('Invite teammate') }}</x-ui.dialog.title>
<x-ui.dialog.description>{{ __('Send an invitation to join your workspace.') }}</x-ui.dialog.description>
</x-ui.dialog.header>
<form method="POST" action="#" class="mt-4 space-y-4">
@csrf
<div class="space-y-2">
<x-ui.label for="invite-email">{{ __('Email address') }}</x-ui.label>
<x-ui.input id="invite-email" name="email" type="email" autocomplete="email" required placeholder="you@example.com" />
</div>
<div class="space-y-2">
<x-ui.label for="invite-role">{{ __('Role') }}</x-ui.label>
<x-ui.input id="invite-role" name="role" type="text" autocomplete="off" placeholder="{{ __('Member') }}" />
</div>
<x-ui.dialog.footer>
<button type="button" @click="hide()" class="inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted">
{{ __('Cancel') }}
</button>
<button type="submit" class="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90">
{{ __('Send invite') }}
</button>
</x-ui.dialog.footer>
</form>
</x-ui.dialog.content>
</x-ui.dialog>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| open | bool | false | Initial open state. |
| noScriptMessage | string | This dialog needs JavaScript. Use the page form... | Declared by @props in the shipped Blade source. |
| hooks | string | null | null | JavaScript object expression `{ beforeLeave, onOpen, onClose, afterLeave }` evaluated in the surrounding Alpine scope. beforeLeave may return a promise; the panel stays mounted until it settles. afterLeave runs once the leave transition has ended; open a follow-up dialog there. |
| name | string | null | null | Opens and closes the dialog on a window open-dialog / close-dialog event whose detail is { name } (or the name): $this->dispatch("open-dialog", name: "invite") in Livewire, a command palette item or a shortcut. Null listens for nothing. |
| size | sm | md | lg | xl | full | lg | Documented catalog control used by the preview workbench. |
| scrollable | bool | false | Declared by @props in the registry Blade source. |
| overlay | string | default | Declared by @props in the registry Blade source. |
| padding | string | default | Declared by @props in the registry Blade source. |
| transition | string | default | Declared by @props in the registry Blade source. |
| sticky | bool | false | Declared by @props in the registry Blade source. |
Slots
default— Primary Blade slot rendered by the component.x-ui.dialog.trigger— Installed subcomponent from the registry item.x-ui.dialog.content— Installed subcomponent from the registry item.x-ui.dialog.header— Installed subcomponent from the registry item.x-ui.dialog.title— Installed subcomponent from the registry item.x-ui.dialog.description— Installed subcomponent from the registry item.x-ui.dialog.footer— Installed subcomponent from the registry item.x-ui.dialog.close— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Binds wire:model (Livewire) or x-model (Alpine) to its open state through x-modelable on the root; closing writes false back.
- Traps focus while open, closes on Escape, and restores focus to the trigger.
- Teleports the overlay so ancestor overflow does not clip the modal.
- Without JavaScript, renders a clear message and leaves the primary action to a normal page link or form.
- Marks the rest of the page inert while open (reference-counted across stacked overlays) so a screen reader on a virtual cursor cannot browse behind the modal.
- A nested layer (an inline form, a stepper) can call pushEscapeLayer(close)/popEscapeLayer(close) to claim the first Escape press instead of the dialog closing; the global Escape handler also defers to any keydown that already called preventDefault().
- Call announceSubjectChange(message) to tell assistive tech an already-open dialog's content changed in place (e.g. stepping to another record); ordinary open is not announced twice.
- hide() awaits an optional beforeLeave hook (set through the hooks prop or assigned on the scope from x-init): while it runs `closing` is true, the panel stays mounted (x-show is `open || closing`) and the overlay starts fading, so a consumer can run its own exit animation before the panel unmounts. Without the hook the close is synchronous, as before.
- onOpen fires once the panel is laid out and focused; onClose fires after the locks are released and focus is handed back, while the panel is still leaving; afterLeave fires once the leave transition has ended and the portal is hidden, so a dialog opened there takes focus with nothing of the first one on screen (see the chained example). Focus goes back to the trigger only if a hook has not moved it into another overlay. expandable-card (a FLIP morph) and hero-video-dialog (iframe src lifecycle) compose the dialog through these hooks.
- Named dialogs (name="invite"): a window open-dialog event with detail { name: "invite" } opens the dialog and close-dialog closes it; other names are ignored. Focus moves into the panel and returns to the element that had focus when it opened (IC-003), so a dialog opened from a shortcut hands focus back to where the user was. The root carries data-dialog-name.
- When the panel has a close button, the title keeps inline-end space for it, so a long title wraps before the button instead of running under it.
- Binds wire:model (Livewire) or x-model (Alpine) to its open state through x-modelable; closing writes false back.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Handle a short focused interruption or blocking decision.
Use when
- Use for short, self-contained work that benefits from preserving page context, or for interruptions that truly deserve focused attention.
- The user must complete or acknowledge a focused task before returning to the page.
- Confirming a consequential action or editing a compact form.
Avoid when
- Do not use for long forms, multi-step tasks, or broad comparison work that needs more room and less interruption.
- Reserve for confirmations, critical warnings, or short blocking decisions that genuinely require focused attention.
- The content is non-modal contextual help; use popover.
- The task benefits from persistent edge-aligned space; use sheet.
- The workflow is a mobile-first drag-dismiss surface; use drawer.
Use instead
- Inline content
- Drawer or dedicated page for longer tasks
Anti-patterns
- Long forms in dialogs
- Multi-step or comparison-heavy modal workflows
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Tab Escape
- Focus
managed
- Always provide a dialog title; add a description when the purpose is not obvious.
- Keep the primary action last in logical focus order.
- Background content is inert while the dialog is open; do not rely on aria-hidden or z-index alone to keep it from a virtual cursor.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="dialog-{{ $record->id }}">
<x-ui.dialog>
<x-ui.dialog.trigger class="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
{{ __('Edit profile') }}
</x-ui.dialog.trigger>
<x-ui.dialog.content>
<x-ui.dialog.close />
<x-ui.dialog.header>
<x-ui.dialog.title>{{ __('Edit profile') }}</x-ui.dialog.title>
<x-ui.dialog.description>{{ __("Make changes to your profile here. Click save when you're done.") }}</x-ui.dialog.description>
</x-ui.dialog.header>
<div class="mt-4 space-y-2">
<x-ui.label for="name">{{ __('Name') }}</x-ui.label>
<x-ui.input id="name" value="Ada Lovelace" />
</div>
<x-ui.dialog.footer>
<button type="button" @click="hide()" class="inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted">
{{ __('Cancel') }}
</button>
<button type="button" @click="hide()" class="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90">
{{ __('Save changes') }}
</button>
</x-ui.dialog.footer>
</x-ui.dialog.content>
</x-ui.dialog>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:dialog
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{--
Dialog (spec §17 dialog focus trap / Escape / focus return + §19 behavior).
Root holds open state, focus trapping and focus restoration. Each instance
is self-contained, so multiple and nested dialogs work.
--}}
@props([
'open' => false,
'noScriptMessage' => 'This dialog needs JavaScript. Use the page form or link for the primary action.',
// Lifecycle hooks as a JavaScript object expression, evaluated in the
// surrounding Alpine scope: `{ beforeLeave, onOpen, onClose, afterLeave }`.
// `beforeLeave` may return a promise; the panel stays mounted (and the
// overlay starts fading) until it settles, so a consumer can run its own
// exit animation. `onOpen` fires once the panel is laid out and focused;
// `onClose` after focus has been handed back, while the panel still
// leaves; `afterLeave` once the leave transition has ended (open the
// next dialog there). A consumer can also assign the same names on the
// dialog scope from `x-init`.
'hooks' => null,
// Opens and closes on a window `open-dialog` / `close-dialog` event whose
// detail names it: `$this->dispatch('open-dialog', name: 'invite')` in
// Livewire, or `window.dispatchEvent(new CustomEvent('open-dialog',
// { detail: { name: 'invite' } }))`. Null (default) listens for nothing.
'name' => null,
])
<div
x-data="uiDialog({ open: @js((bool) $open)@if (filled($hooks)), hooks: {{ $hooks }}@endif{{ filled($name) ? ', name: '.\Illuminate\Support\Js::from((string) $name) : '' }} })"
x-modelable="open"
x-id="['dialog-title', 'dialog-description']"
data-slot="dialog"
@if (filled($name)) data-dialog-name="{{ $name }}" @endif
{{ $attributes->merge(['class' => 'contents']) }}
>
{{ $slot }}
<noscript>
<p data-slot="dialog-noscript" role="status" class="my-3 break-words rounded-md border border-border bg-muted px-4 py-3 text-sm text-foreground">
{{ __($noScriptMessage) }}
</p>
</noscript>
</div>
<button
type="button"
data-slot="dialog-trigger"
x-ref="trigger"
aria-haspopup="dialog"
:aria-expanded="open"
@click="show()"
{{ $attributes->merge(['class' => 'inline-flex min-w-0 max-w-full items-center justify-center whitespace-normal break-all text-center']) }}
>
{{ $slot }}
</button>
@props([
// Panel width preset. 'sm' | 'md' | 'lg' | 'xl' | 'full'. Defaults to 'lg'
// to preserve the original max-w-lg behaviour for existing usages.
'size' => 'lg',
// When true the panel becomes a height-capped flex column whose middle
// region scrolls, so header/footer (with `sticky`) stay pinned.
'scrollable' => false,
// Backdrop preset. 'default' is the dimmed page (bg-scrim/50); 'blur'
// is a translucent, blurred page for media lightboxes; 'none' renders no
// overlay element at all (the portal still covers the page for clicks).
'overlay' => 'default',
// Panel inset. 'default' is the density-aware dialog padding; 'none' lets
// a consumer own the inset (an edge-to-edge video, a morphing card).
'padding' => 'default',
// Panel enter/leave motion. 'none' drops the panel's own x-transition so a
// consumer can animate the panel itself (a shared-layout FLIP), while the
// overlay keeps its fade. The overlay-lifecycle (trap, inert, focus) is
// unchanged in every mode.
'transition' => 'default',
])
@php
// Width presets (logical max-inline-size). 'full' edge-to-edge fullscreen.
$sizes = [
'sm' => 'w-full max-w-sm',
'md' => 'w-full max-w-md',
'lg' => 'w-full max-w-lg',
'xl' => 'w-full max-w-2xl',
'full' => 'h-full w-full max-w-none rounded-none border-0 sm:h-[calc(100%-2rem)] sm:max-w-3xl sm:rounded-lg sm:border',
];
$sizeKey = array_key_exists($size, $sizes) ? $size : 'lg';
$sizeClass = $sizes[$sizeKey];
// Scrollable / fullscreen panels cap their height and scroll the panel
// itself, so header/footer with `sticky` pin to the panel edges and the
// close button (absolute, resolving to this relative panel) stays put.
// Simple dialogs use the density-aware dialog padding token.
$isScroll = $scrollable || $sizeKey === 'full';
$panelPadding = $padding === 'none' ? '' : 'p-dialog';
$panelLayout = trim(($isScroll ? 'max-h-[calc(100dvh-2rem)] overflow-y-auto ' : '').$panelPadding);
$overlays = [
'default' => 'bg-scrim/50',
'blur' => 'bg-background/80 backdrop-blur-sm',
'none' => null,
];
$overlayKey = array_key_exists($overlay, $overlays) ? $overlay : 'default';
$overlayClass = $overlays[$overlayKey];
$panelTransition = $transition !== 'none';
@endphp
<template x-teleport="body">
<div
data-slot="dialog-portal"
x-ref="portal"
x-show="open"
x-cloak
class="fixed inset-0 z-modal flex items-center justify-center p-4"
{{-- With no overlay element, a click on the bare portal (outside the
panel) still dismisses, as an overlay click would. --}}
@if ($overlayClass === null) @click.self="hide()" @endif
{{-- Defers to any surface that already handled the key (a nested menu
calling preventDefault() on its own Escape) and to a nested layer
that claims the first Escape via pushEscapeLayer() before this
closes the dialog itself. --}}
@keydown.escape.window="open && !$event.defaultPrevented && escape()"
>
{{-- Announces an in-place subject change (see announceSubjectChange())
without re-announcing on ordinary open, which the dialog role
already covers. --}}
<p data-slot="dialog-live-region" aria-live="polite" class="sr-only" x-text="subjectAnnouncement"></p>
{{-- Overlay. It starts leaving as soon as a `beforeLeave` hook begins
(`closing`), so a consumer's own exit animation and the backdrop
fade run together instead of back to back. --}}
@if ($overlayClass !== null)
<div
data-slot="dialog-overlay"
data-overlay="{{ $overlayKey }}"
x-show="open && !closing"
x-transition:enter="motion-safe:transition-opacity motion-safe:duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="motion-safe:transition-opacity motion-safe:duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 {{ $overlayClass }}"
@click="hide()"
></div>
@endif
{{-- Panel --}}
<div
data-slot="dialog-content"
data-size="{{ $sizeKey }}"
x-ref="panel"
role="dialog"
aria-modal="true"
tabindex="-1"
x-bind:aria-labelledby="hasTitle ? $id('dialog-title') : null"
x-bind:aria-describedby="hasDescription ? $id('dialog-description') : null"
{{-- Stays mounted while a `beforeLeave` hook runs (`closing`), so a
consumer can animate the panel out before it unmounts. --}}
x-show="open || closing"
@if ($panelTransition)
x-transition:enter="motion-safe:transition motion-safe:duration-150"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="motion-safe:transition motion-safe:duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@endif
@keydown.tab.prevent="trap($event)"
{{ $attributes->merge(['class' => 'relative z-10 min-w-0 rounded-lg border border-border bg-card text-card-foreground shadow-lg motion-reduce:transition-none ' . $sizeClass . ' ' . $panelLayout]) }}
>
{{ $slot }}
</div>
</div>
</template>
@props([
// Pin the header to the top of a scrollable dialog body.
'sticky' => false,
])
{{-- Anatomy shared with the other overlays — see ui/overlay/header.blade.php. --}}
<x-ui.overlay.header data-slot="dialog-header" :sticky="$sticky" {{ $attributes }}>{{ $slot }}</x-ui.overlay.header>
{{-- Anatomy shared with the other overlays — see ui/overlay/title.blade.php.
When the panel shows the close button (pinned to the top end corner), the
title keeps inline-end space for it, so a long title wraps before the
button instead of running under it. --}}
<x-ui.overlay.title data-slot="dialog-title" scope="dialog-title" {{ $attributes->merge(['class' => '[[data-slot=dialog-content]:has([data-slot=dialog-close])_&]:pe-10']) }}>{{ $slot }}</x-ui.overlay.title>
{{-- Anatomy shared with the other overlays — see ui/overlay/description.blade.php. --}}
<x-ui.overlay.description data-slot="dialog-description" scope="dialog-description" {{ $attributes }}>{{ $slot }}</x-ui.overlay.description>
@props([
// Pin the footer to the bottom of a scrollable dialog body.
'sticky' => false,
])
{{-- Anatomy shared with the other overlays — see ui/overlay/footer.blade.php. --}}
<x-ui.overlay.footer data-slot="dialog-footer" :sticky="$sticky" {{ $attributes }}>{{ $slot }}</x-ui.overlay.footer>
@props([
// In a scrollable dialog, float the close button so it stays pinned to the
// panel's top-right while the body scrolls (sticky instead of absolute).
'sticky' => false,
])
{{-- Anatomy shared with the other overlays — see ui/overlay/close.blade.php. --}}
<x-ui.overlay.close data-slot="dialog-close" :sticky="$sticky" {{ $attributes }} />
import {
announceOverlaySubjectChange,
focusOverlayInitial,
listenForOverlayName,
lockOverlayInert,
lockOverlayScroll,
overlayFocusables,
overlayReturnFocusTarget,
popOverlayEscapeLayer,
pushOverlayEscapeLayer,
resolveOverlayEscape,
trapOverlayFocus,
unlockOverlayInert,
unlockOverlayScroll,
} from './overlay-lifecycle.js';
/**
* Dialog behavior (spec §17: focus trap, Escape, focus return).
*
* On open we remember the element that had focus, move focus into the panel,
* and lock body scroll. Tab is trapped inside the panel. On close we restore
* focus to the trigger. Escape and overlay click both close.
*
* Lifecycle hooks (`config.hooks`, or assigned on the scope from `x-init`):
* - `beforeLeave()` runs when a close is requested and may return a promise.
* `closing` is true while it runs: the panel stays mounted (its `x-show`
* is `open || closing`) and the overlay starts fading, so a consumer can
* animate the panel out itself (a shared-layout FLIP) before it unmounts.
* Without the hook `hide()` flips `open` synchronously, exactly as before.
* - `onOpen()` fires after the panel is laid out and focus has moved in.
* - `onClose()` fires after the locks are released and focus handed back,
* while the panel is still leaving.
* - `afterLeave()` fires once the leave transition has ended and the portal
* is hidden. Open the next dialog here: nothing of this one is on screen
* or in the focus order any more.
*/
/**
* Resolves once `el` is hidden (display: none) or gone; a timer caps the wait.
* @returns {Promise<void>}
*/
function whenHidden(el, limit = 1000) {
return new Promise((resolve) => {
const hidden = () => !el || !el.isConnected || getComputedStyle(el).display === 'none';
if (hidden()) {
resolve();
return;
}
let timer = null;
const observer = new MutationObserver(() => {
if (!hidden()) return;
observer.disconnect();
window.clearTimeout(timer);
resolve();
});
observer.observe(el, { attributes: true, attributeFilter: ['style', 'class'] });
timer = window.setTimeout(() => {
observer.disconnect();
resolve();
}, limit);
});
}
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiDialog', (config = {}) => ({
open: false,
// True from the moment a close is requested until `beforeLeave`
// settles; keeps the panel mounted for a consumer-owned exit.
closing: false,
beforeLeave: typeof config.hooks?.beforeLeave === 'function' ? config.hooks.beforeLeave : null,
onOpen: typeof config.hooks?.onOpen === 'function' ? config.hooks.onOpen : null,
onClose: typeof config.hooks?.onClose === 'function' ? config.hooks.onClose : null,
afterLeave: typeof config.hooks?.afterLeave === 'function' ? config.hooks.afterLeave : null,
initiallyOpen: config.open === true,
previouslyFocused: null,
lastPointerTarget: null,
pointerListener: null,
scrollLocked: false,
// Set by <x-ui.dialog.title>/<x-ui.dialog.description> when present, so
// the panel only advertises aria-labelledby/aria-describedby when the
// referenced node actually exists (no dangling idref / nameless dialog).
hasTitle: false,
hasDescription: false,
inertLocked: false,
inertPortal: null,
escapeLayers: [],
// Polite live region text. Set by consumers via announceSubjectChange()
// when an already-open dialog's content is swapped in place (e.g. a
// peek panel stepping to another record) — never on ordinary open,
// since the dialog role already announces that.
subjectAnnouncement: '',
name: typeof config.name === 'string' && config.name !== '' ? config.name : null,
stopListeningForName: null,
init() {
this.stopListeningForName = listenForOverlayName(this.name, {
open: () => this.show(),
close: () => this.hide(),
});
this.pointerListener = (event) => {
if (this.open || !(event.target instanceof Element)) {
return;
}
this.lastPointerTarget = event.target.closest(
'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
};
document.addEventListener('pointerdown', this.pointerListener, true);
this.$watch('open', (value, previous) => {
if (value === previous) {
return;
}
if (value) {
this.afterOpen();
} else {
this.afterClose();
}
});
if (this.initiallyOpen) {
this.$nextTick(() => {
if (this.open) {
this.afterOpen();
} else {
this.show();
}
});
}
},
// Body-scroll lock is reference-counted across stacked overlays so a
// nested close (or an unmount while open) never clears the lock while
// another overlay still needs it. The counter is a single shared global
// key that every overlay item duplicates (copy-into-app model).
lockScroll() {
lockOverlayScroll(this);
},
unlockScroll() {
unlockOverlayScroll(this);
},
lockInert() {
lockOverlayInert(this, this.$refs.portal);
},
unlockInert() {
unlockOverlayInert(this);
},
/** A nested layer (an inline form, a stepper) claims Escape while active. */
pushEscapeLayer(close) {
pushOverlayEscapeLayer(this, close);
},
popEscapeLayer(close) {
popOverlayEscapeLayer(this, close);
},
/** Announce an in-place subject change without re-announcing on open. */
announceSubjectChange(message) {
announceOverlaySubjectChange(this, message);
},
/** Escape unwinds one layer at a time before it closes the dialog. */
escape() {
if (!this.open) return;
if (resolveOverlayEscape(this)) return;
this.hide();
},
destroy() {
this.stopListeningForName?.();
this.stopListeningForName = null;
if (this.pointerListener) {
document.removeEventListener('pointerdown', this.pointerListener, true);
this.pointerListener = null;
}
this.closing = false;
// Release the locks if the overlay is unmounted while still open
// (Livewire re-render, wire:navigate, conditional @if).
if (this.open) {
this.unlockScroll();
this.unlockInert();
}
},
show() {
if (this.open) {
return;
}
this.open = true;
},
afterOpen() {
this.previouslyFocused = overlayReturnFocusTarget(this.$refs.trigger, this.lastPointerTarget);
this.lastPointerTarget = null;
this.lockScroll();
this.lockInert();
this.$nextTick(() => {
this.focusInitial();
if (typeof this.onOpen === 'function') {
this.onOpen();
}
});
},
async hide() {
if (!this.open || this.closing) {
return;
}
if (typeof this.beforeLeave === 'function') {
this.closing = true;
try {
await this.beforeLeave();
} finally {
// The hook may have been outlived by a destroy(); only
// flip `open` when the dialog is still the one closing.
if (this.closing) {
this.open = false;
}
}
return;
}
this.open = false;
},
afterClose() {
this.closing = false;
this.unlockScroll();
this.unlockInert();
const target = this.previouslyFocused?.isConnected
? this.previouslyFocused
: this.$refs.trigger;
this.previouslyFocused = null;
const portal = this.$refs.portal;
if (target) {
this.$nextTick(() => {
// A hook may already have opened another overlay that
// took focus; hand focus back only if it is still ours.
const active = document.activeElement;
if (!active || active === document.body || portal?.contains(active)) {
target.focus();
}
});
}
if (typeof this.onClose === 'function') {
this.onClose();
}
if (typeof this.afterLeave === 'function') {
// Wait one tick for the leave transition to start, then for it to end.
this.$nextTick(() => whenHidden(portal).then(() => {
if (!this.open) this.afterLeave();
}));
}
},
focusables() {
return overlayFocusables(this.$refs.panel);
},
focusInitial() {
focusOverlayInitial(this.$refs.panel);
},
/** Tab is always prevented in markup; we move focus manually. */
trap(event) {
trapOverlayFocus(this.$refs.panel, event);
},
}));
});
const focusableSelector = [
'a[href]',
'area[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'iframe',
'object',
'embed',
'[contenteditable=true]',
'[tabindex]:not([tabindex="-1"])',
].join(',');
export function lockOverlayScroll(state) {
if (state.scrollLocked) return;
window.__uiScrollLocks = (window.__uiScrollLocks || 0) + 1;
document.body.style.overflow = 'hidden';
state.scrollLocked = true;
}
export function unlockOverlayScroll(state) {
if (!state.scrollLocked) return;
window.__uiScrollLocks = Math.max(0, (window.__uiScrollLocks || 1) - 1);
if (window.__uiScrollLocks === 0) document.body.style.overflow = '';
state.scrollLocked = false;
}
export function overlayFocusables(panel) {
if (!panel) return [];
return Array.from(panel.querySelectorAll(focusableSelector)).filter(
(element) =>
element.getClientRects().length > 0 &&
element.getAttribute('aria-hidden') !== 'true' &&
!element.closest('[inert]'),
);
}
// The element an overlay hands focus back to when it closes, read at open
// time. A WebKit (Safari) click does not focus a <button>, so activeElement is
// then <body> or a focusable ancestor of the trigger (a tab panel, a card with
// tabindex). Returning focus to that ancestor loses the user's place, so the
// clicked control (or the trigger) wins over <body> and over any element that
// merely contains it. A focused element elsewhere (a menu item that opened the
// overlay programmatically) is kept as is.
export function overlayReturnFocusTarget(trigger, pointerTarget = null) {
const active = document.activeElement;
const clicked = pointerTarget instanceof HTMLElement && pointerTarget.isConnected ? pointerTarget : null;
const fallback = clicked || (trigger instanceof HTMLElement ? trigger : null);
if (!(active instanceof HTMLElement) || active === document.body || active === document.documentElement) {
return fallback;
}
if (fallback && active !== fallback && active.contains(fallback)) {
return fallback;
}
return active;
}
export function focusOverlayInitial(panel) {
// Focus already inside the panel stays: the open step can run late (a slow
// device, a Livewire round trip) after the user has started typing.
const active = document.activeElement;
if (panel && active && active !== panel && panel.contains(active)) return;
const autofocus = panel?.querySelector('[autofocus], [data-autofocus]');
(autofocus || overlayFocusables(panel)[0] || panel)?.focus();
}
export function trapOverlayFocus(panel, event) {
const focusables = overlayFocusables(panel);
if (focusables.length === 0) {
panel?.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const index = focusables.indexOf(document.activeElement);
const next = event.shiftKey
? (index <= 0 ? last : focusables[index - 1])
: (index === -1 || index === focusables.length - 1 ? first : focusables[index + 1]);
next.focus();
}
// A keyboard user is already contained by the focus trap above, but a screen
// reader on a virtual cursor can still browse `document.body` content behind
// a modal panel. There is no reliable single "main content" wrapper to target
// in an arbitrary consumer app (this file is copied into whatever layout the
// consumer already has), so the safest generic approach is to inert every
// direct child of <body> except the overlay portal(s) currently open — the
// same body-level sweep react-aria/Radix use for this exact reason. The
// trade-off: any other top-level body sibling (a third-party widget, another
// portal) is inert'd too while a modal is open, which is the correct modal
// behaviour, and an element that appears in <body> *after* the sweep runs
// (e.g. a toast fired while the modal is already open) is not retroactively
// inert'd — accepted as a narrow, documented gap rather than adding a
// MutationObserver for it.
//
// Reference-counted exactly like lockOverlayScroll/unlockOverlayScroll: only
// the transition from 0 → 1 open overlays takes the inert snapshot, and only
// the transition back to 0 restores it, so closing an inner overlay in a
// stack never un-inerts the page while an outer one is still open. Each
// overlay's own portal is exempt from the sweep (never inert'd), and a
// stacked overlay whose portal was inert'd by an earlier sweep (it already
// existed in <body>, just closed) is freed the moment it opens.
export function lockOverlayInert(state, portal) {
if (state.inertLocked) return;
window.__uiInertLocks = (window.__uiInertLocks || 0) + 1;
if (window.__uiInertLocks === 1) {
window.__uiInertedSiblings = Array.from(document.body.children).filter(
(element) => element !== portal && !element.hasAttribute('inert'),
);
window.__uiInertedSiblings.forEach((element) => {
// Body children are typed as the generic Element by the DOM lib;
// `inert` is an HTMLElement property, and every real body child
// this sweep targets is one.
/** @type {HTMLElement} */ (element).inert = true;
});
} else if (portal?.inert) {
portal.inert = false;
window.__uiInertedSiblings = (window.__uiInertedSiblings || []).filter(
(element) => element !== portal,
);
}
state.inertLocked = true;
state.inertPortal = portal || null;
}
export function unlockOverlayInert(state) {
if (!state.inertLocked) return;
window.__uiInertLocks = Math.max(0, (window.__uiInertLocks || 1) - 1);
if (window.__uiInertLocks === 0) {
(window.__uiInertedSiblings || []).forEach((element) => {
/** @type {HTMLElement} */ (element).inert = false;
});
window.__uiInertedSiblings = [];
}
state.inertLocked = false;
state.inertPortal = null;
}
// Layered Escape: nested content (a half-typed inline form, a stepper) can
// claim the first Escape press so the overlay unwinds one layer at a time
// instead of discarding unsaved state. A layer registers a close callback
// while it is "active" and unregisters it once it isn't; the overlay's own
// Escape handler only closes itself when no layer claims the key first.
export function pushOverlayEscapeLayer(state, close) {
state.escapeLayers = state.escapeLayers || [];
state.escapeLayers.push(close);
}
export function popOverlayEscapeLayer(state, close) {
if (!state.escapeLayers) return;
const index = state.escapeLayers.lastIndexOf(close);
if (index !== -1) state.escapeLayers.splice(index, 1);
}
/** Returns true when a nested layer claimed the Escape (overlay stays open). */
export function resolveOverlayEscape(state) {
const layers = state.escapeLayers;
if (layers && layers.length > 0) {
layers[layers.length - 1]();
return true;
}
return false;
}
// Announce a subject change in an already-open overlay (a peek/drawer that
// steps between records without closing). The dialog role already announces
// the initial open, so this must only be called on an in-place content swap
// or it double-announces. Clearing the text before setting it (via
// $nextTick) guarantees the live region re-announces even when the new
// subject's label is identical to the last one.
export function announceOverlaySubjectChange(state, message) {
state.subjectAnnouncement = '';
state.$nextTick(() => {
state.subjectAnnouncement = message;
});
}
/**
* Named overlays: a window `open-dialog` / `close-dialog` event whose detail
* is { name } (or the name itself) opens or closes the overlay with that
* `name`, so a Livewire `$this->dispatch('open-dialog', name: 'invite')`, a
* command palette item or a keyboard shortcut can drive a dialog that has no
* trigger of its own. Focus still returns to the element that had it (IC-003).
* Returns the cleanup that removes both listeners.
*/
export function listenForOverlayName(name, { open, close }) {
if (typeof name !== 'string' || name === '') return () => {};
const named = (event) => {
const detail = Array.isArray(event.detail) ? event.detail[0] : event.detail;
return (typeof detail === 'string' ? detail : detail?.name) === name;
};
const onOpen = (event) => {
if (named(event)) open();
};
const onClose = (event) => {
if (named(event)) close();
};
window.addEventListener('open-dialog', onOpen);
window.addEventListener('close-dialog', onClose);
return () => {
window.removeEventListener('open-dialog', onOpen);
window.removeEventListener('close-dialog', onClose);
};
}
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.11.6 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0