CSS Modals: 16 Examples Built on the dialog Element

Most modals still get built from a div, a high z-index and a keydown listener. The native dialog element makes that unnecessary: showModal() puts it in the top layer, draws a ::backdrop and makes the page behind it inert.

Below are 16 CSS modal examples you can try and copy, followed by a guide to focus, scroll lock and animation.

By Bogdan Sandu · Updated October 27, 2026

Modal dialogs built on showModal()

Seven everyday modals on the native dialog element. Each one opens with showModal(), so the browser puts it in the top layer over the whole page, makes everything behind it inert, closes it on Escape and sends focus back to the button that opened it.

Destructive confirm dialog

  • showModal()
  • form method="dialog"
  • returnValue

Harbor Street redesign

14 files, edited 2 hours ago

Delete this project?

Harbor Street redesign and its 14 files will be removed for everyone on the team. This cannot be undone.

Click the button. The dialog opens over the whole page, press Escape or use its buttons to close it.

The buttons sit in a form with method="dialog", so pressing one closes the dialog and writes its value into returnValue without any click handlers. Cancel carries autofocus because the W3C pattern puts initial focus on the least destructive action. The close event reads returnValue, and an empty value means Escape.

View code
.mo-del {
  width: min(100%, 26rem);
}
.mo-del__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px 16px;
  padding: 18px 20px;
  border: 1px solid #e3e6ee;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 8px 24px rgba(16, 24, 40, .06);
}
.mo-del__name {
  margin: 0;
  font-weight: 700;
  color: #101828;
}
.mo-del__sub {
  margin: 2px 0 0;
  font-size: 14px;
  color: #5b6474;
}
.mo-del__open {
  min-height: 44px;
  padding: 0 18px;
  border: 1px solid #f3c7c2;
  border-radius: 10px;
  background: #fff5f4;
  color: #b42318;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.mo-del__open:hover {
  background: #ffe9e6;
}
.mo-del__status {
  min-height: 1.5em;
  margin: 12px 4px 0;
  font-size: 14px;
  color: #344054;
}
.mo-del__dialog {
  width: min(26rem, calc(100% - 2rem));
  margin: auto;
  padding: 28px 24px 24px;
  border: 0;
  border-radius: 16px;
  background: #fff;
  color: #101828;
  box-shadow: 0 24px 64px rgba(16, 24, 40, .28);
}
.mo-del__dialog::backdrop {
  background: rgb(16 24 40 / .55);
}
.mo-del__icon {
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;
  margin-bottom: 16px;
  border-radius: 50%;
  background: #fee4e2;
  color: #b42318;
}
.mo-del__dialog h2 {
  font-family: inherit;
  letter-spacing: normal;
  margin: 0 0 8px;
  font-size: 20px;
  line-height: 1.3;
  color: #101828;
}
.mo-del__dialog p {
  margin: 0;
  font-size: 15px;
  line-height: 1.55;
  color: #475467;
}
.mo-del__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 24px;
}
.mo-del__actions button {
  flex: 1 1 8rem;
  min-height: 44px;
  padding: 0 16px;
  border: 1px solid #d0d5dd;
  border-radius: 10px;
  background: #fff;
  color: #344054;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.mo-del__actions button:hover {
  background: #f5f6f8;
}
.mo-del__actions .mo-del__danger {
  border-color: #b42318;
  background: #b42318;
  color: #fff;
}
.mo-del__actions .mo-del__danger:hover {
  background: #912018;
}
.mo-del button:focus-visible {
  outline: 3px solid #2e5bff;
  outline-offset: 2px;
}

Newsletter signup with a photo

  • split layout
  • required input
  • success state

Field Notes

One long read on slow travel every Sunday morning. No ads, no tracking pixels.

Gabled canal houses with rowing boats moored along the water

Get Field Notes on Sunday

An essay, three photos and one hand-drawn map every week. Unsubscribe with one click.

Click the button. The dialog opens over the whole page, press Escape or use its buttons to close it.

A two-column signup that stacks on phones, with the photo on top. The form never submits: the script cancels the submit event, then swaps the copy for a confirmation and moves focus to the Done button. Use this pattern on click only, never as a popup on page load.

View code
.mo-news__card {
  width: min(100%, 22rem);
  padding: 26px 24px;
  border-radius: 18px;
  background: #fbf7ef;
  color: #1d2b27;
}
.mo-news__title {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 28px;
  font-weight: 600;
  line-height: 1.1;
}
.mo-news__text {
  margin: 8px 0 18px;
  font-size: 15px;
  line-height: 1.5;
  color: #4d5a55;
}
.mo-news__open,
.mo-news__submit,
.mo-news__done {
  min-height: 46px;
  padding: 0 20px;
  border: 0;
  border-radius: 999px;
  background: #285e4f;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.mo-news__open:hover,
.mo-news__submit:hover,
.mo-news__done:hover {
  background: #1d4a3e;
}
.mo-news__dialog {
  width: min(46rem, calc(100% - 2rem));
  margin: auto;
  max-height: min(40rem, calc(100svh - 2rem));
  padding: 0;
  border: 0;
  border-radius: 20px;
  background: #fbf7ef;
  color: #1d2b27;
  box-shadow: 0 30px 80px rgba(0, 0, 0, .35);
}
.mo-news__dialog::backdrop {
  background: rgb(12 20 18 / .7);
}
.mo-news__grid {
  display: grid;
}
.mo-news__img {
  display: block;
  width: 100%;
  height: 160px;
  object-fit: cover;
}
.mo-news__body {
  padding: 24px 22px 26px;
}
.mo-news__body h2 {
  margin: 0 0 8px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 26px;
  font-weight: 600;
  line-height: 1.15;
  color: #1d2b27;
}
.mo-news__lede {
  margin: 0 0 18px;
  font-size: 15px;
  line-height: 1.55;
  color: #4d5a55;
}
.mo-news__form {
  display: grid;
  gap: 8px;
}
.mo-news__form[hidden],
.mo-news__done[hidden] {
  display: none;
}
.mo-news__form label {
  font-size: 14px;
  font-weight: 600;
}
.mo-news__form input {
  min-height: 46px;
  padding: 0 14px;
  border: 1.5px solid #b9c2bc;
  border-radius: 12px;
  background: #fff;
  color: #1d2b27;
  font: inherit;
}
.mo-news__form input:focus-visible {
  border-color: #285e4f;
  outline: 3px solid rgba(40, 94, 79, .25);
  outline-offset: 0;
}
.mo-news__submit {
  margin-top: 6px;
}
.mo-news__x {
  position: absolute;
  top: 12px;
  right: 12px;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: #fff;
  color: #1d2b27;
  box-shadow: 0 2px 10px rgba(0, 0, 0, .15);
  cursor: pointer;
}
.mo-news button:focus-visible {
  outline: 3px solid #f0b429;
  outline-offset: 3px;
}
@media (min-width: 680px) {
  .mo-news__grid {
    grid-template-columns: 5fr 6fr;
  }
  .mo-news__img {
    height: 100%;
    min-height: 380px;
  }
  .mo-news__body {
    align-self: center;
    padding: 44px 36px;
  }
}

Photo lightbox with arrow keys

  • full-viewport dialog
  • object-fit: contain
  • ArrowLeft / ArrowRight
A deep blue fjord between steep granite cliffs, seen from a rocky clifftop
Clifftop view over the fjord1 of 4

Click the button. The dialog opens over the whole page, press Escape or use its buttons to close it.

The dialog itself is a transparent, full-viewport box, so any click that lands on it rather than on the photo or a button is a click on the empty space, and the script closes it. The arrow keys step through the set, and each thumbnail is a real button named by its image alt text.

View code
.mo-lb {
  width: min(100%, 30rem);
}
.mo-lb__grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 10px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.mo-lb__thumb {
  display: block;
  width: 100%;
  padding: 0;
  overflow: hidden;
  border: 0;
  border-radius: 12px;
  background: #d9dde6;
  cursor: zoom-in;
}
.mo-lb__thumb img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
.mo-lb__thumb:hover img {
  opacity: .88;
}
.mo-lb__thumb:focus-visible {
  outline: 3px solid #1d4ed8;
  outline-offset: 3px;
}
.mo-lb__dialog {
  width: 100%;
  height: 100%;
  max-width: none;
  max-height: none;
  margin: 0;
  padding: 64px 16px 24px;
  border: 0;
  background: transparent;
  color: #f2f2f2;
}
.mo-lb__dialog[open] {
  display: grid;
  place-items: center;
}
.mo-lb__dialog::backdrop {
  background: rgb(8 9 12 / .93);
}
.mo-lb__figure {
  margin: 0;
}
.mo-lb__full {
  display: block;
  width: auto;
  max-width: min(100%, 1000px);
  height: auto;
  max-height: calc(100svh - 160px);
  margin: 0 auto;
  border-radius: 6px;
  object-fit: contain;
}
.mo-lb__figure figcaption {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 4px 16px;
  margin-top: 12px;
  font-size: 15px;
}
.mo-lb__count {
  color: #b9bcc4;
  font-variant-numeric: tabular-nums;
}
.mo-lb__nav,
.mo-lb__close {
  position: fixed;
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;
  padding: 0;
  border: 1px solid rgba(255, 255, 255, .2);
  border-radius: 50%;
  background: rgba(20, 22, 28, .8);
  color: #fff;
  cursor: pointer;
}
.mo-lb__nav:hover,
.mo-lb__close:hover {
  background: #2b2e37;
}
.mo-lb__close {
  top: 12px;
  right: 12px;
}
.mo-lb__nav {
  top: 50%;
  translate: 0 -50%;
}
.mo-lb__prev {
  left: 12px;
}
.mo-lb__next {
  right: 12px;
}
.mo-lb__dialog button:focus-visible {
  outline: 3px solid #fff;
  outline-offset: 2px;
}
@media (max-width: 640px) {
  .mo-lb__nav {
    top: auto;
    bottom: 16px;
    translate: none;
  }
  .mo-lb__dialog {
    padding-bottom: 80px;
  }
}

Side drawer cart

  • margin-inline-start: auto
  • backdrop click
  • @starting-style
Larkspur Supply

Your bag

  • Linen work apron

    Sand, one size

    1
  • Waxed canvas tote

    Olive, 18 litres

    1

Subtotal$112

Shipping and taxes are added at checkout.

Click the button. The dialog opens over the whole page, press Escape or use its buttons to close it.

A modal drawer is a dialog with its auto margins changed: margin-left auto pins it to the right edge, and height 100% with max-height none fills the viewport. The inner panel covers the whole dialog box, so a click whose target is the dialog element can only come from the backdrop. The slide runs only when the visitor allows motion.

View code
.mo-cart {
  width: min(100%, 30rem);
}
.mo-cart__bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 14px 14px 14px 20px;
  border-radius: 14px;
  background: #fffdf8;
  box-shadow: 0 6px 20px rgba(34, 32, 28, .08);
}
.mo-cart__brand {
  font-family: "Fraunces", Georgia, serif;
  font-size: 20px;
  font-weight: 600;
  color: #22201c;
}
.mo-cart__open {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  padding: 0 16px;
  border: 1.5px solid #22201c;
  border-radius: 999px;
  background: transparent;
  color: #22201c;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.mo-cart__qty {
  display: grid;
  place-items: center;
  min-width: 22px;
  height: 22px;
  border-radius: 11px;
  background: #22201c;
  color: #fffdf8;
  font-size: 13px;
}
.mo-cart__dialog {
  width: min(24rem, 100%);
  max-width: 100%;
  height: 100%;
  max-height: none;
  margin: 0 0 0 auto;
  padding: 0;
  border: 0;
  border-radius: 18px 0 0 18px;
  background: #fffdf8;
  color: #22201c;
  box-shadow: -20px 0 60px rgba(0, 0, 0, .2);
}
.mo-cart__dialog::backdrop {
  background: rgb(34 32 28 / .45);
}
.mo-cart__panel {
  display: flex;
  flex-direction: column;
  height: 100%;
}
.mo-cart__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 16px 16px 24px;
  border-bottom: 1px solid #ece6da;
}
.mo-cart__head h2 {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 24px;
  font-weight: 600;
  color: #22201c;
}
.mo-cart__x {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: #f3eee4;
  color: #22201c;
  cursor: pointer;
}
.mo-cart__list {
  flex: 1;
  margin: 0;
  padding: 8px 24px;
  overflow-y: auto;
  list-style: none;
}
.mo-cart__item {
  display: grid;
  grid-template-columns: 64px 1fr;
  gap: 4px 14px;
  padding: 16px 0;
  border-bottom: 1px solid #ece6da;
}
.mo-cart__swatch {
  grid-row: span 2;
  width: 64px;
  height: 64px;
  border-radius: 10px;
}
.mo-cart__name {
  margin: 0;
  font-weight: 600;
}
.mo-cart__meta {
  margin: 2px 0 0;
  font-size: 14px;
  color: #6a6356;
}
.mo-cart__step {
  display: inline-flex;
  align-items: center;
  justify-self: start;
  border: 1px solid #d9d1c2;
  border-radius: 999px;
}
.mo-cart__step button {
  width: 36px;
  height: 36px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: #22201c;
  font: inherit;
  font-size: 18px;
  cursor: pointer;
}
.mo-cart__step output {
  min-width: 24px;
  text-align: center;
  font-variant-numeric: tabular-nums;
}
.mo-cart__foot {
  padding: 18px 24px 24px;
  border-top: 1px solid #ece6da;
}
.mo-cart__total {
  display: flex;
  justify-content: space-between;
  margin: 0 0 14px;
  font-size: 17px;
  font-weight: 700;
}
.mo-cart__checkout {
  width: 100%;
  min-height: 50px;
  border: 0;
  border-radius: 12px;
  background: #22201c;
  color: #fffdf8;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.mo-cart__checkout:hover {
  background: #3b372f;
}
.mo-cart__fine {
  margin: 10px 0 0;
  font-size: 13px;
  text-align: center;
  color: #6a6356;
}
.mo-cart button:focus-visible {
  outline: 3px solid #b4591f;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  .mo-cart__dialog {
    transition: translate .32s cubic-bezier(.2, .8, .2, 1);
  }
  .mo-cart__dialog::backdrop {
    transition: background-color .32s ease;
  }
  @starting-style {
    .mo-cart__dialog[open] {
      translate: 100% 0;
    }
    .mo-cart__dialog[open]::backdrop {
      background-color: rgb(34 32 28 / 0);
    }
  }
}

