/ sagar_pansuriya
§ AlpineJS

Accessible Modals with Native <dialog> and a Tiny Alpine.js Wrapper

Illustration of a modal dialog floating in the top layer above a dimmed page, with a keyboard Esc key and focus ring

You've probably written this modal before. A fixed-position div, a dark overlay behind it, an Alpine x-show, a keydown.escape listener, some overflow: hidden on the body, and then, if the project had an accessibility audit, a focus trap bolted on at the end. It works until someone tabs out of it, or a screen reader user lands on the page behind it, or a z-index: 50 header slides over the top.

Browsers have shipped a better primitive for years now: the <dialog> element. Opened the right way, it handles focus, Esc, the backdrop and stacking for you. In most projects I've worked on, switching to it deletes more code than it adds.

This post covers how <dialog> actually behaves, the handful of modern attributes and CSS features worth knowing, and a complete reusable Blade + Alpine component you can drop into a Laravel app.

show() vs showModal(): pick the right one

A <dialog> has two ways to open, and they are not interchangeable.

show()showModal()
Rendered in the top layerNoYes, above every z-index
Rest of the pageStill interactiveMade inert (no clicks, no focus, hidden from AT)
::backdropNoYes
Esc closes itNoYes
Use it forNon-blocking panels, toastsConfirmations, forms, anything that blocks

For a modal, it's always showModal(). That one call is what replaces your focus trap. The page behind the dialog becomes inert, so Tab can only move between elements inside the dialog and the browser UI. Screen readers stop announcing the content behind it. You don't need aria-modal, a hidden sentinel element, or the Alpine focus plugin's x-trap.

Setting the open attribute directly (or toggling it with Alpine) behaves like show(), not showModal(). That's the most common mistake I see when people first try the element: they bind :open="isOpen" and wonder why focus escapes.

Diagram of the dialog lifecycle: trigger button, showModal puts the dialog in the top layer and makes the page inert, the dialog closes via a button, Esc or backdrop click, and focus returns to the trigger

What the browser does for you

Once a dialog is opened with showModal(), you get all of this without writing a line of JavaScript:

  • Initial focus: focus moves into the dialog. Add autofocus to the element that should receive it, for example the first field of a form or the safe button in a destructive confirmation.
  • Esc to close: pressing Esc fires a cancel event and then closes the dialog.
  • Returning focus: when the dialog closes, browsers move focus back to the element that was focused before it opened, which is usually the button that opened it. Keyboard users land exactly where they were.
  • Stacking: the top layer sits above everything else on the page. No more z-index arms race with a sticky header or a third-party chat widget.

One thing it does not do is lock page scrolling. That's one line of CSS, using :has() and the :modal pseudo-class:

html:has(dialog:modal) {
    overflow: hidden;
}

Closing: form method="dialog" and closedby

The nicest close button is one with no JavaScript at all. A form inside a dialog with method="dialog" closes the dialog when submitted, and sets dialog.returnValue to the value of the button that submitted it:

<dialog id="confirm-delete" aria-labelledby="confirm-delete-title">
    <h2 id="confirm-delete-title">Delete this project?</h2>
    <p>This can't be undone.</p>

    <form method="dialog">
        <button value="cancel" autofocus>Cancel</button>
        <button value="confirm">Delete</button>
    </form>
</dialog>

Listen for the close event and read returnValue to decide what to do. Because Esc and the Cancel button both go through close, you handle every exit path in one place.

For clicking the backdrop to close (light dismiss), there's the newer closedby attribute:

  • closedby="any": backdrop click, Esc, and your own code can close it.
  • closedby="closerequest": Esc and your code, but not the backdrop. This is the default for modal dialogs.
  • closedby="none": only your code can close it. Useful for a flow the user must finish, but use it sparingly.

Browser support for closedby is still uneven at the time of writing, so check MDN for the current state and treat it as progressive enhancement. The fallback is short: a click whose target is the dialog itself, landing outside its bounding box, is a click on the backdrop. The component below includes it.

Styling ::backdrop and animating with @starting-style

The ::backdrop pseudo-element replaces your overlay div. Entry animations used to be the reason people avoided <dialog>, because it goes from display: none to visible in one step. Modern CSS fixes that with three pieces: @starting-style defines where the entry transition starts, transition-behavior: allow-discrete lets display take part in the transition so the exit can play, and overlay keeps the element in the top layer until the exit finishes.

/* resources/css/modal.css */
.modal {
    opacity: 0;
    translate: 0 1rem;
    transition-property: opacity, translate, display, overlay;
    transition-duration: .2s;
    transition-timing-function: ease-out;
    transition-behavior: allow-discrete; /* after the longhands/shorthand */
}

.modal[open] {
    opacity: 1;
    translate: 0 0;

    @starting-style {
        opacity: 0;
        translate: 0 1rem;
    }
}

.modal::backdrop {
    background-color: rgb(0 0 0 / 0);
    transition: background-color .2s, display .2s allow-discrete, overlay .2s allow-discrete;
}

.modal[open]::backdrop {
    background-color: rgb(0 0 0 / .5);

    @starting-style {
        background-color: rgb(0 0 0 / 0);
    }
}

