🚧 Under development — suggestions for new items are always welcome! ✨

Dialog

A window displayed over existing content, with optional functionality to temporarily disable interaction with the underlying content.

Loading MDX…

Install

pnpm dlx [email protected] add https://p.livog.com/r/dialog.json

Code

'use client'

import type { PolymorphicProps } from '@/components/shared/polymorphic'
import { clsx } from 'clsx'
import { XIcon } from 'lucide-react'
import type { ElementType } from 'react'
import * as React from 'react'
import { createPortal } from 'react-dom'

export const PLACEMENTS = [
  'top',
  'right',
  'bottom',
  'left',
  'center',
  'top-start',
  'top-end',
  'bottom-start',
  'bottom-end'
] as const
export type Placement = (typeof PLACEMENTS)[number]

export type ScrollBehavior = 'inside' | 'outside'

type DialogContextValue = {
  open: boolean
  onOpenChange: (open: boolean) => void
  triggerRef: React.RefObject<HTMLElement | null>
  placement: Placement
  closeOnEsc: boolean
  closeOnBackdrop: boolean
  disableBackdrop: boolean
  noInTransition: boolean
  noOutTransition: boolean
  lockScroll: boolean
  scrollBehavior: ScrollBehavior
  className?: string
  onClosed?: () => void
}

// Context
const DialogContext = React.createContext<DialogContextValue | null>(null)

const useDialog = () => {
  const context = React.useContext(DialogContext)
  if (!context) {
    throw new Error('Dialog components must be used within a Dialog')
  }
  return context
}

// Utilities
const useFocusTrap = (containerRef: React.RefObject<HTMLElement | null>, active: boolean) => {
  React.useEffect(() => {
    if (!active || !containerRef.current) return

    const container = containerRef.current
    const focusableElements = Array.from(
      container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
    ) as HTMLElement[]

    const isElementInView = (el: HTMLElement) => {
      const elemRect = el.getBoundingClientRect()
      const containerRect = container.getBoundingClientRect()
      return elemRect.top >= containerRect.top && elemRect.bottom <= containerRect.bottom
    }

    const firstElement = focusableElements.find(isElementInView) || focusableElements[0]
    const lastElement = focusableElements[focusableElements.length - 1]

    const handleTabKey = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return

      if (e.shiftKey) {
        if (document.activeElement === firstElement) {
          lastElement?.focus({ preventScroll: true })
          e.preventDefault()
        }
      } else {
        if (document.activeElement === lastElement) {
          firstElement?.focus({ preventScroll: true })
          e.preventDefault()
        }
      }
    }

    container.addEventListener('keydown', handleTabKey)
    firstElement?.focus({ preventScroll: true })

    return () => {
      container.removeEventListener('keydown', handleTabKey)
    }
  }, [active, containerRef])
}

const useBodyScrollLock = (lock: boolean) => {
  React.useEffect(() => {
    if (!lock) return

    const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth

    document.body.classList.add('!overflow-hidden')
    document.body.classList.add('!mr-[var(--scroll-bar-width)]')
    document.body.style.setProperty('--scroll-bar-width', `${scrollBarWidth}px`)

    return () => {
      document.body.classList.remove('!overflow-hidden')
      document.body.classList.remove('!mr-[var(--scroll-bar-width)]')
      document.body.style.removeProperty('--scroll-bar-width')
    }
  }, [lock])
}

// Main Dialog Component
interface DialogProps {
  children: React.ReactNode
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
  placement?: Placement
  closeOnEsc?: boolean
  closeOnBackdrop?: boolean
  disableBackdrop?: boolean
  noInTransition?: boolean
  noOutTransition?: boolean
  scrollBehavior?: ScrollBehavior
  /** Whether to lock body scrolling while the dialog is open (default: true) */
  lockScroll?: boolean
  /** Optional class applied to the outer dialog wrapper */
  className?: string
  onClosed?: () => void
}

