Skip to content
Brok UI

Loading…

No results

Modal Surfaces

Open source Core

The four modal surfaces that compose the shared overlay anatomy — a centred dialog, a confirmation alert dialog, an edge sheet and a bottom drawer.

Version
v1.11.6
Stability
stable
License
MIT
Related
Alert Dialog
Sheet
Drawer
Popover

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

Size
previews.components.dialog.default.blade.php 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>
Sm Current
Md Current
Lg Current
Xl Current
Full Current

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/dialog.blade.php
  • blade resources/views/components/ui/dialog/trigger.blade.php
  • blade resources/views/components/ui/dialog/content.blade.php
  • blade resources/views/components/ui/dialog/header.blade.php
  • blade resources/views/components/ui/dialog/title.blade.php
  • blade resources/views/components/ui/dialog/description.blade.php
  • blade resources/views/components/ui/dialog/footer.blade.php
  • blade resources/views/components/ui/dialog/close.blade.php
  • js resources/js/ui/dialog.js
  • js resources/js/ui/overlay-lifecycle.js
Registry dependencies
overlay
Packages
composer: jml/brok:^0.2
npm: alpinejs

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.

dialog.md
# 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

chained.blade.php Blade
{{-- 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.blade.php Blade
{{-- 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>
loading.blade.php Blade
<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.blade.php Blade
<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>
named.blade.php Blade
{{-- 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.blade.php Blade
{{-- 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>
sizes.blade.php Blade
{{-- 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.blade.php Blade
<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.blade.php Blade
{{-- 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

dialog dialog-close dialog-content dialog-description dialog-footer dialog-header dialog-live-region dialog-noscript dialog-overlay dialog-portal dialog-title dialog-trigger

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

Focused overlay

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
root trigger overlay content header title description footer close
Theming hooks
dialog overlay dialog content dialog close

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/dialog.blade.php Blade
{{--
    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>
resources/views/components/ui/dialog/trigger.blade.php Blade
<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>
resources/views/components/ui/dialog/content.blade.php Blade
@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>
resources/views/components/ui/dialog/header.blade.php Blade
@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>
resources/views/components/ui/dialog/title.blade.php Blade
{{-- 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>
resources/views/components/ui/dialog/description.blade.php Blade
{{-- 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>
resources/views/components/ui/dialog/footer.blade.php Blade
@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>
resources/views/components/ui/dialog/close.blade.php Blade
@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 }} />
resources/js/ui/dialog.js JS
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);
        },
    }));
});
resources/js/ui/overlay-lifecycle.js JS
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