Containment
Dialogs
A modal window for an important prompt or a task that needs focus: alert dialogs and full-screen dialogs.
- COMPONENT_SPEC §29
- CSS · custom elements · React
- Open in the playground
Examples
Alert dialog
A modal prompt: the page is inert, Tab stays inside, Escape or the scrim ask to close, and focus returns to the button that opened it.
The live demo runs with JavaScript. Its code is below.
import { useState } from 'react';
import { AlertDialog, Button } from '@kyralabs/m3exp-web/react';
export default function Alert() {
const [open, setOpen] = useState(false);
const close = () => {
setOpen(false);
};
return (
<>
<Button
variant="tonal"
onPress={() => {
setOpen(true);
}}
>
Discard draft
</Button>
<AlertDialog
isOpen={open}
onOpenChange={setOpen}
icon="delete"
headline="Discard draft?"
dismissButton={
<Button variant="text" onPress={close}>
Cancel
</Button>
}
confirmButton={
<Button variant="text" onPress={close}>
Discard
</Button>
}
>
This permanently deletes the draft.
</AlertDialog>
</>
);
}
<button type="button" class="m3e-button m3e-button--tonal" id="open-discard">Discard draft</button>
<m3e-dialog id="discard" headline="Discard draft?" icon="delete">
This permanently deletes the draft.
<button type="button" class="m3e-button m3e-button--text" slot="actions">Cancel</button>
<button type="button" class="m3e-button m3e-button--text" slot="actions">Discard</button>
</m3e-dialog>
<script type="module">
const dialog = document.getElementById('discard');
document.getElementById('open-discard').addEventListener('click', () => dialog.show());
dialog
.querySelectorAll('[slot="actions"]')
.forEach((b) => b.addEventListener('click', () => dialog.close()));
</script>
Run the HTML version (custom elements, no framework).
Full-screen dialog
Fills the window below 600px; from 600px it is laid out as a basic dialog.
The live demo runs with JavaScript. Its code is below.
import { useState } from 'react';
import { Button, FullScreenDialog, TextField } from '@kyralabs/m3exp-web/react';
export default function FullScreen() {
const [open, setOpen] = useState(false);
return (
<>
<Button
variant="outlined"
onPress={() => {
setOpen(true);
}}
>
New event
</Button>
<FullScreenDialog
isOpen={open}
onOpenChange={setOpen}
headline="New event"
action={
<Button
variant="text"
onPress={() => {
setOpen(false);
}}
>
Save
</Button>
}
>
<TextField label="Title" />
</FullScreenDialog>
</>
);
}
<button type="button" class="m3e-button m3e-button--outlined" id="open-event">New event</button>
<m3e-dialog id="event" variant="full-screen" headline="New event">
<m3e-text-field label="Title"></m3e-text-field>
<button type="button" class="m3e-button m3e-button--text" slot="actions">Save</button>
</m3e-dialog>
<script type="module">
const dialog = document.getElementById('event');
document.getElementById('open-event').addEventListener('click', () => dialog.show());
dialog.querySelector('[slot="actions"]').addEventListener('click', () => dialog.close());
</script>
Run the HTML version (custom elements, no framework).
API
React
<AlertDialog>
Alert dialog (COMPONENT_SPEC §29; Compose AlertDialog): an optional icon, a headline, the supporting text and the actions (the dismiss button, then the confirm button: end-aligned, the confirm button on top when they stack) in a 280–560dp surface over a scrim. A native <dialog role="alertdialog"> (m3.material.io's web role) opened with showModal() by m3e-elements' DialogLayout (the code <m3e-dialog> runs): the page is inert, Tab stays inside, focus starts on the first control and returns to the opener; Escape and the scrim ask to close (onOpenChange(false)). Other props go to the <dialog>.
| Prop | Type | Default | Description |
|---|---|---|---|
headline
|
ReactNode
|
The title (Compose title); it names the dialog.
|
|
icon
|
string
|
A Material Symbols name shown above the headline, which is then centered. | |
confirmButton
|
ReactNode
|
The confirming action (Compose confirmButton), usually a text <Button>.
|
|
dismissButton
|
ReactNode
|
The dismissing action (Compose dismissButton); rendered before the confirm button.
|
|
aria-label
|
string
|
The dialog's name when it has no headline (default "Dialog", Compose's pane title). | |
children
|
ReactNode
|
The supporting text (Compose text); it describes the dialog.
|
|
isOpen
|
boolean
|
Open (controlled). | |
defaultOpen
|
boolean
|
The initial open state (uncontrolled). | |
onOpenChange
|
(isOpen: boolean) => void
|
Called when the dialog asks to close (Escape, a press on the scrim, the full-screen close
button, a browser close request: Compose's onDismissRequest) with false. Controlled dialogs
close when isOpen becomes false; leave it true to keep the dialog open.
|
Also accepts HTML attributes not listed here.
<BasicAlertDialog>
Dialog with custom content (COMPONENT_SPEC §29; Compose BasicAlertDialog): the children, sized 280–560dp, over a scrim, in a plain modal <dialog> named by aria-label (default "Dialog"). Same behavior as <AlertDialog>.
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label
|
string
|
The dialog's name (default "Dialog", Compose's pane title). | |
children
|
ReactNode
|
The whole dialog: it draws its own surface (e.g. a <Surface>).
|
|
isOpen
|
boolean
|
Open (controlled). | |
defaultOpen
|
boolean
|
The initial open state (uncontrolled). | |
onOpenChange
|
(isOpen: boolean) => void
|
Called when the dialog asks to close (Escape, a press on the scrim, the full-screen close
button, a browser close request: Compose's onDismissRequest) with false. Controlled dialogs
close when isOpen becomes false; leave it true to keep the dialog open.
|
Also accepts HTML attributes not listed here.
<FullScreenDialog>
Full-screen dialog (COMPONENT_SPEC §29; m3.material.io only, Compose has none): a header with a close button, the headline and the confirming action, then the content. Below 600dp (the medium window size class) it fills the window and the header takes the on-scroll color while the content scrolls; from 600dp it is laid out as a basic dialog (m3.material.io: "For medium and expanded breakpoints, use a basic dialog"). A plain modal <dialog>; the close button, Escape and the scrim ask to close (onOpenChange(false)). Same behavior as <AlertDialog>.
| Prop | Type | Default | Description |
|---|---|---|---|
headline
|
ReactNode
|
The title in the header; it names the dialog. | |
action
|
ReactNode
|
The confirming action at the end of the header (m3.material.io: a text button, e.g. Save). | |
closeLabel
|
string
|
The close button's name (default "Close"). | |
aria-label
|
string
|
The dialog's name when it has no headline (default "Dialog"). | |
children
|
ReactNode
|
The content (scrolls under the header). | |
isOpen
|
boolean
|
Open (controlled). | |
defaultOpen
|
boolean
|
The initial open state (uncontrolled). | |
onOpenChange
|
(isOpen: boolean) => void
|
Called when the dialog asks to close (Escape, a press on the scrim, the full-screen close
button, a browser close request: Compose's onDismissRequest) with false. Controlled dialogs
close when isOpen becomes false; leave it true to keep the dialog open.
|
Also accepts HTML attributes not listed here.
Custom elements
<m3e-dialog>
import '@kyralabs/m3e-elements/dialog.js';
<m3e-dialog headline="Title" open>Text<button slot="actions">OK</button></m3e-dialog> (COMPONENT_SPEC §29; Compose AlertDialog / BasicAlertDialog and m3.material.io's full-screen dialog). Renders the m3e-css .m3e-dialog contract inside itself (a native <dialog>; the host is display: contents) and runs DialogLayout (shared with m3e-react): showModal(), the open / close motion, Tab kept inside, Escape / the scrim / the close button asking to close, focus returned to the opener.
variant:alert(default;role="alertdialog", m3.material.io's web role, named by the headline and described by the content),custom(BasicAlertDialog: the children are the whole dialog, which draws its own surface; a plain<dialog>),full-screen(a header with a close button and the headline; full-screen below 600dp, a basic dialog above; a plain<dialog>).headline,icon(alert: a Material Symbols name above the headline),label(the dialog's name when it has no headline; default "Dialog", Compose's pane title),close-label(full-screen: the close button's name, default "Close").- Children with
slot="actions"(text buttons; the dismiss button first) go into the actions row; the other child nodes are the content (the supporting text). open(reflected). Methodsshow(),close(). Eventopen-change(detail: { open }), cancelable when Escape, the scrim, the close button or a browser close request asks to close (preventDefault()keeps it open; Compose's onDismissRequest).
| Attribute | Property | Type | Notes |
|---|---|---|---|
open
|
open
|
boolean
|
Reflected. |
variant
|
variant
|
string
|
Reflected. |
headline
|
headline
|
string
|
|
icon
|
icon
|
string
|
|
label
|
label
|
string
|
|
close-label
|
closeLabel
|
string
|
| Event | Detail | Description |
|---|---|---|
open-change
|
OpenChangeDetail
|
Opened or closed. Cancelable when the user closes it (scrim, Escape, close button). |
CSS
dialog.css
@import '@kyralabs/m3e-css/dialog.css';
Dialogs (COMPONENT_SPEC §29): the alert dialog (Compose AlertDialog), custom content (Compose BasicAlertDialog) and the full-screen dialog (m3.material.io; Compose has none at the pin). A native <dialog> opened with showModal() (top layer, background inert); the m3e-elements dialog controller (<m3e-dialog>, m3e-react's dialogs) opens and closes it with the motion below, keeps Tab inside, locks the page scroll, asks to close on Escape / the scrim and returns focus.
<dialog class="m3e-dialog" role="alertdialog" aria-labelledby="h" aria-describedby="t">
<div class="m3e-dialog__scrim" aria-hidden="true"></div>
<div class="m3e-dialog__container">
<span class="m3e-icon m3e-dialog__icon" aria-hidden="true">favorite</span> (optional)
<h2 class="m3e-dialog__headline" id="h">Title</h2> (optional)
<div class="m3e-dialog__content" id="t">Supporting text</div> (optional)
<div class="m3e-dialog__actions">
<button class="m3e-button m3e-button--text" type="button">Dismiss</button>
<button class="m3e-button m3e-button--text" type="button">Confirm</button>
</div>
</div>
</dialog>
- --custom (BasicAlertDialog): the container only sizes and moves its content, which draws its
own surface. The dialog is a plain <dialog> named by aria-label (default "Dialog").
- --full-screen: a __header (a standard icon button __close, then the __headline) before the
__content, and the __actions (a text button, e.g. Save). Below the medium window size class
(600dp) it fills the window, the actions sit at the end of the 56dp header and the content
scrolls under it; from 600dp it is laid out as a basic dialog (m3.material.io: "For medium
and expanded breakpoints, use a basic dialog"), the close button before the headline.
- Buttons come from button.css / icon-button.css (import them as well). The dismiss button goes
first: the row is end-aligned and wraps upwards, so the confirm button sits at the end of a
row and on top when the buttons stack (AlertDialogFlowRow).
- JS hooks: [data-motion='enter' | 'exit'] runs the open / close animations (the controller
closes the <dialog> when the exit ones end); [data-scrolled] (full-screen) while the content
is scrolled.
67 custom properties it reads
| Custom property | Default |
|---|---|
--m3e-comp-dialog-action-label-text-color
|
var(--m3e-sys-color-primary) |
--m3e-comp-dialog-action-label-text-font
|
var(--m3e-sys-typescale-label-large-font) |
--m3e-comp-dialog-action-label-text-line-height
|
var(--m3e-sys-typescale-label-large-line-height) |
--m3e-comp-dialog-action-label-text-size
|
var(--m3e-sys-typescale-label-large-size) |
--m3e-comp-dialog-action-label-text-tracking
|
var(--m3e-sys-typescale-label-large-tracking) |
--m3e-comp-dialog-action-label-text-weight
|
var(--m3e-sys-typescale-label-large-weight) |
--m3e-comp-dialog-actions-cross-axis-spacing
|
8px |
--m3e-comp-dialog-actions-enter-delay
|
150ms |
--m3e-comp-dialog-actions-enter-duration
|
150ms |
--m3e-comp-dialog-actions-main-axis-spacing
|
8px |
--m3e-comp-dialog-button-min-height
|
40px |
--m3e-comp-dialog-container-collapsed-height
|
35% |
--m3e-comp-dialog-container-color
|
var(--m3e-sys-color-surface-container-high) |
--m3e-comp-dialog-container-enter-fade-duration
|
50ms |
--m3e-comp-dialog-container-exit-fade-delay
|
100ms |
--m3e-comp-dialog-container-exit-fade-duration
|
50ms |
--m3e-comp-dialog-container-max-width
|
560px |
--m3e-comp-dialog-container-min-width
|
280px |
--m3e-comp-dialog-container-padding
|
24px |
--m3e-comp-dialog-container-shape
|
var(--m3e-sys-shape-corner-extra-large) |
--m3e-comp-dialog-content-enter-delay
|
50ms |
--m3e-comp-dialog-content-enter-duration
|
200ms |
--m3e-comp-dialog-content-exit-duration
|
100ms |
--m3e-comp-dialog-enter-duration
|
500ms |
--m3e-comp-dialog-enter-easing
|
var(--m3e-sys-motion-easing-emphasized) |
--m3e-comp-dialog-exit-duration
|
150ms |
--m3e-comp-dialog-exit-easing
|
cubic-bezier(0.3, 0, 0.8, 0.15) |
--m3e-comp-dialog-headline-color
|
var(--m3e-sys-color-on-surface) |
--m3e-comp-dialog-headline-font
|
var(--m3e-sys-typescale-headline-small-font) |
--m3e-comp-dialog-headline-line-height
|
var(--m3e-sys-typescale-headline-small-line-height) |
--m3e-comp-dialog-headline-padding-bottom
|
16px |
--m3e-comp-dialog-headline-size
|
var(--m3e-sys-typescale-headline-small-size) |
--m3e-comp-dialog-headline-tracking
|
var(--m3e-sys-typescale-headline-small-tracking) |
--m3e-comp-dialog-headline-weight
|
var(--m3e-sys-typescale-headline-small-weight) |
--m3e-comp-dialog-icon-color
|
var(--m3e-sys-color-secondary) |
--m3e-comp-dialog-icon-padding-bottom
|
16px |
--m3e-comp-dialog-icon-size
|
24px |
--m3e-comp-dialog-scrim-duration
|
var(--m3e-sys-motion-spring-default-effects-duration) |
--m3e-comp-dialog-scrim-easing
|
var(--m3e-sys-motion-spring-default-effects-easing) |
--m3e-comp-dialog-slide-distance
|
50px |
--m3e-comp-dialog-supporting-text-color
|
var(--m3e-sys-color-on-surface-variant) |
--m3e-comp-dialog-supporting-text-font
|
var(--m3e-sys-typescale-body-medium-font) |
--m3e-comp-dialog-supporting-text-line-height
|
var(--m3e-sys-typescale-body-medium-line-height) |
--m3e-comp-dialog-supporting-text-padding-bottom
|
24px |
--m3e-comp-dialog-supporting-text-size
|
var(--m3e-sys-typescale-body-medium-size) |
--m3e-comp-dialog-supporting-text-tracking
|
var(--m3e-sys-typescale-body-medium-tracking) |
--m3e-comp-dialog-supporting-text-weight
|
var(--m3e-sys-typescale-body-medium-weight) |
--m3e-comp-dialog-window-margin
|
24px |
--m3e-comp-divider-thickness
|
1px |
--m3e-comp-full-screen-dialog-container-color
|
var(--m3e-sys-color-surface) |
--m3e-comp-full-screen-dialog-container-shape
|
var(--m3e-sys-shape-corner-none) |
--m3e-comp-full-screen-dialog-header-container-color
|
var(--m3e-sys-color-surface) |
--m3e-comp-full-screen-dialog-header-container-height
|
56px |
--m3e-comp-full-screen-dialog-header-headline-color
|
var(--m3e-sys-color-on-surface) |
--m3e-comp-full-screen-dialog-header-headline-font
|
var(--m3e-sys-typescale-title-large-font) |
--m3e-comp-full-screen-dialog-header-headline-line-height
|
var(--m3e-sys-typescale-title-large-line-height) |
--m3e-comp-full-screen-dialog-header-headline-size
|
var(--m3e-sys-typescale-title-large-size) |
--m3e-comp-full-screen-dialog-header-headline-tracking
|
var(--m3e-sys-typescale-title-large-tracking) |
--m3e-comp-full-screen-dialog-header-headline-weight
|
var(--m3e-sys-typescale-title-large-weight) |
--m3e-comp-full-screen-dialog-header-icon-color
|
var(--m3e-sys-color-on-surface) |
--m3e-comp-full-screen-dialog-header-icon-size
|
24px |
--m3e-comp-full-screen-dialog-header-on-scroll-container-color
|
var(--m3e-sys-color-surface-container) |
--m3e-comp-full-screen-dialog-header-spacing
|
8px |
--m3e-comp-full-screen-dialog-padding
|
24px |
--m3e-comp-minimum-interactive-component-size
|
48px |
--m3e-comp-scrim-container-color
|
var(--m3e-sys-color-scrim) |
--m3e-comp-scrim-container-opacity
|
0.32 |
Differences from Compose
the motion is Material Web's, not Compose's (owner decision M9 #9), on androidx's emphasized easing (D20 (6)); the window margin is Material Web's 24dp, not Android's dialog insets; the stacked buttons wrap by width as FlowRow does but the web measures the labels with the browser's text metrics; the full-screen dialog's header layout (8dp paddings) and colors are the owner's picks from m3.material.io (D19 (13)), its on-scroll elevation and m3.material.io's bottom action bar (deprecated in the token DB) are not built, and from 600dp it keeps its close button above the content (m3.material.io shows no anatomy for the swapped basic dialog).
Sources
Values come from androidx Compose Material3 1.5.0-beta01 at 19991f2c and from
m3.material.io, as cited below from COMPONENT_SPEC §29 and the m3e-css sources.
AlertDialog.kt, AndroidAlertDialog.android.kt, AlertDialogSamples.kt, ModalBottomSheet.kt, Button.kt (ButtonDefaults.MinHeight), DialogTokens.kt, ScrimTokens.kt, material3 res strings.xml, compose/ui res styles.xml (vendored); Material Web 2.5.0 dialog/internal/animations.js, dialog/internal/_dialog.scss, internal/motion/animation.js (vendored); m3.material.io Dialogs (Specs, Guidelines, Accessibility; token DB md.comp.dialog, md.comp.full-screen-dialog).
DialogTokens.kt@19991f2c (generated dialog.css) as read by AlertDialog.kt, ScrimTokens.kt (scrim.css), plus components/dialog-defaults.css (PARITY.md lines):
dialog.css sources
(compose/material3 @ 19991f2c; values: dialog.css, dialog-defaults.css, scrim.css, lines in PARITY.md):
- AlertDialog.kt BasicAlertDialog (L136-154): a dialog window, content sized in [DialogMinWidth, DialogMaxWidth], paneTitle Strings.Dialog. AlertDialogContent (L294-376): a Surface in AlertDialogDefaults.shape / containerColor with TonalElevation 0 and no shadow (owner decision M9 #8), a Column padded dialogPadding: the icon centered (IconPadding below), the title start-aligned or centered with an icon (TitlePadding below), the text weight(1, fill = false) with textPadding below, the buttons at the end (AlertDialogFlowRow L382-401: spacing 8dp, the stacked spacing less the buttons' minimum interactive size padding, which surrounds them).
- ModalBottomSheet.kt L146-151: the scrim (ScrimTokens color at ContainerOpacity) fades on DefaultEffects (owner decision M9 #9; Compose dialogs show the platform window dim).
- @material/web 2.5.0 dialog/internal/animations.js (secondary source; owner decision M9 #9, SOURCES register #72): open, the container slides down and its background grows from 35% of its height, then the parts fade in; close, the reverse, faster. Reduced motion: fades only. The open easing is androidx's emphasized (0.2, 0, 0, 1) (tokens/MotionTokens.kt L40, --m3e-sys-motion-easing-emphasized), not Material Web's approximation (D20 (6)). _dialog.scss: the window margin (register #73).
- m3.material.io Dialogs › Specs (full-screen; owner decision M9 #11, register #74).