Navigation

v0.0.4
Overlays

Dialog

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

<button type="button" class="btn" commandfor="delete-project" command="show-modal">Delete project</button><dialog id="delete-project" class="dialog" closedby="any" aria-labelledby="delete-project-title">  <header class="dialog-header">    <h2 class="dialog-title" id="delete-project-title">Delete this project?</h2>    <p class="dialog-description">Atlas and its 14 deployments are removed, and its domains stop resolving at once.</p>  </header>  <footer class="dialog-footer">    <button type="button" class="btn" commandfor="delete-project" command="close">Cancel</button>    <button type="button" class="btn btn-primary" commandfor="delete-project" command="close">Delete project</button>  </footer></dialog>

#Opening and closing

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.

#With a form

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

<button type="button" class="btn btn-primary" commandfor="invite" command="show-modal">Invite member</button><dialog id="invite" class="dialog" closedby="any" aria-labelledby="invite-title">  <header class="dialog-header">    <h2 class="dialog-title" id="invite-title">Invite a member</h2>    <p class="dialog-description">They receive an email with a link that expires in seven days.</p>  </header>  <form class="dialog-body flex flex-col gap-md" method="dialog" id="invite-form">    <label class="field">Email      <input class="input" type="email" placeholder="ada@example.com" required autofocus>    </label>    <label class="field">Role      <select class="select">        <option class="select-option">Member</option>        <option class="select-option">Admin</option>        <option class="select-option">Billing</option>      </select>    </label>  </form>  <footer class="dialog-footer">    <button type="button" class="btn" commandfor="invite" command="close">Cancel</button>    <button type="submit" class="btn btn-primary" form="invite-form">Send invite</button>  </footer>  <button type="button" class="btn btn-quiet btn-square btn-sm dialog-close" commandfor="invite" command="close" aria-label="Close">    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">      <path d="M6 6l12 12M18 6 6 18" />    </svg>  </button></dialog>

#Sizes

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

<button type="button" class="btn" commandfor="size-sm" command="show-modal">Small</button><button type="button" class="btn" commandfor="size-lg" command="show-modal">Large</button><dialog id="size-sm" class="dialog dialog-sm" closedby="any" aria-labelledby="size-sm-title">  <header class="dialog-header">    <h2 class="dialog-title" id="size-sm-title">Sign out?</h2>    <p class="dialog-description">Unsaved drafts stay on this device.</p>  </header>  <footer class="dialog-footer">    <button type="button" class="btn" commandfor="size-sm" command="close">Stay</button>    <button type="button" class="btn btn-primary" commandfor="size-sm" command="close">Sign out</button>  </footer></dialog><dialog id="size-lg" class="dialog dialog-lg" closedby="any" aria-labelledby="size-lg-title">  <header class="dialog-header">    <h2 class="dialog-title" id="size-lg-title">Release notes</h2>    <p class="dialog-description">Version 2.1 adds overlays that need no script: dialogs, popovers, menus and tooltips.</p>  </header>  <footer class="dialog-footer">    <button type="button" class="btn btn-primary" commandfor="size-lg" command="close">Done</button>  </footer></dialog>

#Drawer

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

<button type="button" class="btn" commandfor="filters" command="show-modal">Filters</button><dialog id="filters" class="dialog dialog-drawer" closedby="any" aria-labelledby="filters-title">  <header class="dialog-header">    <h2 class="dialog-title" id="filters-title">Filters</h2>    <p class="dialog-description">Narrow the list of deployments.</p>  </header>  <div class="dialog-body flex flex-col gap-md">    <label class="field">Branch      <input class="input" value="main">    </label>    <label class="check">      <input type="checkbox" class="checkbox" checked>Production only</label>  </div>  <footer class="dialog-footer">    <button type="button" class="btn" commandfor="filters" command="close">Reset</button>    <button type="button" class="btn btn-primary" commandfor="filters" command="close">Apply</button>  </footer></dialog>

#At the start

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.

A drawer at the start

<button type="button" class="btn" commandfor="nav-drawer" command="show-modal">Open navigation</button><dialog id="nav-drawer" class="dialog dialog-drawer dialog-drawer-start" closedby="any" aria-label="Navigation">  <div class="dialog-body">    <nav class="nav" aria-label="Workspace">      <a class="nav-item" href="#" aria-current="page">Overview</a>      <a class="nav-item" href="#">Projects</a>      <a class="nav-item" href="#">Domains</a>      <a class="nav-item" href="#">Settings</a>    </nav>  </div></dialog>

#Sheet

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

