← Dashboard
Zubeyr··Componentsoverlays

Dialog

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-react from npm.
  • →Files land in components/dashboard/dialog.

Installing by hand? Add the dependencies, then copy the source code below.

terminal.sh
pnpm add lucide-react

Usage

example.tsx
'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

dialog.tsx
'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>
        </>
      }
    />
  )
}
modal.ts
'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 }
}