Zubeyr··ComponentsoverlaysDialog
A modal dialog and a confirm dialog: focus moves in and back out, Tab stays inside, Escape and outside clicks close, background scroll locks, nested dialogs stack, and it slides up as a sheet on phones.
Live demo ↗Installation
Run the shadcn CLI in a project that already has components.json.
pnpm dlx shadcn@latest add https://tomei.zubeyr.dev/ui/r/dialog.json- →Installs
lucide-reactfrom npm. - →Files land in
components/dashboard/dialog.
Installing by hand? Add the dependencies, then copy the source code below.
pnpm add lucide-reactUsage
'use client'
import { useState } from 'react'
import { Button } from '@/registry/dashboard/components/button/button'
import { Field, Input } from '@/registry/dashboard/components/input/input'
import { ConfirmDialog, Dialog } from '@/components/dashboard/dialog/dialog'
export default function Demo() {
const [open, setOpen] = useState(false)
const [confirm, setConfirm] = useState(false)
const [name, setName] = useState('Bole Holdings')
return (
<div className="flex gap-2">
<Button onClick={() => setOpen(true)}>Rename customer</Button>
<Button variant="danger" onClick={() => setConfirm(true)}>
Delete order
</Button>
<Dialog
open={open}
onOpenChange={setOpen}
title="Rename customer"
description="The new name shows on invoices from now on."
footer={
<>
<Button variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button variant="primary" onClick={() => setOpen(false)}>
Save
</Button>
</>
}
>
<Field label="Customer name" htmlFor="customer-name">
<Input
id="customer-name"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
</Field>
</Dialog>
<ConfirmDialog
open={confirm}
onOpenChange={setConfirm}
tone="danger"
title="Delete order #1078?"
description="The order and its invoice are removed for everyone. This can’t be undone."
confirmLabel="Delete order"
onConfirm={() => new Promise((r) => setTimeout(r, 700))}
/>
</div>
)
}Source code
'use client'
import { useId, useState } from 'react'
import { createPortal } from 'react-dom'
import { AlertTriangle, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useModal, usePresence } from './modal'
export { useModal, usePresence }
const sizes = {
sm: 'max-w-sm',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
full: 'max-w-[min(72rem,100%)]',
}
export type DialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
title: React.ReactNode
description?: React.ReactNode
footer?: React.ReactNode
size?: keyof typeof sizes
dismissible?: boolean
hideClose?: boolean
initialFocus?: React.RefObject<HTMLElement | null>
closeLabel?: string
className?: string
children?: React.ReactNode
}
export function Dialog({
open,
onOpenChange,
title,
description,
footer,
size = 'md',
dismissible = true,
hideClose = false,
initialFocus,
closeLabel = 'Close',
className,
children,
}: DialogProps) {
const titleId = useId()
const descriptionId = useId()
const { present, visible } = usePresence(open)
const { panel } = useModal({
open,
onClose: () => onOpenChange(false),
dismissible,
initialFocus,
})
if (!present || typeof document === 'undefined') return null
return createPortal(
<div
className={cn(
'fixed inset-0 z-[150] flex items-end justify-center p-3 transition-opacity duration-150 motion-reduce:transition-none sm:items-center sm:p-6',
visible ? 'opacity-100' : 'opacity-0',
)}
>
<div
aria-hidden
className="absolute inset-0 bg-black/50"
onPointerDown={() => dismissible && onOpenChange(false)}
/>
<div
ref={panel}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
tabIndex={-1}
className={cn(
'relative flex max-h-[calc(100svh-1.5rem)] w-full flex-col rounded-2xl bg-card text-card-foreground shadow-(--shadow-overlay) transition-transform duration-150 ease-out outline-none motion-reduce:transition-none sm:max-h-[calc(100svh-3rem)]',
sizes[size],
visible ? 'translate-none scale-none' : 'translate-y-2 scale-[0.98]',
className,
)}
>
<header className="flex items-start gap-3 px-5 pt-5 pb-3">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<h2 id={titleId} className="text-base font-semibold">
{title}
</h2>
{description && (
<p id={descriptionId} className="text-sm text-muted-foreground">
{description}
</p>
)}
</div>
{!hideClose && (
<button
type="button"
aria-label={closeLabel}
onClick={() => onOpenChange(false)}
className="-mt-1 -mr-2 flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-4"
>
<X />
</button>
)}
</header>
{children && <div className="min-h-0 flex-1 overflow-y-auto px-5 pb-5">{children}</div>}
{footer && (
<footer className="flex flex-wrap items-center justify-end gap-2 border-t px-5 py-3">
{footer}
</footer>
)}
</div>
</div>,
document.body,
)
}
export type ConfirmDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
title: React.ReactNode
description?: React.ReactNode
confirmLabel?: string
cancelLabel?: string
tone?: 'default' | 'danger'
onConfirm: () => void | Promise<void>
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
tone = 'default',
onConfirm,
}: ConfirmDialogProps) {
const [busy, setBusy] = useState(false)
const confirm = async () => {
setBusy(true)
try {
await onConfirm()
onOpenChange(false)
} finally {
setBusy(false)
}
}
return (
<Dialog
open={open}
onOpenChange={(next) => !busy && onOpenChange(next)}
size="sm"
dismissible={!busy}
hideClose
title={
<span className="flex items-center gap-2">
{tone === 'danger' && <AlertTriangle className="size-4 text-destructive" />}
{title}
</span>
}
description={description}
footer={
<>
<button
type="button"
disabled={busy}
onClick={() => onOpenChange(false)}
className="h-8 rounded-lg px-3 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50"
>
{cancelLabel}
</button>
<button
type="button"
data-autofocus
disabled={busy}
aria-busy={busy || undefined}
onClick={() => void confirm()}
className={cn(
'h-8 rounded-lg px-3 text-sm font-medium disabled:opacity-60',
tone === 'danger'
? 'bg-destructive text-white hover:bg-destructive/90'
: 'bg-primary text-primary-foreground hover:bg-primary/90',
)}
>
{busy ? '…' : confirmLabel}
</button>
</>
}
/>
)
}'use client'
import { useEffect, useRef, useState } from 'react'
const stack: symbol[] = []
let locks = 0
let saved = { overflow: '', paddingRight: '' }
function lockScroll() {
if (locks++ > 0) return
const body = document.body
saved = { overflow: body.style.overflow, paddingRight: body.style.paddingRight }
const gap = window.innerWidth - document.documentElement.clientWidth
body.style.overflow = 'hidden'
if (gap > 0) body.style.paddingRight = `${gap}px`
}
function unlockScroll() {
if (--locks > 0) return
document.body.style.overflow = saved.overflow
document.body.style.paddingRight = saved.paddingRight
}
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]'
export function usePresence(open: boolean, duration = 180) {
const [present, setPresent] = useState(open)
const [visible, setVisible] = useState(false)
useEffect(() => {
if (open) {
const a = requestAnimationFrame(() => {
setPresent(true)
requestAnimationFrame(() => setVisible(true))
})
return () => cancelAnimationFrame(a)
}
const a = requestAnimationFrame(() => setVisible(false))
const timer = setTimeout(() => setPresent(false), duration)
return () => {
cancelAnimationFrame(a)
clearTimeout(timer)
}
}, [open, duration])
return { present: present || open, visible: visible && open }
}
export function useModal({
open,
onClose,
dismissible = true,
initialFocus,
}: {
open: boolean
onClose: () => void
dismissible?: boolean
initialFocus?: React.RefObject<HTMLElement | null>
}) {
const panel = useRef<HTMLDivElement>(null)
const close = useRef(onClose)
useEffect(() => {
close.current = onClose
}, [onClose])
useEffect(() => {
if (!open) return
const id = Symbol('modal')
stack.push(id)
lockScroll()
const previous = document.activeElement as HTMLElement | null
const frame = requestAnimationFrame(() => {
const target =
initialFocus?.current ??
panel.current?.querySelector<HTMLElement>('[autofocus], [data-autofocus]') ??
panel.current?.querySelector<HTMLElement>(FOCUSABLE) ??
panel.current
target?.focus()
})
const onEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || !dismissible || stack[stack.length - 1] !== id) return
event.stopPropagation()
close.current()
}
const onKey = (event: KeyboardEvent) => {
if (stack[stack.length - 1] !== id) return
if (event.key !== 'Tab' || !panel.current) return
const items = [...panel.current.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
(el) => el.offsetParent !== null || el === document.activeElement,
)
if (!items.length) {
event.preventDefault()
return
}
const first = items[0]
const last = items[items.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', onKey, true)
document.addEventListener('keydown', onEscape)
return () => {
cancelAnimationFrame(frame)
document.removeEventListener('keydown', onKey, true)
document.removeEventListener('keydown', onEscape)
stack.splice(stack.indexOf(id), 1)
unlockScroll()
if (previous?.isConnected) previous.focus()
}
}, [open, dismissible, initialFocus])
return { panel }
}