Type something to search...
How to Create a Pure CSS Modal Without JavaScript

How to Create a Pure CSS Modal Without JavaScript

Modals are everywhere: sign-up prompts, confirmation dialogs, image lightboxes, cookie settings, and "are you sure?" warnings. Traditionally, every one of them needed JavaScript to open, close, trap focus, and dim the page behind. Developers who wanted a script-free modal turned to clever CSS hacks with :target or hidden checkboxes, which worked visually but were rough around the edges for keyboard and screen reader users.

The platform has caught up. Between the Popover API, native <dialog>, and invoker commands, you can now build modals that open and close with nothing but HTML attributes, and style every part of them, including the backdrop and the open/close animation, with CSS. In this guide we'll look at the modern approaches first, then the classic techniques and when they still make sense.

What a Good Modal Needs

Before writing code, it helps to list what a modal has to do:

  1. Open from a trigger and close from a close button, the Escape key, and optionally a click outside.
  2. Render above everything else on the page, regardless of stacking contexts.
  3. Dim or block the page behind it.
  4. Move focus into the modal when it opens and back to the trigger when it closes.
  5. For true modals, make the rest of the page inert, so keyboard and screen reader users can't wander outside.

The old CSS-only hacks handle items 1 through 3. The native platform features handle all five.

Approach 1: The Popover API

The popover attribute turns any element into a popover that the browser can show and hide. No JavaScript is needed to wire it up; a button with popovertarget does the job:

<button type="button" popovertarget="newsletter-modal">
  Join the newsletter
</button>

<div id="newsletter-modal" popover class="modal">
  <h2 class="modal__title">Get the weekly digest</h2>
  <p>One email a week with our best CSS articles. No spam.</p>

  <form class="modal__form" action="/subscribe" method="post">
    <label for="nl-email">Email</label>
    <input id="nl-email" type="email" name="email" required />
    <button type="submit">Subscribe</button>
  </form>

  <button
    type="button"
    class="modal__close"
    popovertarget="newsletter-modal"
    popovertargetaction="hide"
  >
    Close
  </button>
</div>

That's a working modal. The browser handles:

  • Toggling visibility when the trigger is clicked.
  • Closing on Escape.
  • Light dismiss: clicking outside closes it (for the default popover="auto" mode).
  • Placing the popover in the top layer, so it renders above every other element, no z-index needed.
  • Returning focus sensibly when it closes.

Now style it:

.modal {
  width: min(32rem, calc(100vw - 2rem));
  padding: 2rem;
  border: 0;
  border-radius: 1rem;
  background: #ffffff;
  color: #111827;
  box-shadow: 0 24px 64px rgb(0 0 0 / 0.25);
}

.modal::backdrop {
  background: rgb(15 23 42 / 0.6);
}

.modal__title {
  margin-top: 0;
}

.modal__close {
  position: absolute;
  top: 1rem;
  right: 1rem;
}

By default the browser centers popovers in the viewport with position: fixed and margin: auto, so the modal is centered without any extra layout code. The ::backdrop pseudo-element styles the layer between the popover and the page.

One important caveat: a popover is not modal in the accessibility sense. The page behind it is still interactive and focusable. For a newsletter prompt or a "quick view" panel, that's fine. For a dialog that must be answered before continuing, use the approach below.

Approach 2: dialog with Invoker Commands

The <dialog> element has long supported true modal behavior through its showModal() method, which makes the rest of the page inert and traps focus inside the dialog. Until recently, calling it required JavaScript. Invoker commands remove that requirement, letting a button open a dialog declaratively with the command and commandfor attributes:

<button type="button" commandfor="delete-dialog" command="show-modal">
  Delete project
</button>

