Not verified hands-on. This article was written from primary documentation rather than reproduced in an org, so it carries no tested-release badge. If you run it and something differs, say so in the comments and it gets tested and corrected.

The short answer: extend LightningModal rather than building a dialog out of SLDS markup by hand. The base class handles the overlay, the focus trap, the escape key, the ARIA roles and the stacking, and it gives you a promise-based API where opening the modal returns whatever the modal closes with. A hand-rolled slds-modal div reproduces the appearance and almost never reproduces the behaviour.

Writing the modal

A modal is a normal LWC with one difference in the class declaration and three provided layout components in the template.

// confirmDelete.js
import LightningModal from 'lightning/modal';
import { api } from 'lwc';

export default class ConfirmDelete extends LightningModal {
    @api recordName;

    handleCancel() {
        this.close('cancel');
    }

    handleConfirm() {
        this.close('confirm');
    }
}
<!-- confirmDelete.html -->
<template>
    <lightning-modal-header label="Delete record"></lightning-modal-header>
    <lightning-modal-body>
        <p>Delete {recordName}? This cannot be undone.</p>
    </lightning-modal-body>
    <lightning-modal-footer>
        <lightning-button label="Cancel" onclick={handleCancel}></lightning-button>
        <lightning-button variant="destructive" label="Delete"
            onclick={handleConfirm}></lightning-button>
    </lightning-modal-footer>
</template>

You do not write the outer container, the backdrop, or the close button. Those come from the base class. The three section components are optional individually, though a modal with no header needs an accessible name supplied another way.

Opening it and getting a value back

import ConfirmDelete from 'c/confirmDelete';

async handleDeleteClick() {
    const result = await ConfirmDelete.open({
        size: 'small',
        description: 'Confirm deletion of the selected record',
        recordName: this.record.Name
    });

    if (result === 'confirm') {
        await this.deleteRecord();
    }
}

You import the modal class directly and call the static open() method. There is no tag in your template and no lwc:if flag to manage, which removes a whole category of state-handling bugs.

open() returns a promise that settles when the modal closes. The value is whatever you passed to this.close(). A modal dismissed with the escape key or the close button resolves with undefined, so treat undefined as a cancel and never as a default yes.

Properties passed in the open() object are assigned to the modal’s @api properties, so recordName above must be decorated in the modal class. Reserved keys are size, description, label and disableClose.

size takes small, medium, large or full. disableClose removes the close button and blocks the escape key, which you should use sparingly and only when the user genuinely must choose.

Accessibility, which is the real argument

A dialog has requirements that are easy to state and tedious to implement. The base class covers them.

  • Focus moves into the modal when it opens and returns to the triggering element when it closes.
  • Tab cycles within the modal and cannot escape into the page behind it.
  • The escape key closes it unless you disabled that.
  • The content behind is inert, so a screen reader does not read through it.
  • The dialog gets role="dialog" and aria-modal.

What you still owe it is a name and a description. The label on lightning-modal-header becomes the accessible name, so it must describe the dialog’s purpose rather than say “Dialog”. The description passed to open() provides the accessible description for assistive technology, and it is separate from anything visible.

Focus placement inside the modal is still yours to think about. The base class puts focus somewhere sensible, but for a form you usually want the first input focused explicitly. Do it in renderedCallback behind a one-shot guard, and never put initial focus on a destructive button.

Practical notes

The modal renders outside your component’s DOM, so your stylesheet does not reach it and your querySelector cannot find it. Style the modal in its own CSS file.

Events dispatched from the modal do not bubble up to the component that opened it, because they are not in the same tree. Return data through close(). If you need to report progress while the modal is still open, pass a callback function in as a property.

Support depends on the container. Lightning Experience is the primary one. Before relying on lightning/modal inside an Experience Cloud site or a custom container, check the current documentation for that context rather than assuming it is available.

A component can open a modal from a quick action, and that combination needs care: closing the action screen while the modal is open leaves an orphaned overlay. Close the modal first.

Frequently asked

Can I open a modal from a modal? Yes, and the stacking is handled. Two levels is usually one too many for the user.

How do I stop the escape key closing an unsaved form? Pass disableClose: true and provide your own cancel button with a confirmation step.

Why can I not style the modal from the parent? It renders in a separate tree. Put the styles in the modal component’s own CSS.

Is the old slds-modal markup still valid? It still renders. You would be reimplementing focus trapping and inertness yourself, which is why the base class exists.