Filters that go full screen on phones

  • max-height: none
  • sticky footer
  • media query

48 stays in Porto

Filters

Price per night
Amenities
Guest rating

Click the button. The dialog opens over the whole page, press Escape or use its buttons to close it.

On wide screens this is a centered panel. Under 640px it resets the browser caps (max-width and max-height to none, margin to 0) and fills the viewport, with a scrolling middle and the apply button pinned at the bottom. The close button stays visible because a full-screen modal has no backdrop to click.

View code
.mo-filt {
  width: min(100%, 30rem);
}
.mo-filt__bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px 16px;
  padding: 14px 14px 14px 20px;
  border: 1px solid #e2e5ea;
  border-radius: 14px;
  background: #fff;
}
.mo-filt__summary {
  margin: 0;
  color: #374151;
}
.mo-filt__summary strong {
  color: #111827;
}
.mo-filt__open {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  padding: 0 16px;
  border: 1px solid #c9ced6;
  border-radius: 12px;
  background: #fff;
  color: #111827;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.mo-filt__open:hover {
  border-color: #111827;
}
.mo-filt__badge {
  display: grid;
  place-items: center;
  min-width: 22px;
  height: 22px;
  border-radius: 11px;
  background: #c2410c;
  color: #fff;
  font-size: 13px;
}
.mo-filt__badge[hidden] {
  display: none;
}
.mo-filt__dialog {
  width: min(34rem, calc(100% - 2rem));
  margin: auto;
  max-height: min(44rem, calc(100svh - 2rem));
  padding: 0;
  border: 0;
  border-radius: 18px;
  background: #fff;
  color: #111827;
  box-shadow: 0 24px 70px rgba(17, 24, 39, .3);
}
.mo-filt__dialog::backdrop {
  background: rgb(17 24 39 / .55);
}
.mo-filt__dialog[open] {
  display: flex;
}
.mo-filt__form {
  display: flex;
  flex-direction: column;
  width: 100%;
  min-height: 0;
}
.mo-filt__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 12px 10px 24px;
  border-bottom: 1px solid #e5e7eb;
}
.mo-filt__head h2 {
  font-family: inherit;
  letter-spacing: normal;
  margin: 0;
  font-size: 19px;
  color: #111827;
}
.mo-filt__x {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: #111827;
  cursor: pointer;
}
.mo-filt__x:hover {
  background: #f3f4f6;
}
.mo-filt__body {
  flex: 1;
  min-height: 0;
  padding: 8px 24px 16px;
  overflow-y: auto;
}
.mo-filt__body fieldset {
  margin: 0;
  padding: 18px 0;
  border: 0;
  border-bottom: 1px solid #f0f1f3;
}
.mo-filt__body legend {
  padding: 0;
  margin-bottom: 12px;
  font-weight: 700;
}
.mo-filt__chips {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.mo-filt__chips label {
  position: relative;
  display: inline-flex;
  align-items: center;
  min-height: 40px;
  padding: 0 14px;
  border: 1px solid #c9ced6;
  border-radius: 999px;
  font-size: 15px;
  cursor: pointer;
}
.mo-filt__chips input {
  position: absolute;
  inset: 0;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}
.mo-filt__chips label:has(:checked) {
  border-color: #111827;
  background: #111827;
  color: #fff;
}
.mo-filt__chips label:has(:focus-visible) {
  outline: 3px solid #c2410c;
  outline-offset: 2px;
}
.mo-filt__checks {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
  gap: 6px 16px;
}
.mo-filt__checks label {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 40px;
  font-size: 15px;
  cursor: pointer;
}
.mo-filt__checks input {
  width: 20px;
  height: 20px;
  margin: 0;
  accent-color: #c2410c;
}
.mo-filt__foot {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  padding: 14px 24px;
  border-top: 1px solid #e5e7eb;
  background: #fff;
}
.mo-filt__clear {
  min-height: 44px;
  padding: 0 4px;
  border: 0;
  background: transparent;
  color: #111827;
  font: inherit;
  font-weight: 600;
  text-decoration: underline;
  cursor: pointer;
}
.mo-filt__apply {
  min-height: 48px;
  padding: 0 22px;
  border: 0;
  border-radius: 12px;
  background: #c2410c;
  color: #fff;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.mo-filt__apply:hover {
  background: #9a3412;
}
.mo-filt button:focus-visible,
.mo-filt__checks input:focus-visible {
  outline: 3px solid #c2410c;
  outline-offset: 2px;
}
@media (max-width: 640px) {
  .mo-filt__dialog {
    width: 100%;
    height: 100%;
    max-width: none;
    max-height: none;
    margin: 0;
    border-radius: 0;
  }
}

Multi-step setup modal

  • hidden steps
  • focus on heading
  • progress segments

Set up your Kestrel workspace

Three quick steps, about a minute.

Step 1 of 3

Name your workspace

You can change this later in Settings.

Click the button. The dialog opens over the whole page, press Escape or use its buttons to close it.

One dialog, three steps. Only the current step is visible, and each step change moves focus to that step heading (it carries tabindex="-1") so screen readers announce where the user is. Keep it to three or four short steps; a longer flow deserves its own page.

View code
.mo-steps__card {
  width: min(100%, 22rem);
  padding: 26px 24px 20px;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 10px 30px rgba(28, 25, 23, .08);
  color: #1c1917;
}
.mo-steps__title {
  margin: 0;
  font-size: 19px;
  font-weight: 700;
}
.mo-steps__text {
  margin: 4px 0 18px;
  color: #57534e;
}
.mo-steps__open,
.mo-steps__next {
  min-height: 46px;
  padding: 0 22px;
  border: 0;
  border-radius: 12px;
  background: #3730a3;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.mo-steps__open:hover,
.mo-steps__next:hover {
  background: #312e81;
}
.mo-steps__status {
  min-height: 1.4em;
  margin: 12px 0 0;
  font-size: 14px;
  color: #44403c;
}
.mo-steps__dialog {
  width: min(30rem, calc(100% - 2rem));
  margin: auto;
  padding: 20px 24px 24px;
  border: 0;
  border-radius: 18px;
  background: #fff;
  color: #1c1917;
  box-shadow: 0 30px 80px rgba(28, 25, 23, .3);
}
.mo-steps__dialog::backdrop {
  background: rgb(30 27 75 / .5);
}
.mo-steps__top {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.mo-steps__count {
  margin: 0;
  font-size: 14px;
  font-weight: 600;
  color: #57534e;
}
.mo-steps__x {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  margin-right: -10px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: #1c1917;
  cursor: pointer;
}
.mo-steps__x:hover {
  background: #f5f5f4;
}
.mo-steps__bar {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 6px;
  margin: 6px 0 22px;
}
.mo-steps__bar i {
  height: 5px;
  border-radius: 3px;
  background: #e7e5e4;
}
.mo-steps__bar i.is-on {
  background: #3730a3;
}
.mo-steps__step {
  display: grid;
  gap: 8px;
  margin: 0;
  padding: 0;
}
.mo-steps__step[hidden] {
  display: none;
}
.mo-steps__step h2 {
  font-family: inherit;
  letter-spacing: normal;
  margin: 0 0 8px;
  font-size: 22px;
  line-height: 1.25;
  color: #1c1917;
}
.mo-steps__step h2:focus {
  outline: none;
}
.mo-steps__step h2:focus-visible {
  outline: 2px solid #3730a3;
  outline-offset: 4px;
}
.mo-steps__step > label {
  font-size: 14px;
  font-weight: 600;
}
.mo-steps__step input[type="text"],
.mo-steps__step textarea {
  width: 100%;
  min-height: 46px;
  padding: 10px 14px;
  border: 1.5px solid #d6d3d1;
  border-radius: 12px;
  background: #fff;
  color: #1c1917;
  font: inherit;
  box-sizing: border-box;
}
.mo-steps__step input[type="text"]:focus-visible,
.mo-steps__step textarea:focus-visible {
  border-color: #3730a3;
  outline: 3px solid rgba(55, 48, 163, .2);
}
.mo-steps__help {
  margin: 0;
  font-size: 14px;
  color: #57534e;
}
.mo-steps__options {
  display: grid;
  gap: 8px;
}
.mo-steps__options label {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding: 14px 16px;
  border: 1.5px solid #e7e5e4;
  border-radius: 12px;
  cursor: pointer;
}
.mo-steps__options label:has(:checked) {
  border-color: #3730a3;
  background: #eef2ff;
}
.mo-steps__options input {
  width: 18px;
  height: 18px;
  margin: 2px 0 0;
  accent-color: #3730a3;
}
.mo-steps__options span {
  display: grid;
  font-size: 14px;
  color: #57534e;
}
.mo-steps__options strong {
  font-size: 16px;
  color: #1c1917;
}
.mo-steps__nav {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
  margin-top: 26px;
}
.mo-steps__back {
  min-height: 46px;
  margin-right: auto;
  padding: 0 18px;
  border: 1.5px solid #d6d3d1;
  border-radius: 12px;
  background: #fff;
  color: #1c1917;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.mo-steps__back[hidden] {
  display: none;
}
.mo-steps button:focus-visible,
.mo-steps__options input:focus-visible {
  outline: 3px solid #3730a3;
  outline-offset: 2px;
}

Command palette

  • combobox + listbox
  • aria-activedescendant
  • top-aligned dialog

  • New documentCreate
  • Duplicate pageCreate
  • Open recent filesGo to
  • Invite a teammateTeam
  • Export as PDFFile
  • Switch to dark themeView
  • Billing and plansSettings

Click the button. The dialog opens over the whole page, press Escape or use its buttons to close it.

The dialog drops its auto top margin so it sits near the top of the screen, where the results can grow downward. Focus stays in the input while aria-activedescendant points at the highlighted option, so the arrow keys move the highlight and Enter runs it. Picking a command closes the dialog with the command name as returnValue.

View code
.mo-cmd {
  width: min(100%, 24rem);
  text-align: center;
}
.mo-cmd__open {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  min-height: 48px;
  padding: 0 16px;
  border: 1px solid #343944;
  border-radius: 12px;
  background: #17191f;
  color: #aab1bd;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.mo-cmd__open:hover {
  border-color: #59606e;
  color: #e6e8ee;
}
.mo-cmd__status {
  min-height: 1.4em;
  margin: 12px 0 0;
  font-size: 14px;
  color: #c9ced8;
}
.mo-cmd__dialog {
  width: min(36rem, calc(100% - 2rem));
  margin: min(14svh, 7rem) auto auto;
  padding: 0;
  border: 1px solid #2c303a;
  border-radius: 14px;
  background: #17191f;
  color: #e6e8ee;
  box-shadow: 0 30px 80px rgba(0, 0, 0, .55);
}
.mo-cmd__dialog::backdrop {
  background: rgb(5 6 9 / .6);
}
.mo-cmd__field {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 0 18px;
  border-bottom: 1px solid #2c303a;
  color: #9aa1ae;
}
.mo-cmd__label {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.mo-cmd__input {
  flex: 1;
  min-width: 0;
  min-height: 56px;
  padding: 0;
  border: 0;
  background: transparent;
  color: #f3f4f7;
  font: inherit;
  font-size: 17px;
  outline: none;
}
.mo-cmd__input::placeholder {
  color: #8a91a0;
}
.mo-cmd__field:focus-within {
  box-shadow: inset 0 -2px 0 #7aa2ff;
}
.mo-cmd__list {
  max-height: min(19rem, 50svh);
  margin: 0;
  padding: 8px;
  overflow-y: auto;
  list-style: none;
}
.mo-cmd__list li {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  min-height: 44px;
  padding: 0 12px;
  border-radius: 8px;
  cursor: pointer;
}
.mo-cmd__list li[hidden] {
  display: none;
}
.mo-cmd__list li span {
  font-size: 13px;
  color: #9aa1ae;
}
.mo-cmd__list li:hover {
  background: #20232b;
}
.mo-cmd__list li[aria-selected="true"] {
  background: #262b38;
  box-shadow: inset 3px 0 0 #7aa2ff;
}
.mo-cmd__empty {
  margin: 0;
  padding: 18px;
  color: #aab1bd;
}
.mo-cmd__keys {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
  margin: 0;
  padding: 10px 18px;
  border-top: 1px solid #2c303a;
  font-size: 13px;
  color: #9aa1ae;
}
.mo-cmd__keys kbd {
  padding: 1px 6px;
  border: 1px solid #3a3f4b;
  border-radius: 5px;
  font: 12px ui-monospace, Consolas, monospace;
  color: #d3d7df;
}
.mo-cmd__open:focus-visible {
  outline: 3px solid #7aa2ff;
  outline-offset: 2px;
}

Animation, light dismiss and scroll lock

The finishing layer: entry and exit transitions with @starting-style, a keyframe bottom sheet, outside-click closing with closedby, and a page that stops scrolling while the dialog is open. Every movement here is opt-in through prefers-reduced-motion.

Fade and scale in, fade out

  • @starting-style
  • allow-discrete
  • overlay

Spring menu launch

Draft, 3 unpublished changes

Publish this page?

Spring menu launch goes live on the public site right away.

Click the button. The dialog opens over the whole page, press Escape or use its buttons to close it.

The entry animation comes from @starting-style, which gives the opening dialog a starting opacity and scale to transition from. The exit needs display and overlay in the transition list with allow-discrete, so the dialog stays visible in the top layer until the fade ends. That part sits inside @supports (overlay: auto), so browsers without overlay (Firefox and Safari today) get the animated entry and an instant close instead of a glitchy one.

View code
.mo-fade {
  width: min(100%, 26rem);
}
.mo-fade__bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 16px 16px 16px 20px;
  border-radius: 16px;
  background: #0b1d2c;
  color: #f4f1ea;
}
.mo-fade__name {
  margin: 0;
  font-weight: 700;
}
.mo-fade__meta {
  margin: 2px 0 0;
  font-size: 14px;
  color: #b9c4cf;
}
.mo-fade__open {
  min-height: 44px;
  padding: 0 20px;
  border: 0;
  border-radius: 10px;
  background: #f2b134;
  color: #0b1d2c;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.mo-fade__open:hover {
  background: #f6c661;
}
.mo-fade__status {
  min-height: 1.4em;
  margin: 12px 4px 0;
  font-size: 14px;
  color: #33414d;
}
.mo-fade__dialog {
  width: min(25rem, calc(100% - 2rem));
  margin: auto;
  padding: 28px 24px 24px;
  border: 0;
  border-radius: 20px;
  background: #fff;
  color: #0b1d2c;
  box-shadow: 0 30px 80px rgba(11, 29, 44, .35);
}
.mo-fade__dialog::backdrop {
  background-color: rgb(11 29 44 / .6);
}
.mo-fade__dialog h2 {
  margin: 0 0 8px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 26px;
  font-weight: 600;
  color: #0b1d2c;
}
.mo-fade__dialog p {
  margin: 0 0 16px;
  line-height: 1.55;
  color: #3e4c58;
}
.mo-fade__check {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 44px;
  padding: 0 14px;
  border-radius: 12px;
  background: #f4f1ea;
  font-size: 15px;
  cursor: pointer;
}
.mo-fade__check input {
  width: 20px;
  height: 20px;
  margin: 0;
  accent-color: #0b1d2c;
}
.mo-fade__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 22px;
}
.mo-fade__actions button {
  flex: 1 1 8rem;
  min-height: 46px;
  border: 1.5px solid #0b1d2c;
  border-radius: 12px;
  background: #fff;
  color: #0b1d2c;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.mo-fade__actions .mo-fade__go {
  background: #0b1d2c;
  color: #fff;
}
.mo-fade button:focus-visible,
.mo-fade__check input:focus-visible {
  outline: 3px solid #d18a00;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  .mo-fade__dialog {
    opacity: 0;
    scale: .94;
    transition: opacity .2s ease-out, scale .2s ease-out;
  }
  .mo-fade__dialog[open] {
    opacity: 1;
    scale: 1;
  }
  .mo-fade__dialog::backdrop {
    background-color: rgb(11 29 44 / 0);
    transition: background-color .2s ease-out;
  }
  .mo-fade__dialog[open]::backdrop {
    background-color: rgb(11 29 44 / .6);
  }
  @starting-style {
    .mo-fade__dialog[open] {
      opacity: 0;
      scale: .94;
    }
    .mo-fade__dialog[open]::backdrop {
      background-color: rgb(11 29 44 / 0);
    }
  }
  @supports (overlay: auto) {
    .mo-fade__dialog {
      transition: opacity .2s ease-out, scale .2s ease-out, overlay .2s ease-out allow-discrete, display .2s ease-out allow-discrete;
    }
    .mo-fade__dialog::backdrop {
      transition: background-color .2s ease-out, overlay .2s ease-out allow-discrete, display .2s ease-out allow-discrete;
    }
  }
}

Bottom share sheet with keyframes

  • @keyframes
  • margin: auto auto 0
  • ::backdrop animation
A narrow alley between red brick walls, wet pavement shining under a pale sky
Back alley after rainPhoto set, 24 frames

Share this set

Click the button. The dialog opens over the whole page, press Escape or use its buttons to close it.

Setting the top margin to auto and the bottom one to 0 drops the dialog to the bottom edge. The slide-up is a plain keyframe animation on dialog[open], which needs neither @starting-style nor allow-discrete. This sheet only animates in, so it closes instantly. MDN shows the exit can be keyframed too by putting display values in the keyframes, but the backdrop cannot fade out, because it is removed the moment the dialog closes.

View code
.mo-sheet {
  width: min(100%, 22rem);
}
.mo-sheet__card {
  margin: 0;
  overflow: hidden;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 12px 30px rgba(17, 17, 17, .1);
}
.mo-sheet__card img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 10;
  object-fit: cover;
}
.mo-sheet__card figcaption {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 14px 16px;
}
.mo-sheet__card figcaption span {
  display: grid;
  font-size: 14px;
  color: #5c5f66;
}
.mo-sheet__card strong {
  font-size: 16px;
  color: #141518;
}
.mo-sheet__open {
  min-height: 44px;
  padding: 0 18px;
  border: 0;
  border-radius: 999px;
  background: #141518;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.mo-sheet__status {
  min-height: 1.4em;
  margin: 12px 4px 0;
  font-size: 14px;
  color: #33363d;
}
.mo-sheet__dialog {
  width: min(32rem, 100%);
  max-width: 100%;
  margin: auto auto 0;
  padding: 10px 20px 22px;
  border: 0;
  border-radius: 22px 22px 0 0;
  background: #fff;
  color: #141518;
}
.mo-sheet__dialog::backdrop {
  background: rgb(10 10 12 / .5);
}
.mo-sheet__handle {
  display: block;
  width: 40px;
  height: 5px;
  margin: 0 auto 14px;
  border-radius: 3px;
  background: #d3d5da;
}
.mo-sheet__dialog h2 {
  font-family: inherit;
  letter-spacing: normal;
  margin: 0 0 16px;
  font-size: 18px;
  text-align: center;
  color: #141518;
}
.mo-sheet__targets {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 6px;
}
.mo-sheet__targets button {
  display: grid;
  justify-items: center;
  gap: 8px;
  min-height: 44px;
  padding: 6px 0;
  border: 0;
  border-radius: 12px;
  background: transparent;
  color: #141518;
  font: inherit;
  font-size: 13px;
  cursor: pointer;
}
.mo-sheet__targets span {
  display: grid;
  place-items: center;
  width: 54px;
  height: 54px;
  border-radius: 50%;
  background: #eef0f3;
}
.mo-sheet__targets button:hover span {
  background: #e1e4e9;
}
.mo-sheet__cancel {
  width: 100%;
  min-height: 48px;
  margin-top: 16px;
  border: 0;
  border-radius: 14px;
  background: #eef0f3;
  color: #141518;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.mo-sheet button:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 2px;
}
@keyframes mo-sheet-up {
  from {
    translate: 0 100%;
  }
}
@keyframes mo-sheet-fade {
  from {
    opacity: 0;
  }
}
@media (prefers-reduced-motion: no-preference) {
  .mo-sheet__dialog[open] {
    animation: mo-sheet-up .34s cubic-bezier(.2, .8, .2, 1);
  }
  .mo-sheet__dialog[open]::backdrop {
    animation: mo-sheet-fade .34s ease-out;
  }
}

