EmpireUI
Get Pro
← Blog7 min read#html-dialog#native-browser-api#modal

Native <dialog> Element: Modals Without React State or Libraries

Stop reaching for modal libraries. The native HTML dialog element gives you accessible, animatable modals with zero JavaScript state. Here's how to actually use it.

Code editor showing HTML and CSS for a styled dialog modal component

You Don't Need a Modal Library

Honestly, the modal ecosystem in React has gotten ridiculous. Headless UI, Radix Dialog, React Modal, MUI Dialog, Ant Design Modal — each pulling in its own accessibility logic, its own focus trap, its own Portal implementation. And underneath all of them? The browser already ships a perfectly good solution.

The native <dialog> element has been available in all major browsers since 2022. It handles focus trapping automatically, it exposes the ::backdrop pseudo-element for overlays, and it fires a close event when the user presses Escape. You get all of that for free. No npm install required.

This article is about using the <dialog> element directly — sometimes with minimal React glue, sometimes with no React state at all. We'll cover the API, the accessibility behaviors you get for free, animation techniques, and where the rough edges still are.

The showModal() vs show() Distinction

There are two ways to open a <dialog>: show() and showModal(). They behave very differently and the distinction matters.

show() opens the dialog as a non-modal. It renders in document flow, it doesn't block interaction with the rest of the page, and it doesn't create a backdrop. Think of it as a popover or tooltip container, not a traditional modal. showModal() is what you actually want for modal dialogs. It opens the element in the top layer, above everything including z-index: 9999 elements, blocks interaction with the rest of the page via inert, and provides an implicit backdrop.

The top layer is a browser-managed stacking context that sits above everything. Nothing in your CSS can escape it — not z-index, not transform, not overflow: hidden on a parent. If you've ever wrestled with a modal getting clipped by an ancestor with overflow: hidden, showModal() solves that problem permanently.

Using dialog Without React State

Here's where it gets genuinely interesting. You can open and close a <dialog> purely from other HTML elements using the Popover API's popovertarget, but for a proper modal you'll wire it up through a ref. The key insight is that the open/closed state lives in the DOM, not in React state.

import { useRef } from 'react'

export function NativeModal({ children }: { children: React.ReactNode }) {
  const dialogRef = useRef<HTMLDialogElement>(null)

  const open = () => dialogRef.current?.showModal()
  const close = () => dialogRef.current?.close()

  return (
    <>
      <button onClick={open} className="btn-primary">
        Open Modal
      </button>

      <dialog
        ref={dialogRef}
        onClose={close}
        className="native-dialog"
      >
        <div className="dialog-content">
          {children}
          <button onClick={close} autofocus>Close</button>
        </div>
      </dialog>
    </>
  )
}

Notice there's no useState for isOpen. The dialog is either open or closed based on whether showModal() or close() has been called. React doesn't re-render when you open or close it — the DOM just updates. This is meaningfully less work for the reconciler, especially if your app opens modals frequently.

Styling the ::backdrop Pseudo-Element

The ::backdrop pseudo-element renders behind the dialog and above everything else in the document. By default it's transparent. You can style it with CSS, including transitions — though animation support here has some quirks we'll get to.

/* Global styles or a CSS module */
.native-dialog {
  border: none;
  border-radius: 12px;
  padding: 32px;
  max-width: 560px;
  width: calc(100% - 48px);
  background: #ffffff;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
}

.native-dialog::backdrop {
  background: rgba(0, 0, 0, 0.6);
  backdrop-filter: blur(4px);
}

/* Entry animation */
.native-dialog[open] {
  animation: dialog-in 200ms ease-out;
}

