Components

Getting started

Components144

More

A decision that needs the whole screen: the page fades back under a veil of its own colour and the panel rises out of a blur.

Used forDestructive confirmationsShort formsTerms and detailsUpgrade prompts

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/dialog.json

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • Destructive confirmations

    Delete a project, with the reason and a red action.

  • Short forms

    Invite a teammate or rename a workspace.

  • Terms and details

    Long content that scrolls inside the panel.

  • Upgrade prompts

    A plan choice before a paid action.

What it handles for you

Scroll locks that set overflow on <html> reflow the page and jump the scrollbar. This stops wheel and touch scrolling at the dialog, so nothing behind it moves.

Instead of wiring upRadix Dialogfocus-trapscroll lockportal
  1. Native dialog

    showModal(): top layer, inert page, focus trap, Escape.

  2. No layout shift

    The page's own styles are never touched.

  3. Focus returns

    To whatever opened it, after the exit.

  4. Bottom on phones

    It sits at the bottom of small screens, within thumb reach.

Overview

Most dialogs drop a black overlay over the page and lock scroll by restyling <html>, which shifts the layout. Here the page fades back under a veil of its own background with no blur, the panel rises out of a 4px blur, and scrolling is stopped at the dialog instead. It is the native <dialog>, so focus is trapped and the page is inert without extra code.

States

open
Veil fades in (260ms); the panel rises 8px from 0.96 out of a 4px blur (420ms).
close
Escape, ✕ or the veil; it sinks back (220ms) and focus returns.
long content
The body scrolls inside the panel; the page stays still.

Usage

usage.tsx
import { Dialog } from "@/components/ui/dialog";<Dialog  trigger={<button>Delete project</button>}  title="Delete Northwind?"  description="Its 14 pages and every deploy go with it."  footer={({ close }) => <><button onClick={close}>Cancel</button><button onClick={remove}>Delete</button></>}/>

Props

PropTypeDescription
open / defaultOpen / onOpenChangeboolean / (open) => voidState.
triggerReactElementA button that opens it.
title / descriptionReactNodeHeading and the line under it (aria-labelledby / describedby).
children / footerReactNode | ({ close }) => ReactNodeBody and buttons.
size"sm" | "md" | "lg"default "md"400, 520 or 680px wide.
dismissiblebooleandefault trueA press on the veil closes it.

Accessibility and motion

  • Built on the native <dialog> (modal.ts, shared with the other modal surface): focus stays inside, the page is inert, Escape works, and it sits in the top layer.
  • Reduced motion: it fades.
  • Buttons stack full-width on phones, primary last.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs ui-interaction-motion.

Source