Quick view with closedby="any"

  • closedby
  • feature detection
  • click-outside fallback
Granite cliffs rising above pine forest and a sandy riverbank

Valley Morning print

From $36

Valley Morning

Giclee print on 310 gsm cotton rag, signed on the back. Ships rolled in a tube.

Size

Click outside or press Esc to close. Light dismiss: native closedby.

Click the button. The dialog opens over the whole page, press Escape or use its buttons to close it.

closedby="any" lets a click outside the dialog close it, with no script, in browsers that support the attribute. The script checks for the closedBy property first and only adds a click listener where it is missing, comparing the click position with the dialog rectangle. The footer line tells you which path your browser took.

View code
.mo-qv__card {
  display: grid;
  grid-template-columns: 120px 1fr;
  width: min(100%, 24rem);
  overflow: hidden;
  border-radius: 16px;
  background: #fff;
  box-shadow: 0 10px 28px rgba(31, 41, 31, .1);
}
.mo-qv__card img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.mo-qv__info {
  padding: 16px;
}
.mo-qv__name {
  margin: 0;
  font-weight: 700;
  color: #1f291f;
}
.mo-qv__price {
  margin: 2px 0 12px;
  color: #4f5b4f;
}
.mo-qv__open {
  min-height: 44px;
  padding: 0 16px;
  border: 1.5px solid #2f4a2f;
  border-radius: 10px;
  background: #fff;
  color: #2f4a2f;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.mo-qv__open:hover {
  background: #eef3ec;
}
.mo-qv__dialog {
  width: min(48rem, calc(100% - 2rem));
  margin: auto;
  max-height: min(44rem, calc(100svh - 2rem));
  padding: 0;
  border: 0;
  border-radius: 18px;
  background: #fff;
  color: #1f291f;
  box-shadow: 0 30px 80px rgba(0, 0, 0, .3);
}
.mo-qv__dialog::backdrop {
  background: rgb(23 31 23 / .55);
}
.mo-qv__grid {
  display: grid;
}
.mo-qv__grid > img {
  display: block;
  width: 100%;
  height: 180px;
  object-fit: cover;
}
.mo-qv__body {
  padding: 22px 24px 8px;
}
.mo-qv__body h2 {
  margin: 0 0 6px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 28px;
  font-weight: 600;
  color: #1f291f;
}
.mo-qv__desc {
  margin: 0 0 14px;
  line-height: 1.55;
  color: #4f5b4f;
}
.mo-qv__body fieldset {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 0 0 16px;
  padding: 0;
  border: 0;
}
.mo-qv__body legend {
  margin-bottom: 8px;
  padding: 0;
  font-weight: 700;
}
.mo-qv__body fieldset label {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 40px;
  padding: 0 12px;
  border: 1.5px solid #d5dbd3;
  border-radius: 10px;
  font-size: 15px;
  cursor: pointer;
}
.mo-qv__body fieldset label:has(:checked) {
  border-color: #2f4a2f;
  background: #eef3ec;
}
.mo-qv__body fieldset input {
  margin: 0;
  accent-color: #2f4a2f;
}
.mo-qv__add {
  width: 100%;
  min-height: 48px;
  border: 0;
  border-radius: 12px;
  background: #2f4a2f;
  color: #fff;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.mo-qv__add:hover {
  background: #233823;
}
.mo-qv__added {
  min-height: 1.4em;
  margin: 8px 0 0;
  font-size: 14px;
  color: #2f4a2f;
}
.mo-qv__mode {
  margin: 0;
  padding: 12px 24px 16px;
  font-size: 13px;
  color: #5a665a;
}
.mo-qv__x {
  position: absolute;
  top: 10px;
  right: 10px;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: #fff;
  color: #1f291f;
  box-shadow: 0 2px 10px rgba(0, 0, 0, .15);
  cursor: pointer;
}
.mo-qv button:focus-visible,
.mo-qv__body fieldset label:has(:focus-visible) {
  outline: 3px solid #b86e00;
  outline-offset: 2px;
}
@media (min-width: 700px) {
  .mo-qv__grid {
    grid-template-columns: 1fr 1fr;
  }
  .mo-qv__grid > img {
    height: 100%;
  }
  .mo-qv__body {
    padding: 32px 28px 8px;
  }
}