<button type="button" class="btn" commandfor="share-sheet" command="show-modal">Share</button><dialog id="share-sheet" class="dialog dialog-sheet" closedby="any" aria-labelledby="share-sheet-title">  <header class="dialog-header">    <h2 class="dialog-title" id="share-sheet-title">Share this project</h2>    <p class="dialog-description">Anyone with the link can view the latest deployment.</p>  </header>  <div class="dialog-body">    <div class="input-group">      <input class="input-group-field" value="https://atlas.dev/p/9f2c" readonly aria-label="Link">      <button type="button" class="btn">Copy</button>    </div>  </div>  <footer class="dialog-footer">    <button type="button" class="btn btn-primary" commandfor="share-sheet" command="close">Done</button>  </footer></dialog>

#Motion

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.

#Accessibility

  • 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.

#React, Svelte and Vue

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'export interface DialogProps extends Omit<ComponentProps<'dialog'>, 'open'> {  /** Whether the dialog is open, when this component is told. Leave it out and the trigger opens it. */  open?: boolean  onOpenChange?: (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"`. */export function Dialog({ open, onOpenChange, trigger, className, id, children, ...dialog }: DialogProps) {  const generated = useId()  const name = id ?? generated  const 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.current    if (!root || open === undefined) return    known.current = open    if (open && !root.open) root.showModal()    else if (!open && root.open) root.close()  }, [open, tick])  useEffect(() => {    const root = element.current    if (!root) return    const controller = new AbortController()    const report = () => {      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' })}      <dialog id={name} className={className ? `dialog ${className}` : 'dialog'} ref={element} {...{ closedby: 'any' }} {...dialog}>{children}</dialog>    </>  )}
ui/Dialog.svelte
<script lang="ts" module>  import type { Snippet } from 'svelte'  import type { HTMLDialogAttributes } from 'svelte/elements'  export interface DialogProps extends Omit<HTMLDialogAttributes, 'open'> {    /** Whether the dialog is open. Bind it, or leave it out and the trigger opens it. */    open?: boolean    onOpenChange?: (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><script lang="ts">  import type { 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 = false  const presence: Attachment<HTMLDialogElement> = (root) => {    const controller = new AbortController()    const report = () => {      if (root.open === known) return      open = known = root.open      onOpenChange?.(known)    }    for (const type of ['toggle', 'close']) root.addEventListener(type, report, { signal: controller.signal })    $effect(() => {      if (open === undefined) return      known = open      if (open && !root.open) root.showModal()      else if (!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"`.-->{@render trigger?.({ commandfor: name, command: 'show-modal' })}<dialog id={name} class={['dialog', className]} closedby="any" {@attach presence} {...dialog}>{@render children?.()}</dialog>
ui/Dialog.vue
<script setup lang="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' }) => unknown  default?: () => 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 = falseconst sync = () => {  const dialog = root.value  if (!dialog || open.value === undefined) return  known = open.value  if (open.value && !dialog.open) dialog.showModal()  else if (!open.value && dialog.open) dialog.close()}const report = () => {  const dialog = root.value  if (!dialog) return  if (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.  void nextTick(sync)}onMounted(sync)watch(open, sync, { flush: 'post' })</script><template>  <slot name="trigger" v-bind="{ commandfor: name, command: 'show-modal' as const }" />  <dialog :id="name" class="dialog" closedby="any" ref="root" v-bind="$attrs" @toggle="report" @close="report"><slot /></dialog></template>

#Classes

ClassDescription
dialogThe 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-sm22rem wide, for a confirmation of one sentence; the default is 28rem. With dialog
dialog-lg40rem wide; the default is 28rem. With dialog
dialog-drawerA 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-startWith dialog-drawer: the panel rests on the inline-start edge, 18rem wide, where navigation lives. With dialog
dialog-sheetA panel that rises from the bottom edge, with square lower corners, clear of the home indicator. With dialog
dialog-headerHolds the title and the description at the top of the sheet
dialog-titleThe title; point the dialog's aria-labelledby at it
dialog-descriptionA quiet sentence under the title that says what the title means
dialog-bodyContent between header and footer. It takes the remaining height and scrolls inside the sheet when it is long
dialog-footerThe actions, the main one last. Under 22rem of dialog width they stack at full width, the main one first
dialog-closePlaces a quiet square button in the top corner at the inline end; it needs an accessible name

#Custom properties

PropertyDefaultDescription
--dialog-width28remPreferred width
--dialog-padding1.5remPadding around the parts
--dialog-radius1.25remCorner radius

#Source

@master/ui/components/dialog.css
/* * 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: 0 0;      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: 0 0;        @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: .5rem auto;      &: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: auto auto 0;      padding-bottom: max(var(--dialog-padding), env(safe-area-inset-bottom));      border-end-start-radius: 0;      border-end-end-radius: 0;      scale: 1;      translate: 0 100%;      &[open] {        translate: 0 0;        @starting-style {          scale: 1;          translate: 0 100%;        }      }    }    @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: 1px solid 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: 0 var(--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.35 var(--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: 1 1 auto;    min-height: 0;    margin-block: -.25rem;    padding: .25rem var(--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: .25rem var(--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;  }}