Modal Dialog

The dialog rises and scales in over a fading backdrop, and leaves the same way, all on the native dialog element.

  • CSS
  • Layout
  • Toggle
  • Added

Delete this project?

Its motions and settings will be removed. This cannot be undone.

AI prompt

Create a modal using the native <dialog> element with CSS enter and exit transitions. Open it with showModal() so focus trapping, Escape to close, and the top layer come for free. Style the closed state with opacity 0, translateY(12px), and scale(0.96), and [open] with opacity 1 and no transform. Transition opacity, transform, overlay, and display over 260ms with cubic-bezier(0.16, 1, 0.3, 1) and transition-behavior: allow-discrete so the exit can animate too. Use @starting-style to give the [open] state its starting values so the enter animates. Do the same for ::backdrop, fading a dark scrim with a light backdrop blur. Close on the Cancel button, on Confirm, and on a click on the backdrop. Under prefers-reduced-motion, keep the fade and drop the movement. The dialog title reads "Delete this project?".

Source

Demo.tsx
'use client'

import { useId, useRef } from 'react'
import s from './demo.module.css'

export default function ModalDialog({ labels }: { labels: Record<string, string> }) {
  const ref = useRef<HTMLDialogElement>(null)
  const titleId = useId()
  const close = () => ref.current?.close()

  return (
    <>
      <button type="button" className={s.open} onClick={() => ref.current?.showModal()}>
        {labels.open}
      </button>
      <dialog
        ref={ref}
        className={s.dialog}
        aria-labelledby={titleId}
        onClick={(e) => e.target === e.currentTarget && close()}
      >
        <div className={s.body}>
          <h2 id={titleId} className={s.title}>
            {labels.title}
          </h2>
          <p className={s.text}>{labels.body}</p>
          <div className={s.actions}>
            <button type="button" className={s.ghost} onClick={close} autoFocus>
              {labels.cancel}
            </button>
            <button type="button" className={s.danger} onClick={close}>
              {labels.confirm}
            </button>
          </div>
        </div>
      </dialog>
    </>
  )
}
demo.module.css
.open {
  height: 44px;
  padding: 0 20px;
  border: 1px solid var(--stage-rule);
  border-radius: var(--radius-sm);
  background: var(--stage-surface);
  color: var(--stage-ink);
  font: 500 var(--text-sm) / 1 var(--font-body);
  cursor: pointer;
}

.open:hover { border-color: var(--stage-accent); }
.open:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }

.dialog {
  width: min(360px, calc(100vw - 32px));
  padding: 0;
  border: 1px solid var(--stage-rule);
  border-radius: var(--radius-md);
  background: var(--stage-surface);
  color: var(--stage-ink);
  box-shadow: 0 24px 60px oklch(0% 0 0 / 0.45);
  opacity: 0;
  transform: translateY(12px) scale(0.96);
  transition:
    opacity 260ms var(--ease-out),
    transform 260ms var(--ease-out),
    overlay 260ms allow-discrete,
    display 260ms allow-discrete;
}

.dialog[open] {
  opacity: 1;
  transform: none;
}

@starting-style {
  .dialog[open] {
    opacity: 0;
    transform: translateY(12px) scale(0.96);
  }
}

.dialog::backdrop {
  background: oklch(0% 0 0 / 0);
  backdrop-filter: blur(0);
  transition:
    background-color 260ms var(--ease-out),
    backdrop-filter 260ms var(--ease-out),
    overlay 260ms allow-discrete,
    display 260ms allow-discrete;
}

.dialog[open]::backdrop {
  background: oklch(0% 0 0 / 0.55);
  backdrop-filter: blur(3px);
}

@starting-style {
  .dialog[open]::backdrop {
    background: oklch(0% 0 0 / 0);
    backdrop-filter: blur(0);
  }
}

.body {
  display: grid;
  gap: 10px;
  padding: 22px;
}

.title {
  margin: 0;
  font: 600 var(--text-lg) / 1.3 var(--font-display);
  color: var(--stage-ink);
}

.text {
  margin: 0;
  font: 400 var(--text-sm) / 1.5 var(--font-body);
  color: var(--stage-muted);
}

.actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  margin-top: 10px;
}

.ghost, .danger {
  height: 38px;
  padding: 0 16px;
  border-radius: var(--radius-sm);
  font: 500 var(--text-sm) / 1 var(--font-body);
  cursor: pointer;
}

.ghost { border: 1px solid var(--stage-rule); background: transparent; color: var(--stage-ink); }
.danger { border: 0; background: var(--stage-accent); color: var(--stage-accent-ink); }
.ghost:focus-visible, .danger:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }

@media (prefers-reduced-motion: reduce) {
  .dialog, .dialog[open] { transform: none; }
  @starting-style {
    .dialog[open] { transform: none; }
  }
}

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.