Scroll-locked terms dialog

  • html:has(dialog[open])
  • scrollbar-gutter
  • overscroll-behavior

Our terms change on 1 December. Two sections affect how invoices work.

What is changing

Invoices move to the 1st

Every workspace will be billed on the first day of the month. Your next invoice covers the days between your current billing date and 1 December.

Annual plans can be paused

You can pause an annual plan once a year for up to 60 days. The paused days are added to the end of your term.

Storage counts version history

File versions older than 90 days now count toward your storage. Workspaces under 80 percent of their limit will not notice a difference.

Data export

Owners can export every file and comment as a ZIP archive from Settings at any time, including after cancelling, for 30 days.

Contact

Questions about these terms go to the account team from the Help menu. Nothing changes for plans bought through a reseller.

Click the button. The dialog opens over the whole page, press Escape or use its buttons to close it.

While this dialog is open, html:has(.mo-lock__dialog[open]) sets overflow: hidden on the page, so the article behind it stops scrolling, and scrollbar-gutter: stable keeps the space the scrollbar used so nothing jumps sideways. The selector is scoped to this demo class; on your own site, use html:has(dialog:modal). overscroll-behavior: contain on the inner scroller keeps scroll chaining out of the page.

View code
html:has(.mo-lock__dialog[open]) {
  overflow: hidden;
  scrollbar-gutter: stable;
}
.mo-lock {
  width: min(100%, 26rem);
}
.mo-lock__notice {
  padding: 18px 20px;
  border-left: 4px solid #0f6e6e;
  border-radius: 6px 14px 14px 6px;
  background: #fff;
  color: #1b2b2b;
}
.mo-lock__notice p {
  margin: 0 0 14px;
  line-height: 1.5;
}
.mo-lock__open {
  min-height: 44px;
  padding: 0 18px;
  border: 0;
  border-radius: 10px;
  background: #0f6e6e;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.mo-lock__open:hover {
  background: #0a5555;
}
.mo-lock__status {
  min-height: 1.4em;
  margin: 12px 4px 0;
  font-size: 14px;
  color: #2c3b3b;
}
.mo-lock__dialog {
  width: min(32rem, calc(100% - 2rem));
  margin: auto;
  max-height: min(36rem, calc(100svh - 2rem));
  padding: 0;
  border: 0;
  border-radius: 18px;
  background: #fff;
  color: #1b2b2b;
  box-shadow: 0 30px 80px rgba(0, 0, 0, .3);
}
.mo-lock__dialog::backdrop {
  background: rgb(10 30 30 / .6);
}
.mo-lock__dialog[open] {
  display: flex;
}
.mo-lock__dialog form {
  display: flex;
  flex-direction: column;
  width: 100%;
  min-height: 0;
}
.mo-lock__dialog h2 {
  font-family: inherit;
  letter-spacing: normal;
  margin: 0;
  padding: 22px 24px 14px;
  border-bottom: 1px solid #e3eaea;
  font-size: 21px;
  color: #1b2b2b;
}
.mo-lock__scroll {
  flex: 1;
  min-height: 0;
  padding: 4px 24px 16px;
  overflow-y: auto;
  overscroll-behavior: contain;
}
.mo-lock__scroll:focus-visible {
  outline: 3px solid #0f6e6e;
  outline-offset: -3px;
}
.mo-lock__scroll h3 {
  font-family: inherit;
  letter-spacing: normal;
  margin: 18px 0 4px;
  font-size: 16px;
  color: #1b2b2b;
}
.mo-lock__scroll p {
  margin: 0;
  line-height: 1.6;
  color: #3f5050;
}
.mo-lock__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  padding: 16px 24px 20px;
  border-top: 1px solid #e3eaea;
}
.mo-lock__actions button {
  flex: 1 1 9rem;
  min-height: 46px;
  padding: 0 12px;
  border: 1.5px solid #b9caca;
  border-radius: 12px;
  background: #fff;
  color: #1b2b2b;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.mo-lock__actions .mo-lock__ok {
  border-color: #0f6e6e;
  background: #0f6e6e;
  color: #fff;
}
.mo-lock button:focus-visible {
  outline: 3px solid #0f6e6e;
  outline-offset: 2px;
}