@keyframes dialog-in {
  from {
    opacity: 0;
    transform: translateY(12px) scale(0.97);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

The backdrop-filter: blur(4px) on the ::backdrop gives you that frosted glass effect popular in modern UIs — similar to what we explore in the glassmorphism guide. The backdrop pseudo-element respects transition and animation, so you can fade it in. The exit animation is the tricky part, which we'll tackle in the next section.

One thing that trips people up: you can't target ::backdrop from inside a CSS module in the usual way because the pseudo-element is generated by the browser, not your component. You'll need a global stylesheet or a :global(dialog::backdrop) selector if you're in CSS Modules. See tailwind vs css modules for a full breakdown of when that matters.

Animating the Close Transition

Entry animations are easy. Exit animations are hard. The problem is that when you call dialog.close(), the dialog immediately disappears from the top layer — there's no built-in way to wait for a CSS animation to finish. You have to handle this yourself.

const close = () => {
  const dialog = dialogRef.current
  if (!dialog) return

  // Add the exit class, then wait for animation
  dialog.classList.add('dialog-closing')

  dialog.addEventListener(
    'animationend',
    () => {
      dialog.classList.remove('dialog-closing')
      dialog.close()
    },
    { once: true }
  )
}
```

```css
.native-dialog.dialog-closing {
  animation: dialog-out 180ms ease-in forwards;
}

.native-dialog.dialog-closing::backdrop {
  animation: backdrop-out 180ms ease-in forwards;
}

@keyframes dialog-out {
  from { opacity: 1; transform: scale(1); }
  to   { opacity: 0; transform: scale(0.96); }
}

@keyframes backdrop-out {
  from { opacity: 1; }
  to   { opacity: 0; }
}

This pattern is reliable but slightly verbose. The { once: true } option on addEventListener ensures the handler removes itself automatically. You can also use the Web Animations API (dialog.animate(...)) for a promise-based approach if you prefer that to class toggling.

Is this more boilerplate than a library? Yes. But you're also not shipping 12 KB of JavaScript for a dialog component. That trade-off is real, and for performance-sensitive apps or design systems, it's worth thinking about seriously.

Accessibility: What You Get For Free and What You Don't

The native <dialog> gives you solid accessibility out of the box. Focus is trapped inside the dialog when opened with showModal() — Tab and Shift+Tab cycle only within the dialog's focusable elements. Pressing Escape fires the cancel event, then the close event, and closes the dialog. Screen readers receive the dialog ARIA role automatically. When the dialog closes, focus returns to the element that opened it.

What you still need to handle: aria-labelledby pointing to the dialog's heading, aria-describedby if there's body text, and autofocus on a sensible element inside the dialog (usually the close button or the first form field, not the dialog element itself). The autofocus attribute on an element inside the <dialog> controls where focus lands when it opens. Don't skip it.

One genuine limitation: the native close event doesn't tell you *why* the dialog closed. Was it the Escape key? The close button? A form submission? You have to track that yourself if the close reason matters to your application logic.

Integrating with Tailwind v4

Tailwind v4.0.2 added full support for the ::backdrop pseudo-element via the backdrop: variant. This makes styling native dialogs much cleaner than managing separate global CSS files.

<dialog
  ref={dialogRef}
  className={[
    'rounded-xl border-0 p-8 shadow-2xl max-w-xl w-[calc(100%-3rem)]',
    'backdrop:bg-black/60 backdrop:backdrop-blur-sm',
    'open:animate-in open:fade-in open:slide-in-from-bottom-3',
  ].join(' ')}
>
  <h2 id="dialog-title" className="text-xl font-semibold mb-4">
    Confirm Action
  </h2>
  <p className="text-gray-600 mb-6">This can't be undone.</p>
  <div className="flex gap-3 justify-end">
    <button onClick={close} className="btn-ghost">Cancel</button>
    <button onClick={handleConfirm} className="btn-danger" autofocus>
      Delete
    </button>
  </div>
</dialog>

The open: variant applies styles only when the dialog has the open attribute — which the browser adds automatically when you call showModal(). The animate-in utilities here assume you've got Tailwind's animation plugin configured. If you haven't set that up yet, a quick @keyframes block in your global CSS handles it fine.

For dark mode support, pair the backdrop:bg-black/60 with a dark:backdrop:bg-black/80 class. Theme-aware modals are something we cover in more depth in the theme toggle guide, which is worth reading if your app supports both light and dark modes.

When to Skip the Native Dialog

The native <dialog> isn't the right tool for everything. Drawers and side panels that slide in from the edge work better as positioned elements — the top layer centering behavior fights you there. Tooltips and floating menus belong in the Popover API, not <dialog>. And if you need nested modals, the top layer handles stacking correctly but the UX is almost always a sign you should rethink the interaction design.

Older browser support is also worth checking. showModal() is in all evergreen browsers, but if your analytics show significant Safari 14 usage (released 2020), you'll hit issues — Apple only fully supported <dialog> in Safari 15.4, released March 2022. Check your caniuse targets before committing.

For the common case — a centered modal with a backdrop, triggered by a button — the native <dialog> is genuinely excellent. It ships less JavaScript, it's accessible by default, and it doesn't add another dependency to your package.json. That's a meaningful win. If your component library needs something more elaborate, techniques from canvas-based UI animation can enhance the overlay layer without touching the dialog mechanics at all.

FAQ

Does showModal() work inside a React Portal?

Yes, but you don't need a Portal at all. The native dialog element uses the browser's top layer when opened with showModal(), which renders above everything regardless of where the element sits in the DOM tree. You can place the dialog element anywhere in your JSX and it'll stack correctly.

How do I prevent the dialog from closing when the user clicks the backdrop?

The native dialog fires a 'click' event on the dialog element itself when the backdrop is clicked (the backdrop isn't a separate DOM node you can target with addEventListener). Check if event.target === dialogRef.current inside a click handler on the dialog. If it is, the click landed on the dialog element's border/padding area — which is the backdrop — and you can call event.preventDefault() on the 'cancel' event to block Escape-key closing, or simply ignore backdrop clicks.

Can I use form method='dialog' to close the dialog and capture form data?

Absolutely. A <form method='dialog'> inside a <dialog> will close the dialog on submit and populate dialog.returnValue with the value of the submit button. This is useful for confirmation dialogs where you want to know which button was clicked (OK vs Cancel) without any event listeners.

Why does my exit animation not play on the ::backdrop?

The ::backdrop closes at the same time as the dialog element. If you're using the class-swap approach (adding a 'dialog-closing' class), you need to explicitly animate the ::backdrop in that closing state too. Some browsers handle this automatically when you animate the dialog, but don't rely on it — add an explicit .dialog-closing::backdrop animation in your CSS.

Is the dialog element accessible to screen readers without extra ARIA attributes?

Partially. The browser exposes an implicit role='dialog' and handles focus trapping. But you should still add aria-labelledby pointing to the dialog heading and aria-modal='true' (the latter reinforces to assistive technology that content behind the dialog is inert). Without aria-labelledby, screen readers may announce the dialog without context.

How do I animate the ::backdrop separately in Tailwind v4?

Use the backdrop: variant combined with a custom animation class: backdrop:animate-[backdrop-fade_200ms_ease-out]. Define the @keyframes backdrop-fade in your CSS layer. In Tailwind v4.0.2 the backdrop: variant works on the ::backdrop pseudo-element when applied to a dialog or popover element.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Native Popover API: Tooltips and Menus Without JavaScriptWeb Components + React: Custom Elements Without the HeadachesProgress Stepper Component in React: Wizard UI with StateReact Portals: Modals, Tooltips, and Drawers Done Correctly