A native modal on an overlay sheet, opened and closed without script.
On this page
A dialog is the highest surface on the ladder: an overlay sheet over a scrim that dims and softens the page. Use .dialog on a native <dialog>. A button with commandfor and command="show-modal" opens it, so the browser supplies the top layer, the focus trap and Esc, and no script is involved.
Dialog
<buttontype="button"class="btn"commandfor="delete-project"command="show-modal">Delete project</button><dialogid="delete-project"class="dialog"closedby="any"aria-labelledby="delete-project-title"><headerclass="dialog-header"><h2class="dialog-title"id="delete-project-title">Delete this project?</h2><pclass="dialog-description">Atlas and its 14 deployments are removed, and its domains stop resolving at once.</p></header><footerclass="dialog-footer"><buttontype="button"class="btn"commandfor="delete-project"command="close">Cancel</button><buttontype="button"class="btn btn-primary"commandfor="delete-project"command="close">Delete project</button></footer></dialog>
Three attributes replace the script a modal used to need:
commandfor="id" with command="show-modal" on a button opens the dialog as a modal. command="close" closes it and returns focus to the button that opened it.
closedby="any" on the dialog adds light dismiss: a click on the scrim closes it, as Esc always does. Leave it out when a stray click must not discard work. A browser that does not know the attribute keeps Esc and the close buttons.
A <form method="dialog"> inside the dialog closes it on submit and reports which button was used through the dialog's returnValue.
While a modal dialog is open, the page behind it is inert and does not scroll.
dialog-body holds the content between the header and the footer. It takes the remaining height and scrolls inside the sheet when the content is longer than the viewport allows. dialog-close puts a quiet square button in the corner: give it an accessible name.
With a form
<buttontype="button"class="btn btn-primary"commandfor="invite"command="show-modal">Invite member</button><dialogid="invite"class="dialog"closedby="any"aria-labelledby="invite-title"><headerclass="dialog-header"><h2class="dialog-title"id="invite-title">Invite a member</h2><pclass="dialog-description">They receive an email with a link that expires in seven days.</p></header><formclass="dialog-body flexflex-colgap-md"method="dialog"id="invite-form"><labelclass="field">Email<inputclass="input"type="email"placeholder="ada@example.com"requiredautofocus></label><labelclass="field">Role<selectclass="select"><optionclass="select-option">Member</option><optionclass="select-option">Admin</option><optionclass="select-option">Billing</option></select></label></form><footerclass="dialog-footer"><buttontype="button"class="btn"commandfor="invite"command="close">Cancel</button><buttontype="submit"class="btn btn-primary"form="invite-form">Send invite</button></footer><buttontype="button"class="btn btn-quiet btn-square btn-sm dialog-close"commandfor="invite"command="close"aria-label="Close"><svgclass="icon"viewBox="0 0 24 24"fill="none"stroke="currentColor"stroke-width="1.5"stroke-linecap="round"stroke-linejoin="round"aria-hidden="true"><pathd="M6 6l12 12M18 6 6 18"/></svg></button></dialog>
A dialog is 28rem wide. dialog-sm is 22rem, for a confirmation of one sentence, and dialog-lg is 40rem. Each keeps 1rem clear of the viewport on a narrow screen. The footer answers to the dialog's own width through a container query: under 22rem its buttons stack at full width, the main action first.
Sizes
<buttontype="button"class="btn"commandfor="size-sm"command="show-modal">Small</button><buttontype="button"class="btn"commandfor="size-lg"command="show-modal">Large</button><dialogid="size-sm"class="dialog dialog-sm"closedby="any"aria-labelledby="size-sm-title"><headerclass="dialog-header"><h2class="dialog-title"id="size-sm-title">Sign out?</h2><pclass="dialog-description">Unsaved drafts stay on this device.</p></header><footerclass="dialog-footer"><buttontype="button"class="btn"commandfor="size-sm"command="close">Stay</button><buttontype="button"class="btn btn-primary"commandfor="size-sm"command="close">Sign out</button></footer></dialog><dialogid="size-lg"class="dialog dialog-lg"closedby="any"aria-labelledby="size-lg-title"><headerclass="dialog-header"><h2class="dialog-title"id="size-lg-title">Release notes</h2><pclass="dialog-description">Version 2.1 adds overlays that need no script: dialogs, popovers, menus and tooltips.</p></header><footerclass="dialog-footer"><buttontype="button"class="btn btn-primary"commandfor="size-lg"command="close">Done</button></footer></dialog>
dialog-drawer is a panel on the inline-end edge, for filters, details and settings that keep the page in view. It floats 8px inside the viewport, so its corner and its shadow stay whole, and slides in from the edge it rests on.
Drawer
<buttontype="button"class="btn"commandfor="filters"command="show-modal">Filters</button><dialogid="filters"class="dialog dialog-drawer"closedby="any"aria-labelledby="filters-title"><headerclass="dialog-header"><h2class="dialog-title"id="filters-title">Filters</h2><pclass="dialog-description">Narrow the list of deployments.</p></header><divclass="dialog-body flexflex-colgap-md"><labelclass="field">Branch<inputclass="input"value="main"></label><labelclass="check"><inputtype="checkbox"class="checkbox"checked>Production only</label></div><footerclass="dialog-footer"><buttontype="button"class="btn"commandfor="filters"command="close">Reset</button><buttontype="button"class="btn btn-primary"commandfor="filters"command="close">Apply</button></footer></dialog>
Add dialog-drawer-start and the drawer rests on the inline-start edge, 18rem wide: the place for navigation. A sidebar that has left a narrow layout comes back as this drawer; see the app shell.
dialog-sheet rises from the bottom edge, for a short task or a list of choices on a small screen, where the thumb is. It spans the width up to its --dialog-width, squares its lower corners against the edge and clears the home indicator with env(safe-area-inset-bottom).
Sheet
<buttontype="button"class="btn"commandfor="share-sheet"command="show-modal">Share</button><dialogid="share-sheet"class="dialog dialog-sheet"closedby="any"aria-labelledby="share-sheet-title"><headerclass="dialog-header"><h2class="dialog-title"id="share-sheet-title">Share this project</h2><pclass="dialog-description">Anyone with the link can view the latest deployment.</p></header><divclass="dialog-body"><divclass="input-group"><inputclass="input-group-field"value="https://atlas.dev/p/9f2c"readonlyaria-label="Link"><buttontype="button"class="btn">Copy</button></div></div><footerclass="dialog-footer"><buttontype="button"class="btn btn-primary"commandfor="share-sheet"command="close">Done</button></footer></dialog>
The dialog fades in from 97% of its size and half a rem below, over 280ms; it leaves in 160ms. The stylesheet writes both directions in CSS: @starting-style gives the first rendered frame, and display and overlay transition with allow-discrete, so the exit plays before the dialog leaves the top layer. With reduced motion only the fade remains.
Name the dialog with aria-labelledby pointing at its dialog-title.
Opened with show-modal, focus moves into the dialog and returns to the invoking button on close. Put autofocus on the field a reader should start in. For a destructive confirmation, put it on the safe action.
command and commandfor are supported in current Chrome, Edge, Firefox and Safari. In an older browser, call dialog.showModal() from a click handler; the stylesheet is the same.
The dialog component opens and closes the dialog from the application's state and says when the browser did. The trigger and the parts inside stay the page's own markup. Copy it into the project with npx master-ui add dialog; React, Svelte and Vue says how the components are built and used.
ui/dialog.tsx
'use client'import{ useEffect, useId, useReducer, useRef,type ComponentProps,type ReactNode }from'react'exportinterfaceDialogPropsextendsOmit<ComponentProps<'dialog'>,'open'>{/** Whether the dialog is open, when this component is told. Leave it out and the trigger opens it. */open?:booleanonOpenChange?:(open:boolean)=>void/** The button that opens the dialog: spread what it is given on a button of your own. */trigger?:(attributes:{commandfor:string,command:'show-modal'})=>ReactNode}/** * A modal dialog. The browser opens it from the trigger, traps focus and closes it on Esc; this component * only opens and closes it from `open`, and says when the browser did. Its children are the contract's * parts: `dialog-header`, `dialog-body`, `dialog-footer`. A button inside closes it with * `commandfor={id}` and `command="close"`, or from a form with `method="dialog"`. */exportfunctionDialog({ open, onOpenChange, trigger, className, id, children,...dialog }:DialogProps){const generated =useId()const name = id ?? generatedconst element =useRef<HTMLDialogElement>(null)const heard =useRef(onOpenChange)/** What the owner knows: the last state it set or was told. */const known =useRef(false)const[tick, again]=useReducer((count:number)=> count +1,0)useEffect(()=>{ heard.current= onOpenChange })useEffect(()=>{const root = element.currentif(!root || open ===undefined)return known.current= openif(open &&!root.open) root.showModal()elseif(!open && root.open) root.close()},[open, tick])useEffect(()=>{const root = element.currentif(!root)returnconst controller =newAbortController()constreport=()=>{if(root.open!== known.current) heard.current?.(known.current= root.open)// An `open` this component is told wins: if its owner did not take the change, the dialog goes back.again()}for(const type of['toggle','close']) root.addEventListener(type, report,{signal: controller.signal})return()=> controller.abort()},[])return(<>{trigger?.({commandfor: name,command:'show-modal'})}<dialogid={name}className={className ?`dialog ${className}`:'dialog'}ref={element}{...{closedby:'any'}}{...dialog}>{children}</dialog></>)}
ui/Dialog.svelte
<scriptlang="ts"module>importtype{ Snippet }from'svelte'importtype{ HTMLDialogAttributes }from'svelte/elements'exportinterfaceDialogPropsextendsOmit<HTMLDialogAttributes,'open'>{/** Whether the dialog is open. Bind it, or leave it out and the trigger opens it. */open?:booleanonOpenChange?:(open:boolean)=>void/** The button that opens the dialog: spread what it is given on a button of your own. */trigger?:Snippet<[{commandfor:string,command:'show-modal'}]>}</script><scriptlang="ts">importtype{ Attachment }from'svelte/attachments'let{ open =$bindable(), onOpenChange, trigger,class: className, id, children,...dialog }:DialogProps=$props()const generated =$props.id()const name =$derived(id ?? generated)/** What the owner knows: the last state it set or was told. */let known =falseconstpresence:Attachment<HTMLDialogElement>=(root)=>{const controller =newAbortController()constreport=()=>{if(root.open=== known)return open = known = root.openonOpenChange?.(known)}for(const type of['toggle','close']) root.addEventListener(type, report,{signal: controller.signal})$effect(()=>{if(open ===undefined)return known = openif(open &&!root.open) root.showModal()elseif(!open && root.open) root.close()})return()=> controller.abort()}</script><!--@component A modal dialog. The browser opens it from the trigger, traps focus and closes it on Esc; this component only opens and closes it from `open`, and says when the browser did. Its children are the contract's parts: `dialog-header`, `dialog-body`, `dialog-footer`. A button inside closes it with`commandfor={id}` and `command="close"`, or from a form with `method="dialog"`.-->{@rendertrigger?.({commandfor: name,command:'show-modal'})}<dialogid={name}class={['dialog', className]}closedby="any"{@attach presence}{...dialog}>{@renderchildren?.()}</dialog>
ui/Dialog.vue
<scriptsetuplang="ts">import{ computed, nextTick, onMounted, useId, useTemplateRef, watch }from'vue'defineOptions({inheritAttrs:false})const{ id }=defineProps<{id?:string}>()/** Whether the dialog is open: `v-model:open`. Leave it out and the trigger opens it. */const open =defineModel<boolean>('open')defineSlots<{/** The button that opens the dialog: bind what it is given on a button of your own. */trigger?:(attributes:{commandfor:string,command:'show-modal'})=>unknowndefault?:()=>unknown}>()/* * A modal dialog. The browser opens it from the trigger, traps focus and closes it on Esc; this component * only opens and closes it from `open`, and says when the browser did. Its default slot is the contract's * parts: `dialog-header`, `dialog-body`, `dialog-footer`. A button inside closes it with * `commandfor` and `command="close"`, or from a form with `method="dialog"`. */const generated =useId()const name =computed(()=> id ?? generated)const root =useTemplateRef<HTMLDialogElement>('root')/** What the owner knows: the last state it set or was told. */let known =falseconstsync=()=>{const dialog = root.valueif(!dialog || open.value===undefined)return known = open.valueif(open.value&&!dialog.open) dialog.showModal()elseif(!open.value&& dialog.open) dialog.close()}constreport=()=>{const dialog = root.valueif(!dialog)returnif(dialog.open!== known) open.value= known = dialog.open// An `open` this component is told wins: if its owner did not take the change, the dialog goes back.voidnextTick(sync)}onMounted(sync)watch(open, sync,{flush:'post'})</script><template><slotname="trigger"v-bind="{commandfor: name,command:'show-modal'asconst}" /><dialog:id="name"class="dialog"closedby="any"ref="root"v-bind="$attrs"@toggle="report"@close="report"><slot /></dialog></template>
The overlay sheet, on a dialog element named with aria-labelledby or aria-label. It is 28rem wide and keeps 1rem clear of the viewport
dialog-sm
22rem wide, for a confirmation of one sentence; the default is 28rem. With dialog
dialog-lg
40rem wide; the default is 28rem. With dialog
dialog-drawer
A panel on the inline-end edge, 24rem wide, for work that keeps the page in view. It floats 8px inside the viewport and slides in from the edge it rests on. With dialog
dialog-drawer-start
With dialog-drawer: the panel rests on the inline-start edge, 18rem wide, where navigation lives. With dialog
dialog-sheet
A panel that rises from the bottom edge, with square lower corners, clear of the home indicator. With dialog
dialog-header
Holds the title and the description at the top of the sheet
dialog-title
The title; point the dialog's aria-labelledby at it
dialog-description
A quiet sentence under the title that says what the title means
dialog-body
Content between header and footer. It takes the remaining height and scrolls inside the sheet when it is long
dialog-footer
The actions, the main one last. Under 22rem of dialog width they stack at full width, the main one first
dialog-close
Places a quiet square button in the top corner at the inline end; it needs an accessible name
/* * Dialog: a native <dialog>, opened as a modal by a button with commandfor and command="show-modal". * The browser supplies the top layer, the focus trap, Escape and, with closedby="any", light dismiss. * The stylesheet supplies the overlay sheet, the scrim, and the motion in and out through @starting-style * and discrete transitions. Geometry: a 20px corner, 24px of padding, 10px controls. */@layer components {.dialog{--dialog-width:28rem;--dialog-padding:1.5rem;--dialog-radius:1.25rem;--surface-color:var(--color-surface-floating);/* The parts respond to the dialog's own width, not the viewport's. */container: dialog / inline-size;flex-direction:column;gap:1.25rem;width:min(var(--dialog-width),calc(100%-2rem));max-width:none;max-height:min(calc(100dvh-2rem),44rem);margin:auto;padding:var(--dialog-padding)0;border:0;border-radius:calc(var(--dialog-radius)*var(--corner-scale));corner-shape:var(--corner-shape);overflow:hidden;background:var(--color-surface-floating);color:var(--color-text-body);box-shadow:var(--shadow-overlay);font-size:.875rem;line-height:1.55;/* Closed is where it leaves to: it fades and settles back a little, faster than it arrived. */opacity:0;scale:.97;translate:0.5rem;transition-property: opacity, scale, translate,overlay, display;transition-duration:var(--duration-quick);transition-timing-function:var(--easing-glide);transition-behavior: allow-discrete;&[open]{display:flex;opacity:1;scale:1;translate:00;transition-duration:var(--duration-base);/* Where it arrives from, on the first frame it is rendered. */@starting-style{opacity:0;scale:.97;translate:0.5rem;}}/* The scrim dims and softens the page; it fades with the dialog. */&::backdrop{background:var(--color-surface-scrim);backdrop-filter:blur(3px);opacity:0;transition-property: opacity,overlay, display;transition-duration:var(--duration-quick);transition-timing-function:var(--easing-glide);transition-behavior: allow-discrete;}&[open]::backdrop{opacity:1;transition-duration:var(--duration-base);}@starting-style{&[open]::backdrop{opacity:0;}}&:focus-visible{outline:none;}&.dialog-sm{--dialog-width:22rem;}&.dialog-lg{--dialog-width:40rem;}/* * Drawer: a panel on the inline-end edge for work that keeps the page in view. It floats 8px inside the * viewport, so its corner and its shadow stay whole, and it slides in from the edge it rests on. */&.dialog-drawer{--dialog-width:24rem;--dialog-from:calc(100%+.5rem);width:min(var(--dialog-width),calc(100%-1rem));height:calc(100dvh-1rem);max-height:none;margin:.5rem;margin-inline-start:auto;scale:1;translate:var(--dialog-from)0;&:dir(rtl){--dialog-from:calc(-100%-.5rem);}&[open]{translate:00;@starting-style{scale:1;translate:var(--dialog-from)0;}}}/* * Start: the same panel on the inline-start edge, where navigation lives. A sidebar that has left a narrow * layout comes back as this drawer. Use it together with dialog-drawer. */&.dialog-drawer.dialog-drawer-start{--dialog-width:18rem;--dialog-from:calc(-100%-.5rem);margin-inline:.5remauto;&:dir(rtl){--dialog-from:calc(100%+.5rem);}}/* * Sheet: a panel that rises from the bottom edge, for a task on a small screen or a short list of * choices. It spans the width up to --dialog-width, squares its lower corners and clears the home indicator. */&.dialog-sheet{width:min(var(--dialog-width),100%);max-height:calc(100dvh-3rem);margin:autoauto0;padding-bottom:max(var(--dialog-padding), env(safe-area-inset-bottom));border-end-start-radius:0;border-end-end-radius:0;scale:1;translate:0100%;&[open]{translate:00;@starting-style{scale:1;translate:0100%;}}}@media(prefers-reduced-motion:reduce){scale:none;translate:none;&[open],&.dialog-drawer,&.dialog-drawer[open],&.dialog-sheet,&.dialog-sheet[open]{scale:none;translate:none;@starting-style{scale:none;translate:none;}}}@media(forced-colors:active){outline:1pxsolid CanvasText;outline-offset:-1px;}}/* The title and what it means, at the top of the sheet. */.dialog-header{display:grid;flex:none;gap:.375rem;padding:0var(--dialog-padding);}/* Leave the title clear of the close button. */.dialog:has(.dialog-close).dialog-header{padding-inline-end:calc(var(--dialog-padding)+2rem);}.dialog-title{margin:0;color:var(--color-text-strong);font:var(--font-weight-control)1.0625rem/1.35var(--font-family-sans);font-feature-settings:inherit;letter-spacing:-.01em;text-wrap:balance;overflow-wrap: anywhere;}.dialog-description{margin:0;color:var(--color-text-muted);text-wrap: pretty;overflow-wrap: anywhere;}/* The body takes the remaining height and scrolls inside the sheet; a little block padding keeps focus rings whole. */.dialog-body{flex:11auto;min-height:0;margin-block:-.25rem;padding:.25remvar(--dialog-padding);overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin;}/* Actions end the sheet, the main one last. In a narrow dialog they stack at full width, the main one first. */.dialog-footer{display:flex;flex:none;flex-wrap:wrap;justify-content:flex-end;gap:.5rem;padding:.25remvar(--dialog-padding)0;@container dialog (inline-size<22rem){flex-direction:column-reverse;align-items:stretch;}}/* A quiet square button in the corner; give it an accessible name. */.dialog.dialog-close{position:absolute;top:calc(var(--dialog-padding)-.4375rem);inset-inline-end:calc(var(--dialog-padding)-.4375rem);}/* While a modal dialog is open the page behind it does not scroll. */:root:has(.dialog:modal){overflow:hidden;}}