export function Dialog({
  children,
  open,
  defaultOpen = false,
  onOpenChange,
  onClosed,
  placement = 'center',
  closeOnEsc = true,
  closeOnBackdrop = true,
  disableBackdrop = false,
  noInTransition = false,
  noOutTransition = false,
  scrollBehavior = 'inside',
  lockScroll = true,
  className
}: DialogProps) {
  const [internalOpen, setInternalOpen] = React.useState(defaultOpen)
  const triggerRef = React.useRef<HTMLElement | null>(null)

  const isControlled = open !== undefined
  const isOpen = isControlled ? open : internalOpen

  const handleOpenChange = React.useCallback(
    (newOpen: boolean) => {
      if (!isControlled) {
        setInternalOpen(newOpen)
      }
      onOpenChange?.(newOpen)
    },
    [isControlled, onOpenChange]
  )

  const contextValue = React.useMemo(
    () => ({
      open: isOpen,
      onOpenChange: handleOpenChange,
      triggerRef,
      placement,
      closeOnEsc,
      closeOnBackdrop,
      disableBackdrop,
      noInTransition,
      noOutTransition,
      lockScroll,
      scrollBehavior,
      className,
      onClosed
    }),
    [
      isOpen,
      handleOpenChange,
      placement,
      closeOnEsc,
      closeOnBackdrop,
      disableBackdrop,
      noInTransition,
      noOutTransition,
      lockScroll,
      scrollBehavior,
      className,
      onClosed
    ]
  )

  return <DialogContext.Provider value={contextValue}>{children}</DialogContext.Provider>
}

const DEFAULT_TRIGGER_ELEMENT = 'button'
type DialogTriggerProps<E extends ElementType = typeof DEFAULT_TRIGGER_ELEMENT> = PolymorphicProps<E>

export function DialogTrigger<E extends ElementType = typeof DEFAULT_TRIGGER_ELEMENT>({
  as,
  children,
  ...props
}: DialogTriggerProps<E>) {
  const { onOpenChange, triggerRef } = useDialog()
  const Component = as || DEFAULT_TRIGGER_ELEMENT

  return (
    <Component
      ref={triggerRef as unknown as React.Ref<HTMLButtonElement>}
      onClick={(event: React.MouseEvent<HTMLElement>) => {
        onOpenChange(true)
        props.onClick?.(event)
      }}
      {...props}>
      {children}
    </Component>
  )
}

// Dialog Content
interface DialogContentProps {
  children: React.ReactNode
  className?: string
}