Popover, commandfor and CSS-only overlays

The alternatives the guide compares: a declarative dialog that needs no script, a popover, a non-modal dialog, and the two CSS-only overlays (:target and the checkbox hack) with their limits stated plainly.

Declarative dialog with commandfor

  • commandfor
  • command="show-modal"
  • script fallback
index.html

Keyboard shortcuts

Save file
CtrlS
Find in file
CtrlF
Go to line
CtrlG
Toggle sidebar
CtrlB
Comment line
Ctrl/

Opened by the browser through commandfor, no script involved.

Click the button. It opens a real modal through HTML attributes alone, Escape closes it.

The buttons carry commandfor with the dialog id and command="show-modal" or command="close", so the browser opens and closes a true modal before any script runs. The small script only runs where HTMLButtonElement has no commandForElement property and wires the same buttons up with showModal() and close().

View code
.mo-cf {
  width: min(100%, 24rem);
}
.mo-cf__bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 10px 10px 10px 16px;
  border: 1px solid #2d2f36;
  border-radius: 12px;
  background: #1b1c21;
}
.mo-cf__file {
  font: 14px ui-monospace, Consolas, monospace;
  color: #c7cad1;
}
.mo-cf__open {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  padding: 0 14px;
  border: 1px solid #3a3d46;
  border-radius: 10px;
  background: #24262d;
  color: #eceef2;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.mo-cf__open:hover {
  background: #2e3038;
}
.mo-cf kbd {
  display: inline-grid;
  place-items: center;
  min-width: 24px;
  height: 24px;
  padding: 0 6px;
  border: 1px solid #4a4d57;
  border-bottom-width: 2px;
  border-radius: 6px;
  background: #2a2c33;
  color: #eceef2;
  font: 13px ui-monospace, Consolas, monospace;
  box-sizing: border-box;
}
.mo-cf__dialog {
  width: min(26rem, calc(100% - 2rem));
  margin: auto;
  padding: 0;
  border: 1px solid #33353d;
  border-radius: 16px;
  background: #1b1c21;
  color: #eceef2;
  box-shadow: 0 30px 80px rgba(0, 0, 0, .55);
}
.mo-cf__dialog::backdrop {
  background: rgb(0 0 0 / .65);
}
.mo-cf__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 10px 10px 22px;
  border-bottom: 1px solid #2d2f36;
}
.mo-cf__head h2 {
  font-family: inherit;
  letter-spacing: normal;
  margin: 0;
  font-size: 18px;
  color: #eceef2;
}
.mo-cf__x {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 10px;
  background: transparent;
  color: #eceef2;
  cursor: pointer;
}
.mo-cf__x:hover {
  background: #2a2c33;
}
.mo-cf__list {
  margin: 0;
  padding: 8px 22px;
}
.mo-cf__list div {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 10px 0;
  border-bottom: 1px solid #26282e;
}
.mo-cf__list div:last-child {
  border-bottom: 0;
}
.mo-cf__list dt {
  color: #d5d8de;
}
.mo-cf__list dd {
  display: flex;
  gap: 4px;
  margin: 0;
}
.mo-cf__mode {
  margin: 0;
  padding: 12px 22px 16px;
  border-top: 1px solid #2d2f36;
  font-size: 13px;
  color: #a7abb5;
}
.mo-cf button:focus-visible {
  outline: 3px solid #8ab4ff;
  outline-offset: 2px;
}

Notifications popover

  • popover
  • popovertarget
  • anchor positioning
Quayside

Notifications

  • Rhea Tan commented on Dock 4 schedule4 min ago

  • Jonas Obi shared Q4 freight rates with you1 hour ago

  • Mila Kos approved your timesheetYesterday

Click the bell. Click anywhere else or press Escape to close the panel.

A popover also renders in the top layer, but it is not modal: the page stays clickable, nothing goes inert, and an outside click or Escape closes it with no script at all. Where CSS anchor positioning is supported the panel hangs under the bell; elsewhere it falls back to the default centered popover.