components/dialog.tsx
"use client";import * as React from "react";import { MODAL_ROOT, SCRIM, useModal } from "./modal";import { EASE_OUT, INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * DIALOG: a decision that needs the whole screen * *   open     the page fades back under a veil of its own *            background (no blur); the panel rises 8px, out of a *            4px blur, from 0.96 *   close    Escape, the ✕ or a press on the veil; it sinks back *            the way it came and focus returns to what opened it *   long     the body scrolls inside the panel; the page behind *            never moves * * The native <dialog>: focus stays inside, the page is inert, * and it sits above everything without z-index games. * ───────────────────────────────────────────────────────── */export interface DialogProps {  open?: boolean;  defaultOpen?: boolean;  onOpenChange?: (open: boolean) => void;  /** A button that opens it. */  trigger?: React.ReactElement<React.HTMLAttributes<HTMLElement>>;  title: React.ReactNode;  description?: React.ReactNode;  /** The body, or a function that gets `close`. */  children?: React.ReactNode | ((api: { close: () => void }) => React.ReactNode);  /** Buttons along the bottom, or a function that gets `close`. */  footer?: React.ReactNode | ((api: { close: () => void }) => React.ReactNode);  /** 400, 520 or 680px wide. */  size?: "sm" | "md" | "lg";  /** Press on the veil closes it. Turn off for forms that would lose work. */  dismissible?: boolean;  className?: string;}const WIDTH = { sm: 400, md: 520, lg: 680 };export function Dialog({  open: controlled,  defaultOpen = false,  onOpenChange,  trigger,  title,  description,  children,  footer,  size = "md",  dismissible = true,  className = "",}: DialogProps) {  const reduced = useInteractionReducedMotion();  const [own, setOwn] = React.useState(defaultOpen);  const open = controlled ?? own;  const setOpen = (next: boolean) => {    if (controlled === undefined) setOwn(next);    onOpenChange?.(next);  };  const close = () => setOpen(false);  const { dialog, mounted, shown } = useModal(open, close, 220);  const titleId = React.useId();  const descId = React.useId();  const ease = EASE_OUT;  return (    <>      {trigger &&        React.cloneElement(trigger, {          "aria-haspopup": "dialog",          onClick: (e: React.MouseEvent<HTMLElement>) => {            trigger.props.onClick?.(e);            setOpen(true);          },        })}      {mounted && (        <dialog ref={dialog} aria-labelledby={titleId} aria-describedby={description ? descId : undefined} className={MODAL_ROOT}>          <div            className={SCRIM}            onClick={() => dismissible && close()}            style={{ opacity: shown ? 1 : 0, transition: `opacity ${shown ? 260 : 200}ms ${ease}` }}          />          <div className="pointer-events-none absolute inset-0 flex items-end justify-center p-3 sm:items-center sm:p-6">            <div              className={`pointer-events-auto relative flex max-h-[min(85dvh,760px)] w-full flex-col rounded-[22px] bg-background shadow-[0_0_0_1px_var(--border)] ${className}`}              style={{                maxWidth: WIDTH[size],                opacity: shown ? 1 : 0,                translate: shown || reduced ? "0 0" : "0 8px",                scale: shown || reduced ? "1" : "0.96",                filter: shown || reduced ? "blur(0px)" : "blur(4px)",                transition: reduced                  ? "opacity 160ms ease-out"                  : shown                    ? `opacity 240ms ${ease}, translate 420ms ${ease}, scale 420ms ${ease}, filter 240ms ${ease}`                    : `opacity 180ms ${ease}, translate 220ms ${ease}, scale 220ms ${ease}, filter 180ms ${ease}`,              }}            >              <div className="flex items-start gap-4 px-6 pb-2 pt-5">                <div className="flex min-w-0 flex-1 flex-col gap-1">                  <h2 id={titleId} className="text-[16px] font-semibold tracking-[-0.01em] text-foreground">                    {title}                  </h2>                  {description && (                    <p id={descId} className="text-[13.5px] leading-relaxed text-muted-foreground">                      {description}                    </p>                  )}                </div>                <button                  type="button"                  aria-label="Close"                  onClick={close}                  className={`-mr-2 flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground cursor-pointer hover:bg-accent hover:text-foreground ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}                >                  <svg viewBox="0 0 16 16" fill="none" className="size-3.5">                    <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />                  </svg>                </button>              </div>              {children !== undefined && (                <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-2 text-[13.5px] leading-relaxed text-foreground">                  {typeof children === "function" ? children({ close }) : children}                </div>              )}              {footer !== undefined && (                <div className="flex flex-col-reverse gap-2 px-6 pb-5 pt-4 sm:flex-row sm:justify-end">{typeof footer === "function" ? footer({ close }) : footer}</div>              )}              {footer === undefined && <div className="h-4" />}            </div>          </div>        </dialog>      )}    </>  );}export default Dialog;
components/modal.ts
"use client";import * as React from "react";/* ───────────────────────────────────────────────────────── * MODAL: the native <dialog> under Dialog and Sheet * *   open     showModal() (focus trap, inert page, top layer) and *            the next frame turns `shown` on for the entry motion *   close    Escape and the scrim ask to close; the surface plays *            its exit, then dialog.close() and focus goes back to *            whatever opened it *   scroll   wheel and touch scrolling outside a scrollable part *            of the panel are stopped at the dialog, so the page *            behind never moves (the page's own styles are never *            touched: no layout shift, no restyle of <html>) * ───────────────────────────────────────────────────────── */export function useModal(open: boolean, onClose: () => void, exit = 220) {  const dialog = React.useRef<HTMLDialogElement>(null);  const [mounted, setMounted] = React.useState(open);  const [shown, setShown] = React.useState(false);  const opener = React.useRef<HTMLElement | null>(null);  const close = React.useRef(onClose);  React.useEffect(() => {    close.current = onClose;  });  if (open && !mounted) setMounted(true);  React.useEffect(() => {    const el = dialog.current;    if (!el) return;    if (open) {      opener.current = document.activeElement as HTMLElement | null;      if (!el.open) el.showModal();      let inner = 0;      const outer = requestAnimationFrame(() => {        inner = requestAnimationFrame(() => setShown(true));      });      return () => {        cancelAnimationFrame(outer);        cancelAnimationFrame(inner);      };    }    const timer = window.setTimeout(() => {      if (el.open) el.close();      setMounted(false);      setShown(false);      opener.current?.focus?.({ preventScroll: true });    }, exit);    return () => window.clearTimeout(timer);  }, [open, mounted, exit]);  React.useEffect(() => {    const el = dialog.current;    if (!el || !mounted) return;    const onCancel = (e: Event) => {      e.preventDefault();      close.current();    };    // Stop scrolling from leaking to the page unless it happens inside something that scrolls.    const guard = (e: Event) => {      let node = e.target as HTMLElement | null;      while (node && node !== el) {        const style = getComputedStyle(node);        if (/(auto|scroll)/.test(style.overflowY) && node.scrollHeight > node.clientHeight) return;        node = node.parentElement;      }      e.preventDefault();    };    el.addEventListener("cancel", onCancel);    el.addEventListener("wheel", guard, { passive: false });    el.addEventListener("touchmove", guard, { passive: false });    return () => {      el.removeEventListener("cancel", onCancel);      el.removeEventListener("wheel", guard);      el.removeEventListener("touchmove", guard);    };  }, [mounted]);  return { dialog, mounted: mounted || open, shown: open && shown };}/** Class for the full-window <dialog> both surfaces sit in. */export const MODAL_ROOT =  "fixed inset-0 m-0 h-dvh max-h-none w-screen max-w-none overflow-hidden bg-transparent p-0 text-foreground outline-none backdrop:bg-transparent";/** The dim behind the surface: the page fades back under a veil of the background colour, no blur. */export const SCRIM = "absolute inset-0 bg-[color-mix(in_oklab,var(--background)_62%,transparent)]";

Registry item: /r/dialog.json