@media (prefers-reduced-motion: reduce) {
    .modal { translate: none; transition-duration: .01s; }
}

@starting-style and transition-behavior are supported across current major browsers. overlay is Chromium-only so far. In browsers that don't support a piece, the dialog simply appears or disappears without the animation. It still works, which is exactly how an enhancement should fail.

The reusable Blade + Alpine component

Alpine's job here is small: open the dialog by name from anywhere on the page, and give you a hook when it closes. Everything else stays native. This follows the same event-based pattern Laravel Breeze uses for its modal, so it feels familiar in most Laravel codebases.

{{-- resources/views/components/modal.blade.php --}}
@props([
    'name',
    'title',
    'closedby' => 'any',
])

<dialog
    x-data="modal(@js($name))"
    x-on:open-modal.window="$event.detail === name && open()"
    x-on:close-modal.window="$event.detail === name && $el.close()"
    x-on:click="backdropFallback($event)"
    x-on:close="$dispatch('modal-closed', { name, value: $el.returnValue })"
    closedby="{{ $closedby }}"
    aria-labelledby="{{ $name }}-title"
    {{ $attributes->merge(['class' => 'modal w-full max-w-lg rounded-2xl bg-white p-6 shadow-xl']) }}
>
    <h2 id="{{ $name }}-title" class="text-lg font-semibold">{{ $title }}</h2>

    <div class="mt-4">
        {{ $slot }}
    </div>

    @isset($footer)
        <form method="dialog" class="mt-6 flex justify-end gap-3">
            {{ $footer }}
        </form>
    @endisset
</dialog>

And the small Alpine data object, registered once in your Vite entry:

// resources/js/modal.js
import Alpine from 'alpinejs';

const supportsClosedBy = 'closedBy' in HTMLDialogElement.prototype;

Alpine.data('modal', (name) => ({
    name,

    open() {
        this.$el.returnValue = '';
        this.$el.showModal();
    },

    backdropFallback(event) {
        if (supportsClosedBy) return;
        if (this.$el.getAttribute('closedby') !== 'any') return;
        if (event.target !== this.$el) return; // a click on content inside the dialog

        const r = this.$el.getBoundingClientRect();
        const inside = event.clientX >= r.left && event.clientX <= r.right
            && event.clientY >= r.top && event.clientY <= r.bottom;

        if (!inside) this.$el.close();
    },
}));

Using it looks like this. The trigger is a normal button, so focus returns to it when the dialog closes:

<button type="button" x-data x-on:click="$dispatch('open-modal', 'delete-project')">
    Delete project
</button>

<x-modal name="delete-project" title="Delete this project?" closedby="closerequest">
    <p>All deployments and logs for {{ $project->name }} will be removed.</p>

    <x-slot:footer>
        <button value="cancel" autofocus class="btn-secondary">Cancel</button>
        <button value="confirm" class="btn-danger">Delete</button>
    </x-slot:footer>
</x-modal>

Then react to the result wherever it makes sense, for example in a parent Alpine component or by calling Livewire:

<div x-data x-on:modal-closed.window="
    $event.detail.name === 'delete-project'
        && $event.detail.value === 'confirm'
        && $wire.deleteProject()
">…</div>

A few details worth pointing out. The destructive modal uses closedby="closerequest" so a stray click on the backdrop doesn't dismiss a decision the user was reading. autofocus sits on Cancel, not Delete, so hitting Enter by reflex is safe. And the dialog has an accessible name via aria-labelledby, which screen readers announce when it opens.

A note on Livewire

If a dialog lives inside a Livewire component, a re-render can morph the <dialog> element and drop its open state. Keep the modal markup stable: put wire:ignore.self on the <dialog> so Livewire leaves its own attributes alone while still updating the children, or place the modal outside the part of the component that re-renders.

Why I stopped hand-rolling focus traps

A custom focus trap has to find every focusable element (including ones added later), wrap Tab and Shift+Tab, handle iframes and shadow DOM, stop the page behind from being read by assistive tech, and restore focus afterwards. Each of those is a place for a bug, and the bugs only show up for keyboard and screen reader users, who are the least likely to file a ticket.

The native element does all of it in the browser, consistently, and gets better without you shipping anything. Your JavaScript shrinks to "open this dialog by name". That's a trade I'll take every time.

Checklist for every modal

  1. Open it with showModal(), never by toggling the open attribute.
  2. Give it an accessible name with aria-labelledby pointing at its heading.
  3. Put autofocus on the element that should get focus first (the safe choice for destructive actions).
  4. Close buttons live in a <form method="dialog"> and carry a value.
  5. Handle the outcome in the close event, reading returnValue.
  6. Choose closedby deliberately, with a light-dismiss fallback if you rely on it.
  7. Lock page scroll with html:has(dialog:modal).
  8. Animate with @starting-style and respect prefers-reduced-motion.
  9. Test it with only a keyboard, then with a screen reader. It takes five minutes.

Next time you reach for x-show and a fixed overlay, try the <dialog> version first. You'll write less code, and the people who rely on a keyboard or a screen reader will get a modal that actually behaves like one.

Discussion