Skip to content
Overlays & Feedback primitive

Dialog

A modal on the native dialog element, which supplies the focus trap, inert background and top layer — plus the parts it does not: scroll lock that survives two dialogs, backdrop click, and an alertdialog that ignores it.

156 lineslucide-react
  • dialog
  • modal
  • alertdialog
  • overlay
  • confirm
  • focus trap

What's included

  • components/ui/dialog.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add dialog

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Live

Interactive, in your current theme. Every state below is the real component — type in it, tab through it, switch the theme.

Edit profile

Changes are visible to everyone in your workspace.

Delete this project?

This removes its deployments and environment variables. It cannot be undone.

Source

One file. Create components/ui/dialog.tsx and paste.

components/ui/dialog.tsx
'use client'

/**
 * <Dialog> — a modal window, built on the native `<dialog>` element.
 *
 * `showModal()` does the hard parts of a modal for free and does them
 * correctly: it makes everything behind the dialog inert (so Tab cannot walk
 * into the page underneath), it traps focus, it puts the dialog in the top
 * layer above every z-index, and Escape closes it. Nearly every hand-rolled
 * modal reimplements those four things worse, so this one does not.
 *
 * What the browser does NOT do, and this component adds:
 *
 *  - The page behind still scrolls under a wheel or a swipe. Scroll is locked
 *    on the root element while it is open and restored to whatever it was, so
 *    two dialogs (or a page that had already set overflow) do not fight.
 *  - Clicking the backdrop closes it. A click on the `<dialog>` element ITSELF
 *    is a click outside its content, because the content fills the box with
 *    its own padding and the dialog has none. That is why the padding lives on
 *    an inner div.
 *  - `open` is controlled state. Escape closes the element natively, so the
 *    `close` event is what reports it back through `onOpenChange`; without
 *    that, the parent still thinks it is open and the next "open" does nothing.
 *
 * `role="alertdialog"` is for a confirmation the user must answer, such as a
 * delete. It drops the backdrop dismiss by default (`dismissible` false),
 * because clicking away from "Delete this project?" should not count as an
 * answer. Focus lands on the first focusable element in the dialog, so put the
 * safe action (Cancel) first in the footer's tab order.
 *
 * Tailwind's preflight resets `margin` to 0 on every element, including
 * `<dialog>`, whose browser default is `margin: auto` — the thing that
 * centres it. `m-auto` restores it; without it the dialog sits top-left.
 */

import * as React from 'react'
import { X } from 'lucide-react'

export interface DialogProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  title: string
  description?: string
  children?: React.ReactNode
  /** Usually the buttons. Rendered in a bar under the content. */
  footer?: React.ReactNode
  size?: 'sm' | 'md' | 'lg'
  role?: 'dialog' | 'alertdialog'
  /** Whether a click on the backdrop closes it. Defaults to false for alertdialog. */
  dismissible?: boolean
  className?: string
}

const SIZES = { sm: 'max-w-sm', md: 'max-w-lg', lg: 'max-w-2xl' } as const

let scrollLocks = 0
let previousOverflow = ''

function lockScroll() {
  if (scrollLocks++ === 0) {
    previousOverflow = document.documentElement.style.overflow
    document.documentElement.style.overflow = 'hidden'
  }
}
function unlockScroll() {
  if (--scrollLocks === 0) document.documentElement.style.overflow = previousOverflow
}

export function Dialog({
  open,
  onOpenChange,
  title,
  description,
  children,
  footer,
  size = 'md',
  role = 'dialog',
  dismissible,
  className = '',
}: DialogProps) {
  const uid = React.useId()
  const ref = React.useRef<HTMLDialogElement>(null)
  const canDismiss = dismissible ?? role !== 'alertdialog'
  // `close()` reports back through the `close` event, one task later. When the
  // component itself closes the element (the parent set `open` false, or an
  // effect re-ran) that report is not news, and forwarding it would tell a
  // parent that had just re-opened us that we were closed.
  const closedByUs = React.useRef(false)

  React.useEffect(() => {
    const el = ref.current
    if (!el) return
    if (open && !el.open) {
      el.showModal()
      lockScroll()
      return () => {
        unlockScroll()
        if (el.open) {
          closedByUs.current = true
          el.close()
        }
      }
    }
  }, [open])

  return (
    <dialog
      ref={ref}
      role={role === 'alertdialog' ? 'alertdialog' : undefined}
      aria-labelledby={`${uid}-title`}
      aria-describedby={description ? `${uid}-desc` : undefined}
      // Escape and close() both land here; report it so state stays true.
      onClose={() => {
        if (closedByUs.current) {
          closedByUs.current = false
          return
        }
        onOpenChange(false)
      }}
      onClick={(e) => {
        if (canDismiss && e.target === e.currentTarget) onOpenChange(false)
      }}
      className={`m-auto w-[calc(100%-2rem)] ${SIZES[size]} rounded-2xl border border-border bg-card p-0 text-foreground shadow-2xl backdrop:bg-black/50 backdrop:backdrop-blur-sm ${className}`}
    >
      <div className="p-6">
        <div className="flex items-start justify-between gap-4">
          <h2 id={`${uid}-title`} className="text-lg font-semibold tracking-tight">
            {title}
          </h2>
          {role === 'dialog' ? (
            <button
              type="button"
              onClick={() => onOpenChange(false)}
              aria-label="Close"
              className="-m-1.5 shrink-0 rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
            >
              <X aria-hidden className="h-4 w-4" />
            </button>
          ) : null}
        </div>
        {description ? (
          <p id={`${uid}-desc`} className="mt-2 text-sm text-muted-foreground">
            {description}
          </p>
        ) : null}
        {children ? <div className="mt-4 text-sm">{children}</div> : null}
      </div>
      {footer ? (
        <div className="flex flex-wrap items-center justify-end gap-2 border-t border-border bg-muted/40 px-6 py-4">
          {footer}
        </div>
      ) : null}
    </dialog>
  )
}

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
openrequiredboolean—
onOpenChangerequired(open: boolean) => void—
titlerequiredstring—
descriptionstring—
childrenReact.ReactNode—
footerUsually the buttons. Rendered in a bar under the content.React.ReactNode—
size'sm' | 'md' | 'lg''md'
role'dialog' | 'alertdialog''dialog'
dismissibleWhether a click on the backdrop closes it. Defaults to false for alertdialog.boolean—
classNamestring''

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

More overlays & feedback

View all
Open the full page for this primitive

Alert

An inline message that is not a live region by default — an alert role interrupts a screen reader, which is wrong for a notice that is simply on the page — with a `live` switch for the ones that appear in response to something.

Overlays & Feedback134 lines1 dep
Open the full page for this primitive

Dropdown Menu

An action menu with the menu keyboard model in full: arrows, Home/End, typeahead, and Escape that returns focus to the trigger rather than dropping it on the page body. Its limits — no collision flip, no portal — are stated in the header.

Overlays & Feedback222 linesNo deps
Open the full page for this primitive

Tooltip

Dismissible with Escape, hoverable, and persistent — the three things WCAG 1.4.13 asks of content on hover — shown on keyboard focus rather than every click, and wired with aria-describedby so it is announced even when it is not drawn.

Overlays & Feedback128 linesNo deps