<dialog id="delete-dialog" class="modal" aria-labelledby="delete-title">
  <h2 id="delete-title" class="modal__title">Delete this project?</h2>
  <p>
    This permanently removes the project and all of its files. You can't undo
    this.
  </p>

  <div class="modal__actions">
    <button type="button" commandfor="delete-dialog" command="close">
      Cancel
    </button>
    <form method="dialog">
      <button type="submit" class="button--danger" value="confirm">
        Delete
      </button>
    </form>
  </div>
</dialog>
  • command="show-modal" calls the equivalent of showModal().
  • command="close" closes it.
  • A <form method="dialog"> closes the dialog when submitted and sets its returnValue to the button's value, which is handy if you do add JavaScript later.

With showModal(), you get everything on the checklist: top layer rendering, a backdrop, Escape to close, focus moved into the dialog, and the rest of the page made inert.

Invoker commands are supported in current Chromium-based browsers and Firefox, and Safari support has been arriving; check caniuse for the current status. For browsers without support, a three-line script keeps it working:

<script>
  if (!("command" in HTMLButtonElement.prototype)) {
    document.addEventListener("click", (event) => {
      const button = event.target.closest("button[commandfor]");
      if (!button) return;
      const dialog = document.getElementById(button.getAttribute("commandfor"));
      const command = button.getAttribute("command");
      if (command === "show-modal") dialog.showModal();
      if (command === "close") dialog.close();
    });
  }
</script>

It only runs in browsers that need it.

Light Dismiss for Dialogs

Modal dialogs don't close on outside clicks by default, which is often correct for important confirmations. Where you want it, the closedby attribute controls it:

<dialog id="photo-dialog" class="modal" closedby="any">
  <!-- lightbox content -->
</dialog>

closedby="any" enables light dismiss, closerequest allows Escape but not outside clicks, and none requires an explicit close button. Support is currently strongest in Chromium and Firefox, so don't rely on it for critical behavior.

Styling the Dialog

The CSS is nearly identical to the popover version:

dialog.modal {
  width: min(28rem, calc(100vw - 2rem));
  max-height: calc(100dvh - 2rem);
  padding: 1.75rem;
  border: 0;
  border-radius: 1rem;
  box-shadow: 0 24px 64px rgb(0 0 0 / 0.25);
  overflow: auto;
}

dialog.modal::backdrop {
  background: rgb(15 23 42 / 0.6);
  backdrop-filter: blur(3px);
}

.modal__actions {
  display: flex;
  justify-content: flex-end;
  gap: 0.75rem;
  margin-top: 1.5rem;
}

.button--danger {
  background: #dc2626;
  color: #ffffff;
}

To stop the page behind from scrolling while the modal is open, :has() does it in one rule:

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

The :modal pseudo-class matches only dialogs opened with showModal(), so non-modal dialogs don't lock scrolling.

Animating Open and Close

Animating elements that go from display: none to visible used to be impossible in CSS, because there's nothing to transition from. Three newer features solve it:

  • @starting-style defines the styles an element transitions from when it first appears.
  • transition-behavior: allow-discrete lets display and overlay participate in transitions, so the element stays visible until the exit animation finishes.
  • The overlay property keeps the element in the top layer during the exit transition.
.modal,
.modal::backdrop {
  transition:
    opacity 0.25s ease,
    translate 0.25s ease,
    display 0.25s allow-discrete,
    overlay 0.25s allow-discrete;
}

/* Closed state (also the exit animation target) */
.modal {
  opacity: 0;
  translate: 0 1rem;
}

.modal::backdrop {
  opacity: 0;
}

/* Open state */
.modal:popover-open,
dialog.modal[open] {
  opacity: 1;
  translate: 0 0;
}

.modal:popover-open::backdrop,
dialog.modal[open]::backdrop {
  opacity: 1;
}