export function DialogContent({ children, className: contentClassName }: DialogContentProps) {
  const {
    open,
    onOpenChange,
    placement,
    closeOnEsc,
    closeOnBackdrop,
    disableBackdrop,
    noInTransition,
    noOutTransition,
    triggerRef,
    scrollBehavior,
    lockScroll,
    className: outerClassName,
    onClosed
  } = useDialog()

  const [mounted, setMounted] = React.useState(false)
  const [dataState, setDataState] = React.useState<'open' | 'closed'>('closed')
  const contentRef = React.useRef<HTMLDivElement>(null)

  useFocusTrap(contentRef, open && mounted)
  useBodyScrollLock(mounted && lockScroll)

  // Handle mounting and animations
  React.useEffect(() => {
    if (open) {
      setMounted(true)
    } else {
      setDataState('closed')
    }
  }, [open])

  React.useEffect(() => {
    if (mounted) {
      const id = requestAnimationFrame(() => setDataState('open'))
      return () => cancelAnimationFrame(id)
    }
  }, [mounted])

  // Unmount component after exit animation completes
  React.useEffect(() => {
    const node = contentRef.current
    if (!node) return

    const handleAnimationEnd = (event: AnimationEvent | TransitionEvent) => {
      if (event.target === node && dataState === 'closed') {
        setMounted(false)
        onClosed?.()
      }
    }

    node.addEventListener('transitionend', handleAnimationEnd)
    node.addEventListener('animationend', handleAnimationEnd)
    return () => {
      node.removeEventListener('transitionend', handleAnimationEnd)
      node.removeEventListener('animationend', handleAnimationEnd)
    }
  }, [dataState, onClosed])

  // Handle keyboard events
  React.useEffect(() => {
    if (!mounted || !closeOnEsc) return
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onOpenChange(false)
    }
    document.addEventListener('keydown', handleKeyDown)
    return () => document.removeEventListener('keydown', handleKeyDown)
  }, [mounted, closeOnEsc, onOpenChange])

  React.useEffect(() => {
    if (!mounted || !closeOnBackdrop) return

    const handleClick = (e: MouseEvent) => {
      const target = e.target as Node
      if (contentRef.current?.contains(target) || triggerRef.current?.contains(target)) return
      setTimeout(() => onOpenChange(false), 0)
    }

    document.addEventListener('click', handleClick, true)
    return () => document.removeEventListener('click', handleClick, true)
  }, [mounted, closeOnBackdrop, onOpenChange, triggerRef])

  if (!mounted) return null

  const content = (
    <>
      {!disableBackdrop && (
        <DialogBackdrop
          data-state={dataState}
          data-no-in-transition={noInTransition}
          data-no-out-transition={noOutTransition}
          className="dialog-backdrop"
          onClick={(e) => {
            if (e.target === e.currentTarget && closeOnBackdrop) {
              onOpenChange(false)
            }
          }}
        />
      )}
      <div
        className={clsx('dialog', outerClassName)}
        data-state={dataState}
        data-placement={placement}
        data-scroll={scrollBehavior}
        data-disable-backdrop={disableBackdrop}
        data-no-in-transition={noInTransition}
        data-no-out-transition={noOutTransition}>
        <div
          ref={contentRef}
          className={clsx('dialog-content', contentClassName)}
          data-state={dataState}
          data-no-in-transition={noInTransition}
          data-no-out-transition={noOutTransition}
          role="dialog"
          aria-modal="true">
          {children}
        </div>
      </div>
    </>
  )

  return createPortal(content, document.body)
}

export function DialogHeader({ children, className }: React.ComponentPropsWithoutRef<'div'>) {
  return <div className={clsx('dialog-header', className)}>{children}</div>
}

export function DialogFooter({ children, className }: React.ComponentPropsWithoutRef<'div'>) {
  return <div className={clsx('dialog-footer', className)}>{children}</div>
}

export function DialogTitle({ children, className }: React.ComponentPropsWithoutRef<'h2'>) {
  return <h2 className={clsx('dialog-title', className)}>{children}</h2>
}

interface DialogDescriptionProps {
  children: React.ReactNode
  className?: string
}

export function DialogDescription({ children, className }: DialogDescriptionProps) {
  return <p className={clsx('dialog-description', className)}>{children}</p>
}

const DEFAULT_CLOSE_ELEMENT = 'button'
type DialogCloseProps<E extends ElementType = typeof DEFAULT_CLOSE_ELEMENT> = PolymorphicProps<E>

export function DialogClose<E extends ElementType = typeof DEFAULT_CLOSE_ELEMENT>({
  as,
  children,
  ...props
}: DialogCloseProps<E>) {
  const { onOpenChange } = useDialog()
  const Component = as || DEFAULT_CLOSE_ELEMENT

  return (
    <Component
      onClick={(event: React.MouseEvent<HTMLElement>) => {
        onOpenChange(false)
        props.onClick?.(event)
      }}
      {...props}>
      {children}
    </Component>
  )
}

type DialogCloseIconProps = React.ComponentPropsWithoutRef<typeof DEFAULT_CLOSE_ELEMENT>
export function DialogCloseIcon({ className, ...props }: DialogCloseIconProps) {
  return (
    <DialogClose {...props}>
      <XIcon className={clsx('size-5', className)} />
    </DialogClose>
  )
}

interface DialogBackdropProps extends React.HTMLAttributes<HTMLDivElement> {
  className?: string
}

function DialogBackdrop({ className, ...props }: DialogBackdropProps) {
  return <div className={clsx('dialog-backdrop', className)} {...props} />
}

DialogBackdrop.displayName = 'DialogBackdrop'

export { DialogBackdrop }

Basic

Cookie Banner

Full Height

Alert