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.
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.
components/ui/dialog.tsxnpx hoverlab add dialog
Or over MCP, from your editor's agent — no account needed.
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.
Interactive, in your current theme. Every state below is the real component — type in it, tab through it, switch the theme.
One file. Create components/ui/dialog.tsx and paste.
'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>
)
}
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.
| Prop | Type | Default |
|---|---|---|
openrequired | boolean | — |
onOpenChangerequired | (open: boolean) => void | — |
titlerequired | string | — |
description | string | — |
children | React.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 | — |
className | string | '' |
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.
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.
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.
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.