View code
.mo-pop {
  width: min(100%, 28rem);
}
.mo-pop__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 10px 10px 20px;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 6px 20px rgba(15, 30, 45, .08);
}
.mo-pop__brand {
  font-size: 19px;
  font-weight: 800;
  color: #0f1e2d;
}
.mo-pop__bell {
  position: relative;
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;
  padding: 0;
  border: 0;
  border-radius: 12px;
  background: #eff3f7;
  color: #0f1e2d;
  cursor: pointer;
  anchor-name: --mo-pop-bell;
}
.mo-pop__bell:hover {
  background: #e2e8ef;
}
.mo-pop__badge {
  position: absolute;
  top: 6px;
  right: 6px;
  display: grid;
  place-items: center;
  min-width: 18px;
  height: 18px;
  border: 2px solid #fff;
  border-radius: 10px;
  background: #c8102e;
  color: #fff;
  font-size: 11px;
  font-weight: 700;
}
.mo-pop__panel {
  width: min(22rem, calc(100vw - 2rem));
  margin: auto;
  padding: 0;
  border: 1px solid #e1e6ec;
  border-radius: 16px;
  background: #fff;
  color: #0f1e2d;
  box-shadow: 0 20px 50px rgba(15, 30, 45, .2);
}
.mo-pop__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 8px 8px 18px;
  border-bottom: 1px solid #eef1f4;
}
.mo-pop__head h2 {
  font-family: inherit;
  letter-spacing: normal;
  margin: 0;
  font-size: 16px;
  color: #0f1e2d;
}
.mo-pop__close {
  min-height: 40px;
  padding: 0 12px;
  border: 0;
  border-radius: 8px;
  background: transparent;
  color: #1e5a8a;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.mo-pop__close:hover {
  background: #eff3f7;
}
.mo-pop__list {
  margin: 0;
  padding: 6px;
  list-style: none;
}
.mo-pop__list li {
  display: flex;
  gap: 12px;
  padding: 10px 12px;
  border-radius: 10px;
}
.mo-pop__list li:hover {
  background: #f5f7fa;
}
.mo-pop__avatar {
  display: grid;
  flex: none;
  place-items: center;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  color: #fff;
  font-size: 13px;
  font-weight: 700;
}
.mo-pop__list p {
  margin: 0;
  font-size: 14px;
  line-height: 1.45;
  color: #3a4a5a;
}
.mo-pop__list strong {
  color: #0f1e2d;
}
.mo-pop__list small {
  display: block;
  font-size: 13px;
  color: #5d6b79;
}
.mo-pop button:focus-visible {
  outline: 3px solid #1e5a8a;
  outline-offset: 2px;
}
@supports (position-area: bottom) {
  .mo-pop__panel {
    inset: auto;
    margin: 8px 0 0;
    position-anchor: --mo-pop-bell;
    position-area: bottom span-left;
    position-try-fallbacks: flip-block;
  }
}

:target overlay

  • :target
  • fragment links
  • no JavaScript

Atlas 4.2 is out

Offline maps, faster sync and a new export menu.

Read the release notes

Release notes, 4.2

  • Download regions for offline use.
  • Sync is up to twice as fast on large projects.
  • Export to GPX and GeoJSON from one menu.
Close

Follow the link to open it. The close link and the dimmed area both point back at the trigger.

The overlay shows when its id matches the URL fragment. The close link points at the trigger id instead of #, so the page does not jump to the top and the Back button closes it. What it cannot do: close on Escape, move focus inside, keep focus out of the page behind or make anything inert. Here it is positioned inside the demo; on a real page it would be position: fixed.

View code
.mo-tgt {
  position: relative;
  display: grid;
  place-items: center;
  width: 100%;
  min-height: 360px;
}
.mo-tgt__card {
  width: min(100%, 22rem);
  padding: 24px;
  border: 1px solid #dfe3ea;
  border-radius: 16px;
  background: #fff;
  color: #14213d;
}
.mo-tgt__title {
  margin: 0;
  font-size: 19px;
  font-weight: 700;
}
.mo-tgt__text {
  margin: 4px 0 16px;
  color: #4a5670;
}
.mo-tgt__open,
.mo-tgt__close {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 0 18px;
  border-radius: 10px;
  background: #14213d;
  color: #fff;
  font-weight: 600;
  text-decoration: none;
}
.mo-tgt__open:hover,
.mo-tgt__close:hover {
  background: #263a66;
}
.mo-tgt__open:focus-visible,
.mo-tgt__close:focus-visible {
  outline: 3px solid #fca311;
  outline-offset: 2px;
}
.mo-tgt__overlay {
  position: absolute;
  inset: -40px -24px;
  z-index: 2;
  display: none;
  place-items: center;
  padding: 16px;
}
.mo-tgt__overlay:target {
  display: grid;
}
.mo-tgt__scrim {
  position: absolute;
  inset: 0;
  background: rgb(20 33 61 / .6);
}
.mo-tgt__box {
  position: relative;
  width: min(100%, 24rem);
  padding: 24px;
  border-radius: 16px;
  background: #fff;
  color: #14213d;
  box-shadow: 0 24px 60px rgba(0, 0, 0, .3);
}
.mo-tgt__box h2 {
  font-family: inherit;
  letter-spacing: normal;
  margin: 0 0 10px;
  font-size: 20px;
  color: #14213d;
}
.mo-tgt__box ul {
  margin: 0 0 18px;
  padding-left: 20px;
  line-height: 1.6;
  color: #3b4763;
}

Checkbox hack overlay

  • :checked
  • label for
  • sibling selector

Starter or Studio?

FeatureStarterStudio
Price a month$9$24
Projects3Unlimited
Seats15
Custom domainNoYes

Click Compare plans, then Close or the dimmed area.

A visually hidden checkbox holds the open state, labels toggle it, and the general sibling selector shows the overlay. The checkbox stays focusable, so keyboard users can open and close it with Space, but there is no Escape, no focus move and no inert page, and screen readers hear a checkbox, not a dialog. Fine for a CodePen, not for production.

