Modal
A dialog in the middle of the page that needs an answer before the page behind it can be used again. Built on a native modal <dialog>.
import '@usezeno/tokens/css';
import '@usezeno/core/web-components';
Examples
Confirm an action
Call show() to open the modal. close(value) closes it and the close event carries that value.
Nothing chosen yet.
The file is removed for everyone. This cannot be undone.
<zeno-button variant="destructive">Delete report</zeno-button>
<p data-result>Nothing chosen yet.</p>
<zeno-modal heading="Delete report.pdf?" size="sm">
<p>The file is removed for everyone. This cannot be undone.</p>
<zeno-button slot="footer" variant="ghost" value="cancelled">Cancel</zeno-button>
<zeno-button slot="footer" variant="destructive" value="deleted">Delete</zeno-button>
</zeno-modal>
const modal = document.querySelector('zeno-modal');
document.querySelector('zeno-button').addEventListener('click', () => {
modal.returnValue = '';
modal.show();
});
document.querySelectorAll('zeno-button[slot="footer"]').forEach(button => {
button.addEventListener('click', () => modal.close(button.value));
});
modal.addEventListener('close', event => {
document.querySelector('[data-result]').textContent =
'Closed with: ' + (event.detail.returnValue || 'no value');
});
Sizes
The size sets the width. On a screen narrower than 640px every modal fills the screen.
Press Escape or use the close button to close this modal.
<div data-openers>
<zeno-button variant="outline" value="sm">Small</zeno-button>
<zeno-button variant="outline" value="md">Medium</zeno-button>
<zeno-button variant="outline" value="lg">Large</zeno-button>
<zeno-button variant="outline" value="xl">Extra large</zeno-button>
</div>
<zeno-modal heading="Keyboard shortcuts">
<p>Press Escape or use the close button to close this modal.</p>
</zeno-modal>
const modal = document.querySelector('zeno-modal');
document.querySelectorAll('[data-openers] zeno-button').forEach(button => {
button.addEventListener('click', () => {
modal.size = button.value;
modal.show();
});
});
A choice that must be made
With no-close-button, no-backdrop-close and no-escape-close only the buttons in the footer close the modal.
The terms of service changed on 1 June. Accept them to keep using your account.
<zeno-button variant="outline">Review terms</zeno-button>
<zeno-modal heading="Updated terms" no-close-button no-backdrop-close no-escape-close>
<p>The terms of service changed on 1 June. Accept them to keep using your account.</p>
<zeno-button slot="footer" variant="ghost">Sign out</zeno-button>
<zeno-button slot="footer">Accept</zeno-button>
</zeno-modal>
const modal = document.querySelector('zeno-modal');
document.querySelector('zeno-button').addEventListener('click', () => modal.show());
document.querySelectorAll('zeno-button[slot="footer"]').forEach(button => {
button.addEventListener('click', () => modal.close());
});
Ask before closing
The close button, the backdrop and Escape fire request-close first. Cancel the event to keep the modal open.
No close request yet.
<zeno-button variant="outline">Write a note</zeno-button>
<p data-result>No close request yet.</p>
<zeno-modal heading="New note">
<zeno-textarea label="Note"></zeno-textarea>
<zeno-button slot="footer">Save note</zeno-button>
</zeno-modal>
const modal = document.querySelector('zeno-modal');
const result = document.querySelector('[data-result]');
document.querySelector('zeno-button').addEventListener('click', () => modal.show());
document.querySelector('zeno-button[slot="footer"]').addEventListener('click', () => modal.close());
modal.addEventListener('request-close', event => {
const note = document.querySelector('zeno-textarea').value;
if (note) {
event.preventDefault();
result.textContent = 'Kept open: the note is not saved. Asked by: ' + event.detail.source;
} else {
result.textContent = 'Closed by: ' + event.detail.source;
}
});
Attributes and properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
size | sizereflects | 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'md' | |
open | openreflects | boolean | false | Whether the dialog is showing. Set it, or call show() and close(). From ZenoDialogElement. |
heading | heading | string | '' | The heading, which also names the dialog. Use the header slot for
richer content, or aria-label when there is no visible heading. From ZenoDialogElement. |
noCloseButton | no-close-button | boolean | false | From ZenoDialogElement. |
noBackdropClose | no-backdrop-close | boolean | false | Keeps the dialog open when the backdrop is pressed. From ZenoDialogElement. |
noEscapeClose | no-escape-close | boolean | false | Keeps the dialog open when Escape is pressed. From ZenoDialogElement. |
closeLabel | close-label | string | 'Close' | The name of the close button. From ZenoDialogElement. |
returnValue | string | '' | What close() was given, as on a native dialog. From ZenoDialogElement. | |
testId | string | Test ID for automated testing From ZenoElement. |
Methods
| Method | Description |
|---|---|
show(): void | From ZenoDialogElement. |
close(returnValue?: string): void | From ZenoDialogElement. |
focus(): void | Focus the component or its first focusable element From ZenoElement. |
blur(): void | Blur the component or its focused element From ZenoElement. |
Events
| Event | Detail | Description |
|---|---|---|
open | The modal opened. | |
close | { returnValue: string } | The modal closed. detail.returnValue is what close() was given. |
request-close | { source: ('close-button' | 'backdrop' | 'escape') } | The close button, the backdrop or Escape asked to close; detail.source says which. Cancel it to keep the modal open. |
Slots
| Slot | Description |
|---|---|
| Default | The content. |
header | Heading content, in place of the heading text. |
footer | Actions. The footer is hidden while this slot is empty. |
CSS parts
| Part | Description |
|---|---|
base | The dialog element. |
panel | The column that holds header, body and footer. |
header | The header. |
heading | The heading. |
close | The close button. |
body | The scrolling content area. |
footer | The footer. |
CSS custom properties
This element documents no custom properties of its own. Style it through its parts and through the tokens it reads.
The element's own source reads no tokens.
Accessibility
Keyboard
| Key | What it does |
|---|---|
| TabShift+Tab | Moves focus between the controls inside the modal. Focus does not leave the modal while it is open. |
| Escape | Asks the modal to close. Does nothing when no-escape-close is set or a request-close listener cancels the event. |
| EnterSpace | On the close button, asks the modal to close. |
Forms
This element is not a form control. It adds nothing to a form's data.
Notes
- Renders a native
<dialog>and opens it withshowModal(), so the browser moves focus into the modal, keeps it there, makes the page behind inert and returns focus to the opener on close. - The dialog is named by its heading:
aria-labelledbypoints at an<h2>that holds theheadingtext or theheaderslot. Without a heading, thearia-labelof the element is copied to the dialog. - The close button is a native
<button>named byclose-label, which is “Close” by default. Its icon is hidden from assistive technology. - Page scrolling is locked while the modal is open.
- With reduced motion the modal appears and leaves without a transition. In forced colours it gets a border.
Tests and known gaps
- Tested in a real browser by
dialog.test.ts. - Checked with axe-core in the light and the dark theme.
- Has no Storybook story.
4 of the 26 properties, methods, events, slots and parts above have no description in the source
- the property
size - the property
noCloseButton - the method
show() - the method
close()
Known gaps:
- A modal with no
heading, noheaderslot content and noaria-labelhas no accessible name, and the element does not warn about it. - The
aria-labelis read when the modal renders. Changing onlyaria-labellater does not update the dialog until something else makes it render again. - The heading is always an
<h2>. The level cannot be changed. returnValueis not cleared when the modal opens again. A later close by Escape, the backdrop or the close button reports the value of the earlierclose(value)call.size="full"does not fill a wide screen. The width and height are still limited by the margin around the modal, and the corners stay rounded.- The scroll lock hides the page scrollbar and does not keep its space, so the page can shift sideways while the modal is open.