/* Where the entry animation starts from */
@starting-style {
  .modal:popover-open,
  dialog.modal[open] {
    opacity: 0;
    translate: 0 1rem;
  }

  .modal:popover-open::backdrop,
  dialog.modal[open]::backdrop {
    opacity: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .modal,
  .modal::backdrop {
    transition: none;
  }
}

In browsers that support these features (current Chromium, and Safari and Firefox for most of them), the modal fades and slides in and out. In browsers that don't, it simply appears and disappears instantly, which is a perfectly good fallback. Note that the order matters: the @starting-style block must come after the open-state rule so it isn't overridden.

The Classic :target Technique

Before these APIs existed, the most common CSS-only modal used the URL fragment. A link points to #modal-id, and the :target pseudo-class shows the element with that id:

<a href="#terms-modal" class="button">Read the terms</a>

<div
  id="terms-modal"
  class="target-modal"
  role="dialog"
  aria-labelledby="terms-title"
>
  <a href="#" class="target-modal__overlay" aria-label="Close"></a>
  <div class="target-modal__panel">
    <h2 id="terms-title">Terms of service</h2>
    <p>By using this site you agree to...</p>
    <a href="#" class="target-modal__close">Close</a>
  </div>
</div>
.target-modal {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: none;
  place-items: center;
}

.target-modal:target {
  display: grid;
}

.target-modal__overlay {
  position: absolute;
  inset: 0;
  background: rgb(15 23 42 / 0.6);
}

.target-modal__panel {
  position: relative;
  width: min(32rem, calc(100vw - 2rem));
  padding: 2rem;
  border-radius: 1rem;
  background: #ffffff;
}

It works in every browser ever made, which is its main appeal. The downsides are significant, though:

  • It adds history entries, so the Back button opens and closes the modal.
  • Closing with href="#" jumps to the top of the page.
  • There's no focus management, no Escape key, and the page behind stays interactive.
  • It relies on a positioned z-index, so it can be trapped by stacking contexts.

There's also a checkbox variant that uses a hidden input type="checkbox" and a label as the trigger, toggled with :checked. It avoids the history problem but has the same accessibility gaps, and a label isn't announced as a button.

Today, reach for these only when you must support very old browsers and can't add any script at all.

Which Approach Should You Use?

  • Informational overlays, quick views, menus: popover. Light dismiss and top-layer rendering for free.
  • Confirmations, forms that must be completed, anything blocking: <dialog> with command="show-modal", plus the tiny fallback script.
  • Legacy environments without script: :target, accepting its limitations.

Accessibility Checklist

  • Give every dialog an accessible name with aria-labelledby pointing at its heading.
  • Include a visible close button, not just Escape and outside clicks.
  • Keep the first focusable element meaningful. If you want focus on a specific element, add the autofocus attribute to it inside the dialog.
  • Make sure the modal content scrolls on small screens (max-height with overflow: auto).
  • Don't open modals automatically on page load; users find it disruptive, and it can interfere with assistive technology.

Conclusion

Pure CSS modals used to mean compromising on accessibility. That's no longer the case. The popover attribute gives you a script-free overlay in the top layer with Escape and light dismiss built in, and <dialog> with invoker commands gives you a true modal with focus trapping and an inert background, all opened with HTML attributes. CSS handles the rest: ::backdrop for the overlay, :has() for scroll locking, and @starting-style for smooth entry and exit animations.

Keep the :target trick in your back pocket for legacy cases, but for new work, let the platform do the heavy lifting. Your modals will be lighter, faster, and far friendlier to keyboard and screen reader users.

Tags :
Share :

Related Posts

A Complete Guide to CSS Container Queries

A Complete Guide to CSS Container Queries

For more than a decade, responsive design meant one thing: media queries. You asked the browser how wide the viewport was and adjusted your layout ac

Continue Reading
A Comprehensive Guide to Installing Next.js

A Comprehensive Guide to Installing Next.js

Next.js has emerged as a powerful framework for building React applications, offering features like server-side rendering, static site generation, an

Continue Reading
Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

CSS has evolved significantly, and modern tools like clamp(), min(), and max() are powerful game-changers in dynamic styling. If you’ve struggl

Continue Reading