View code
.mo-chk {
  position: relative;
  display: grid;
  place-items: center;
  width: 100%;
  min-height: 340px;
}
.mo-chk__toggle {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.mo-chk__open,
.mo-chk__close {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 0 20px;
  border-radius: 999px;
  background: #e9ff70;
  color: #121212;
  font-weight: 700;
  cursor: pointer;
}
.mo-chk__open:hover,
.mo-chk__close:hover {
  background: #f2ffa8;
}
.mo-chk__toggle:focus-visible + .mo-chk__open {
  outline: 3px solid #fff;
  outline-offset: 3px;
}
.mo-chk__overlay {
  position: absolute;
  inset: -40px -24px;
  z-index: 2;
  display: none;
  place-items: center;
  padding: 16px;
}
.mo-chk__toggle:checked ~ .mo-chk__overlay {
  display: grid;
}
.mo-chk__scrim {
  position: absolute;
  inset: 0;
  background: rgb(0 0 0 / .7);
  cursor: pointer;
}
.mo-chk__box {
  position: relative;
  width: min(100%, 22rem);
  padding: 22px 20px 20px;
  border: 1px solid #333;
  border-radius: 16px;
  background: #1c1c1c;
  color: #f2f2f2;
}
.mo-chk__title {
  margin: 0 0 12px;
  font-size: 19px;
  font-weight: 700;
}
.mo-chk__table {
  width: 100%;
  margin-bottom: 18px;
  border-collapse: collapse;
  font-size: 14px;
}
.mo-chk__table th,
.mo-chk__table td {
  padding: 8px 6px;
  border-bottom: 1px solid #333;
  text-align: left;
}
.mo-chk__table thead th {
  color: #e9ff70;
  font-weight: 600;
}
.mo-chk__table tbody th {
  font-weight: 400;
  color: #c9c9c9;
}

What Is a CSS Modal, and What Is It Not?

Plenty of overlays get called modals without earning the name. In CSS terms, a modal is built from HTML and CSS (the dialog element, the ::backdrop pseudo-element, fixed positioning) and shows focused content above the page, blocking everything underneath until the user dismisses it.

CSS covers the look and the layering. Opening and closing takes a few lines of JavaScript or a declarative HTML attribute, unless you settle for a plain overlay toggled through :target or :checked.

Designers know the pattern as a modal in UI design, and the CSS version only changes how it gets built. What makes a modal real is enforcement. The browser makes the rest of the page inert instead of just covering it.

Modal dialog vs non-modal dialog

A dialog opened with showModal() is the modal kind. The rest of the page becomes inert, Esc closes it by default, and assistive technology sees it as aria-modal=”true”.

Open one with show() or the open attribute and you get the non-modal kind. The page stays interactive, Esc does nothing by default, and the dialog is exposed as aria-modal=”false”.

Go non-modal only when users have to keep working behind the box, like with an inspector panel.

Modal vs popover and lightbox

A popover is the non-modal cousin. It also lands in the top layer, but an auto popover closes on an outside click or Esc and never blocks the page (MDN Web Docs).

Lightboxes are modals built around media. Hans Christian Reinl’s CSS Modal library, for example, adds gallery and HTML5 video plugins, and its modals stack when one opens from another.

The decision rule is short. If the user must respond before moving on, use a modal. If the content is closer to a hint or a menu (a tooltip, say), use a popover.

How the Dialog Element Works as a Modal

Call showModal() on a dialog and it lands in the top layer, gets a ::backdrop behind it, and turns every other element in the document inert (MDN Web Docs).

That one call replaces most of what div-based tutorials build by hand. Focus moves to the first focusable element inside the dialog, or to whatever carries the autofocus attribute. Escape closes it, and when several modals are open it only closes the last one shown. The browser exposes it with an implicit aria-modal=”true”, much like a custom dialog role, and the background goes inert, so clicks and tab stops skip it.

A div with role=”dialog” has to rebuild every one of those, and MDN says as much. Hyring’s modal tutorial, for instance, adds a document-level keydown listener for Escape, sets overflow: hidden on the body, and writes the ARIA attributes (role, aria-modal, aria-labelledby) by hand.

Scroll locking isn’t on MDN’s list of built-in behaviors, so it gets its own section further down.

One trap. Never put tabindex on the dialog itself. MDN warns against it because the dialog isn’t interactive and doesn’t take focus.

Top layer vs position fixed and z-index

Top layer elements stack last in, first out above all page content, and each one gets its own ::backdrop (MDN Web Docs). Nothing in the DOM tree can trap them.

A fixed overlay with a z-index is a different story, because it stays inside its ancestors’ stacking contexts. A parent with a transform becomes the containing block for a fixed child, so the modal can end up sitting below a sticky header however high the z-index climbs.

Native dialog ends the z-index arms race.

Which CSS Modal Method Should You Use?

Use the native dialog element with showModal(). It’s the only option here where the browser supplies the inert background, Escape handling and focus placement.

Several tutorials that rank for this topic teach the checkbox hack or :target, which is exactly why those techniques keep getting copied into production. Treat them as demo techniques.

If you want zero JavaScript to open and close, a dialog with commandfor does it, plus a showModal() click listener for older browsers. A :target overlay only makes sense on a static page where nobody needs Escape. For everything else, showModal().

Capability comparison

MethodOpens without JavaScriptEscape closesBackground inert
Dialog with showModal()NoYes, built inYes, built in
Dialog with commandforYesYes, built inYes, built in
:target overlayYesManual, needs JavaScriptManual
Checkbox hackYesManual, needs JavaScriptManual
Div overlay with JS toggleNoManual, needs JavaScriptManual

The native rows come from MDN’s dialog reference. The CSS-only rows follow the limits reported by The Admin Bar (no Escape, no backdrop click), and neither CSS-only method moves focus.

Pros and cons of each method

The native dialog with showModal() hands you the inert background, Escape and focus placement for free. What it costs is a JavaScript call to open it.

Adding commandfor removes even that. The dialog opens and closes without a script, even before JavaScript loads. It’s only newly available in Baseline terms, though, so older browsers need the showModal() fallback.

The :target overlay is pure CSS and a link. There’s no Escape key, no focus handling, and the URL fragment changes.

The checkbox hack is pure CSS too and leaves the URL alone, but it has the same missing Escape and focus handling, and the state lives in a form control.

A div overlay with a JS toggle works in any browser and gives you full control. Inert, Escape, focus and ARIA are all yours to build.

Browser support by method

Support dates decide how much fallback code each method needs.

  • The dialog element has been supported across browsers since March 2022 and is Baseline widely available (MDN Web Docs).
  • Invoker Commands (commandfor) became Baseline newly available in December 2025 (MDN Web Docs).
  • closedby shipped in Chrome 134 (March 2025) and Firefox 141 (July 2025). Safari doesn’t have it, and Baseline marks it limited availability (WebDX web-features data).
  • :target is Baseline widely available and has been supported across browsers since July 2015 (MDN Web Docs).

MDN’s closedBy reference carries the same limited availability label. Skeleton’s dialog docs list Firefox 136 for closedby, while the web-features dataset says 141, and the dataset is the primary source.

Until Safari ships closedby, a build that relies on it needs a click-listener fallback, which makes cross-browser compatibility checks part of the job.

How to Build a CSS Modal with the Dialog Element

Start with the markup, then wire up opening and closing, then style it.

  1. Write a dialog with a heading, the content and a close button, and point aria-labelledby at the heading.
  2. Put autofocus on the close button when nothing else needs immediate input, as MDN recommends.
  3. Add a trigger button that calls showModal() on click.
  4. Close it with close(), or put the actions inside a form with method=”dialog” so the browser closes the dialog and sets returnValue.
  5. Style the box with width and max-height, then style the ::backdrop.
<button id="open-modal">Open modal</button>

<dialog id="modal" aria-labelledby="modal-title">
  <h2 id="modal-title">Subscribe</h2>
  <p>Short content goes here.</p>
  <button id="close-modal" autofocus>Close</button>
</dialog>

<script>
  const modal = document.querySelector("#modal");
    document.querySelector("#open-modal").addEventListener("click", () => modal.showModal());
    document.querySelector("#close-modal").addEventListener("click", () => modal.close());
  </script>

Center and size the modal

The browser already centers a modal dialog. The HTML Standard’s default rules set position: fixed, inset-block to 0 and margin: auto, and cap both max-width and max-height at calc(100% – 6px – 2em).

So the CSS you write is short. Set a width, cap the height, and leave positioning alone.

dialog {
  width: min(32rem, calc(100% - 2rem));
  max-height: min(40rem, 90svh);
  padding: 1.5rem;
  border: 0;
  border-radius: 0.75rem;
}

A global reset such as * { margin: 0 } removes margin: auto and pushes the dialog to the top-left corner. The default max-height cap also leaves a visible gap on full-height modals, so set max-height: none (a fix documented by Simon Willison).

Watch display too. Putting display: flex on the dialog selector overrides the closed state, so scope it to dialog[open].

For the height cap, prefer svh over vh, since the small, large and dynamic CSS viewport units account for mobile browser toolbars and plain vh does not.

Style the backdrop

The default backdrop is nearly invisible. The HTML Standard’s default style sheet sets it to rgba(0, 0, 0, 0.1).

Replace it with a stronger color and add a blur. The backdrop-filter property has been Baseline newly available since September 2024, when Safari 18 shipped it (WebDX web-features data), so keep the solid color dark enough to work without the blur.

dialog::backdrop {
  background: rgb(0 0 0 / 0.6);
  backdrop-filter: blur(4px);
}

The backdrop only exists when the dialog opens through showModal(). A dialog opened with show() or the open attribute has none, per MDN.

How to Build a CSS Modal Without JavaScript

A :target link and a checkbox toggle can both open a modal without JavaScript, and so can the commandfor attribute on a dialog button. Only that last one gives you a true modal.

The :target method

A link to #modal makes the element with that id the document’s target, and the :target pseudo-class styles it. A link to # closes it.

.modal:not(:target) { display: none; }
.modal:target { position: fixed; inset: 0; display: grid; place-items: center; }

One side effect is the URL fragment changing, so the Back button closes the modal. MDN notes that the target element updates on history navigation.

The limits are documented. The Admin Bar reports that Escape does not close it, and focus never moves into the modal or back to the trigger.

Backdrop clicks are where sources disagree. The Admin Bar’s build cannot close on a backdrop click, but Envato Tuts+ closes its modal that way by making the overlay itself an anchor that points back to the trigger.

Link-based overlays work for outside clicks. Escape still needs JavaScript.

The checkbox hack

A hidden checkbox holds the state, a label opens and closes it, and the :checked pseudo-class shows the modal. The modal has to follow the checkbox in the DOM so the sibling selector can reach it.

It shares the :target limits (no Escape, no focus handling) and adds a form control with no dialog meaning. For styling the control itself, see these CSS checkbox examples. Demos only, honestly.

Declarative dialog with commandfor

This is the only no-JavaScript route that keeps native modal behavior. A button with command=”show-modal” and commandfor pointing at the dialog’s id opens it modally, and command=”close” closes it (MDN Web Docs).

<button commandfor="my-dialog" command="show-modal">Open dialog</button>

<dialog id="my-dialog">
  <p>No click handler needed.</p>
  <button commandfor="my-dialog" command="close">Close</button>
</dialog>

Because the browser handles the command, the button works without waiting for a JavaScript bundle to download and run. Dialogs respond to show-modal, close and request-close.

Keep a small click listener as a fallback for browsers that predate the attributes.

Focus, Keyboard and Screen Reader Behavior

A native modal dialog contains focus at the page level only. Tab can still reach the browser’s own interface, and that’s deliberate.

It departs from the W3C pattern for modal dialogs, which keeps Tab inside the dialog.

Focus containment: ARIA pattern vs native behavior

Under the W3C ARIA Authoring Practices, Tab and Shift+Tab never move focus outside the dialog, and Tab on the last tabbable element wraps to the first.

A native dialog opened with showModal() behaves differently. Tab and Shift+Tab can reach the address bar, the tabs and extensions, while the page behind the dialog stays unreachable.

A WHATWG HTML issue (#8339) records why the native behavior stays. Keyboard users can open a new tab or change a browser setting without closing the dialog.

So skip the JavaScript focus trap on a native dialog. Add one only to div-based modals, where nothing else keeps focus off the page.

Where focus lands and where it returns

With long content, put tabindex=”-1″ on a static element at the top, such as the title, and focus that, so the start of the text does not scroll out of view. For a destructive action, focus the least destructive button, like Cancel. A simple confirmation can focus the most used control, such as OK or Continue.

All of that comes from the W3C pattern notes. The earlier tabindex warning applies to the dialog element itself, not to a static child inside it.

On close, focus should return to the invoking element unless it no longer exists. The HTML Standard tells browsers to restore focus to the element that had it before the dialog opened, and UXPin says the same of close(). That only works when the element still exists and can take focus.

A three-line close listener removes the doubt.

let trigger;
openBtn.addEventListener("click", () => { trigger = document.activeElement; modal.showModal(); });
modal.addEventListener("close", () => trigger?.focus());

Dismissal with closedby

closedby valueEsc or back gestureOutside click
anyClosesCloses
closerequestClosesDoes nothing
noneDoes nothingDoes nothing

Without the attribute, a dialog opened with showModal() behaves as closerequest (MDN Web Docs). Esc fires a cancel event first, which code can prevent in some cases.

Outside-click dismissal alone fails keyboard and touch users, who may have no equivalent gesture. Ship a visible close button every time, as the W3C pattern strongly recommends.

Labels for screen readers

Name the dialog through aria-labelledby pointing at a visible title, or through aria-label.

The W3C pattern makes aria-describedby optional and advises skipping it when the content has lists, tables or several paragraphs, because screen readers announce a description as one unbroken string. Mark a custom dialog aria-modal=”true” only when code blocks all interaction outside it and the styling obscures the page. A native dialog meets both conditions by default.

How to Stop the Page Scrolling Behind a Modal

Call showModal() and the page behind the dialog stops accepting clicks, but it can still scroll. Scroll locking is a separate CSS job, and OpenReplay’s scroll-lock guide puts the split plainly: the method blocks interaction, not background scrolling.

Lock the page with overflow

Set overflow: hidden on the root while a dialog is open. Using :has() ties the lock to the open attribute that showModal() sets, so JavaScript has nothing to clean up (Frontend Masters).

html:has(dialog[open]) { overflow: hidden; }

It isn’t bulletproof. Touch scrolling on iOS Safari can bypass overflow: hidden on the body (OpenReplay). And a page with the html element set to overflow-y: scroll keeps scrolling when only the body is locked, so target html instead.

For iOS Safari, OpenReplay’s fallback is a fixed-position body that saves and restores the scroll position.

Contain scroll chaining on the dialog

overscroll-behavior: contain stops scrolling inside the dialog from spilling into the page. MDN and the web-features dataset list the property as limited availability, so it is not yet Baseline: Safari’s support, since version 16, is partial and ignores containers with no scrollable overflow. Chrome 144 and Firefox 150 dropped that same limit.

Sources conflict on whether it works. Frontend Masters tested it on the dialog and the ::backdrop and saw no lock, while OpenReplay says it prevents chaining but not scrolling that starts on the backdrop or the body.

Both can be true. The property stops chaining, and it does not lock a page the user scrolls directly. Older engines also ignored it on a dialog or backdrop with nothing to scroll. With Safari support still partial, treat it as a layer on top of the overflow lock, never a replacement for it.

Prevent layout shift when the scrollbar disappears

According to the web-features dataset, scrollbar-gutter has been Baseline newly available since December 2024, when Safari 18.2 shipped it. Setting it to stable reserves the scrollbar’s space, so locking the page does not push content sideways.

Overlay scrollbars have no gutter, so the property does nothing there (MDN Web Docs). When no scrollbar shows, it leaves a blank strip down the side (Frontend Masters), and fixed-position elements do not always respect the reserved gutter.

Apply it to html rather than body, and test any fixed header.

html { scrollbar-gutter: stable; }
html:has(dialog[open]) { overflow: hidden; }
dialog { overscroll-behavior: contain; }

How to Animate a Modal Opening and Closing

Animating a dialog needs @starting-style for the entry state and a transition on display with allow-discrete. A transition on overlay keeps the dialog in the top layer while it exits (MDN Web Docs).

@starting-style has been Baseline newly available since August 2024, when Firefox 129 shipped it, according to the web-features dataset. The overlay property is the weak link. The same dataset lists it as limited availability, supported only in Chrome and Edge 117 and later.

Treat overlay as progressive enhancement. Without these features the dialog still opens, it just appears without the transition (web.dev). The same rules apply as for any other CSS animation: keep it short and optional.

dialog {
  opacity: 0;
  transform: translateY(1rem);
  transition: opacity 0.2s ease-out, transform 0.2s ease-out,
  overlay 0.2s allow-discrete, display 0.2s allow-discrete;
}

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

@starting-style {
  dialog[open] { opacity: 0; transform: translateY(1rem); }
}

dialog::backdrop {
  background: rgb(0 0 0 / 0);
  transition: background 0.2s, overlay 0.2s allow-discrete, display 0.2s allow-discrete;
}

dialog[open]::backdrop { background: rgb(0 0 0 / 0.6); }

@starting-style {
  dialog[open]::backdrop { background: rgb(0 0 0 / 0); }
}

Write allow-discrete inside the transition shorthand. If transition-behavior comes before the shorthand, the browser ignores it (web.dev).

Keyframes instead of transitions

Keyframe animations are simpler on the dialog and weaker on the backdrop. With CSS keyframes you skip @starting-style and allow-discrete, and a display value placed in a keyframe holds for the whole animation.

The catch is the backdrop. It can’t fade out this way, because the browser removes it the moment the dialog closes (MDN Web Docs).

Duration and reduced motion

MDN’s demos run 0.7s, which is slow for a component people open often. Drop it to around 200ms.

prefers-reduced-motion has been supported in every major browser since January 2020 and is Baseline widely available, and MDN lists scaling and panning of large objects among the vestibular motion triggers. WCAG 2.3.3 asks that interaction-triggered motion can be switched off, at Level AAA.

Inside a media query for the reduce setting, drop the transform from the transition list and keep a short fade.

@media (prefers-reduced-motion: reduce) {
  dialog {
    transition: opacity 0.1s linear, overlay 0.1s allow-discrete, display 0.1s allow-discrete;
  }
}

When a CSS Modal Falls Short

A CSS modal is the wrong tool when the content isn’t critical, when it blocks the first view of a page, when users hold unsaved input, or when the form sits on a phone with the keyboard open.

Content that does not deserve an interruption

Nielsen Norman Group’s 2017 guidance lists the costs: a modal demands immediate attention, interrupts the workflow, adds a dismissal task and blocks background content.

That price is worth paying for critical warnings and input required to continue. It isn’t worth paying for newsletter prompts, or for upsells that need information the modal hides. A flow that takes several steps probably deserves a full page.

NN/g’s example is Walmart.com, whose older checkout used a modal login prompt and later dropped it.

Landing pages and search visibility

Google Search Central says intrusive dialogs and interstitials make content harder for search engines to understand, and it recommends small banners over overlays that obscure the whole page (documentation updated December 2025).

The case to avoid is a modal that opens as soon as someone reaches a landing page from a search result. NN/g’s study caught GoodHousekeeping.com showing a newsletter modal within 3 seconds of a first homepage visit, over a full-screen photo that removed all context.

Age gates are exempt from Google’s guidance. Google still suggests overlaying the content so some of it stays indexable.

Unsaved input and forced closing

The cancel event lets code stop Escape from closing a dialog, but only in some cases.

Browsers added an anti-abuse rule. The cancel event fires only after user activation, and a second close request with no activation in between goes through (WICG CloseWatcher explainer). Google’s Material Web project tracks the same gap as an open issue, listing the Android back button and a double Escape as gestures that cannot be canceled.

Never rely on preventDefault to protect form data. Save drafts while the user types.

Forms and mobile keyboards

The svh, lvh and dvh units do not react to the on-screen keyboard by default, so a modal capped in those units can still hide a focused field behind the keyboard.

Keep form modals short, let the content scroll inside the dialog, and move long forms to their own page. Test every form modal on a real phone.

CSS Modal FAQ

Does ::backdrop inherit styles from the dialog element?

Yes, in current browsers. The ::backdrop pseudo-element inherits from its originating element, so it can read custom properties set on the dialog itself.

Support arrived in Firefox 120, Chrome and Edge 122, and Safari 17.4, according to Chrome for Developers. Older versions needed a :root, ::backdrop selector to share variables.

How does a dialog report which button the user pressed?

Put a form with method=”dialog” inside the dialog. Submitting it closes the dialog without sending data anywhere, and returnValue takes the value of the submitting button.

Read that string in the close event. Calling close(“save”) sets the same property from script.

Can you nest a modal inside another modal?

Technically yes, but avoid it. Calling showModal() on a second dialog stacks it above the first in the top layer, whatever z-index values the page uses.

Bootstrap’s documentation rejects nested modals as a poor user experience, and the a11y-dialog library supports them while calling them questionable. Swap the content inside one modal, or open modals in sequence.

What CSS makes a modal full screen?

Reset the browser’s size caps, then fill the viewport. Set max-width: none and max-height: none, then add width: 100%, height: 100% and margin: 0 to the dialog.

Keep a visible close button. A full-screen modal leaves no backdrop to click.

Is a native CSS modal better than a Bootstrap modal?

Native wins for new builds, and Bootstrap wins on sites already using it. Bootstrap’s modal runs on HTML, CSS and JavaScript, adds role=”dialog” through script, supports one modal at a time, and ignores the autofocus attribute.

The dialog element supplies top-layer placement and an inert background itself, with no library.

Checks to Run Before Shipping a CSS Modal

A CSS modal ships safely once a keyboard user can open it, leave it and land back on the button that opened it. Test in that order, because the severity of a failure sets the priority.

Keyboard open, close and focus return come first. Scroll lock is next, along with form fields when a phone keyboard is open. The exit animation and the reduced-motion fallback go last.

Combining the W3C dialog pattern with the animation support data gives that sequence. The first group of failures blocks a task, the second disrupts one, and the third costs only polish.

The trade-off is a plain, instant modal on the first release, with motion added after the other checks pass.

As of October 26, 2026, Safari has not shipped closedby (MDN’s compatibility data lists it only in Safari Technology Preview), so light dismiss still needs a script there. Revisit that line of the checklist when it does.

A site-wide pass with a web accessibility checklist covers the rest of the page around the modal.