Dialog
pnpm dlx shadcn@latest add …/r/dialog.jsonAn accessible React dialog for Next.js and Tailwind CSS: a modal window that grows out of its trigger, keeps focus inside and returns it, built on Radix Dialog.
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
export function InviteDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Invite people</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite people</DialogTitle>
<DialogDescription>They'll get an email with a link to join.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button>Send invites</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useId, useState } from "react";
/*
* A form in a dialog. Saving (the button, or Enter in a field) closes it, so it's controlled; Cancel, the
* close button, Escape and the backdrop close it too.
*/
export default function DialogEditProfile() {
const id = useId();
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">Edit profile</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<form
className="grid gap-6"
onSubmit={(event) => {
event.preventDefault();
setOpen(false);
}}
>
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>Change how others see you. Save when you're done.</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-2">
<label htmlFor={`${id}-name`} className="text-sm font-medium">
Name
</label>
<Input id={`${id}-name`} defaultValue="Maya Chen" autoComplete="name" />
</div>
<div className="grid gap-2">
<label htmlFor={`${id}-username`} className="text-sm font-medium">
Username
</label>
<Input id={`${id}-username`} defaultValue="maya" autoComplete="username" />
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit">Save changes</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}Installation
Install the dependencies.
$ pnpm add lucide-react motion radix-uiAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Add Button first.
Copy the source into your project.
"use client"; import { Button } from "@/components/ui/button"; import { blur, duration, ease, spring } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { XIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Dialog as DialogPrimitive } from "radix-ui"; import { type ComponentProps, createContext, type Ref, type RefObject, useCallback, useContext, useMemo, useRef, useState, } from "react"; /* * Dialog, built on Radix Dialog: focus moves into the window and stays there, Escape and the backdrop close * it, focus goes back to the trigger, and the page behind is hidden from screen readers and doesn't scroll. * - The root keeps the open state, controlled (`open`) or not (`defaultOpen`), so the window can play its * exit: the portal stays mounted (forceMount) inside AnimatePresence until the exit ends. * - Motion (.agents/rules/motion.md): the window comes through the blur, scaling up from 96% on * spring.snappy, and leaves on a timed duration.base + ease.in. The backdrop only fades. With reduced * motion, both only fade. * - It grows out of what opened it: the scale's origin is the trigger's centre, so the window seems to come * from that button and shrinks back into it as it closes. * - While it leaves, clicks go through to the page, so the page is usable again at once. Focus then goes * back to the trigger, unless the person has already focused something else there. * - Alert Dialog shares the window, its motion and the open state from this file (bottom). */ /** What every part needs: the open state, and the trigger the window grows from */ export type DialogState = { open: boolean; trigger: RefObject<HTMLElement | null> }; const DialogContext = createContext<DialogState | null>(null); function useDialog(part: string) { const dialog = useContext(DialogContext); if (!dialog) throw new Error(`${part} must be used inside Dialog`); return dialog; } /** The open state, controlled (`open`) or not (`defaultOpen`), and a ref for the trigger */ function useDialogState({ open, defaultOpen, onOpenChange, }: { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; }) { const [own, setOwn] = useState(defaultOpen ?? false); const trigger = useRef<HTMLElement | null>(null); const current = open ?? own; const setOpen = useCallback( (next: boolean) => { setOwn(next); onOpenChange?.(next); }, [onOpenChange], ); const state = useMemo<DialogState>(() => ({ open: current, trigger }), [current]); return { state, setOpen }; } /** Hands a node to our ref and to the caller's */ function useMergedRef<T>(own: RefObject<T | null>, theirs?: Ref<T>) { return useCallback( (node: T | null) => { own.current = node; if (typeof theirs === "function") theirs(node); else if (theirs) theirs.current = node; }, [own, theirs], ); } /** Leaving is timed rather than a spring, so it's over quickly */ const leave = { duration: duration.base, ease: ease.in }; /** The backdrop only fades: a full-screen blur would cost too much (ADR 0011) */ const dialogOverlayMotion = { initial: { opacity: 0 }, animate: { opacity: 1, transition: { duration: duration.base, ease: ease.out } }, exit: { opacity: 0, transition: leave }, }; /** The look shared with Alert Dialog */ const dialogStyles = { overlay: "fixed inset-0 z-(--z-overlay) bg-overlay", // Centres the window and keeps it 16px inside the screen; clicks pass through it to the backdrop positioner: "pointer-events-none fixed inset-0 z-(--z-modal) flex items-center justify-center p-4", window: [ "pointer-events-auto relative grid max-h-full w-full max-w-lg gap-4 overflow-y-auto rounded-lg border border-border bg-background p-6 text-foreground shadow-lg outline-hidden", // Leaving, it no longer catches clicks "data-[state=closed]:pointer-events-none", ], header: "grid gap-1.5", footer: "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", title: "text-base font-semibold", description: "text-sm text-muted-foreground", }; /** * For Radix's onCloseAutoFocus, which runs once the window is gone: focus goes back to the trigger, unless * the person has already moved on. Clicks reach the page while the window leaves, so they may have focused * something there; otherwise the focused element went with the window and focus is on the body. */ function keepMovedFocus(event: Event) { if (document.activeElement !== document.body) event.preventDefault(); } /** * The window's motion, as props for a motion.div (shared with Alert Dialog). It comes through the blur, * scaling up from 96% on spring.snappy, and leaves on a timed duration.base + ease.in, growing from the * centre of what opened it. */ function useDialogWindow(trigger: RefObject<HTMLElement | null>) { const reduce = useReducedMotion() ?? false; const hidden = { opacity: 0, filter: reduce ? blur.shown : blur.hidden, scale: reduce ? 1 : 0.96 }; // Measured once, as the window mounts and before Radix moves focus into it. What opened it is the focused // element (a key press, or a click in most browsers), else the trigger (Safari doesn't focus buttons on // click). Opened from code with nothing focused, it grows from its own centre. const ref = useCallback( (node: HTMLElement | null) => { if (!node) return; const focused = document.activeElement; const from = focused instanceof HTMLElement && focused !== document.body && !node.contains(focused) ? focused : trigger.current; const frame = node.offsetParent?.getBoundingClientRect(); const box = from?.getBoundingClientRect(); if (!frame || !box || (box.width === 0 && box.height === 0)) return; // The origin is in the window's own box before any transform: its offset in the positioner, not its // scaled on-screen rect const x = box.left + box.width / 2 - frame.left - node.offsetLeft; const y = box.top + box.height / 2 - frame.top - node.offsetTop; node.style.transformOrigin = `${x}px ${y}px`; }, [trigger], ); return { ref, initial: hidden, animate: { opacity: 1, filter: blur.shown, scale: 1 }, exit: { ...hidden, transition: leave }, // The fade and the blur are eased; the scale springs transition: { duration: duration.base, ease: ease.out, scale: spring.snappy }, }; } function Dialog({ open, defaultOpen, onOpenChange, ...props }: ComponentProps<typeof DialogPrimitive.Root>) { const { state, setOpen } = useDialogState({ open, defaultOpen, onOpenChange }); return ( <DialogContext.Provider value={state}> <DialogPrimitive.Root data-slot="dialog" open={state.open} onOpenChange={setOpen} {...props} /> </DialogContext.Provider> ); } function DialogTrigger({ ref, ...props }: ComponentProps<typeof DialogPrimitive.Trigger>) { const dialog = useDialog("DialogTrigger"); const setRef = useMergedRef(dialog.trigger, ref); return <DialogPrimitive.Trigger data-slot="dialog-trigger" ref={setRef} {...props} />; } /** Stays mounted while the dialog is open and while its exit plays */ function DialogPortal({ children, ...props }: Omit<ComponentProps<typeof DialogPrimitive.Portal>, "forceMount">) { const { open } = useDialog("DialogPortal"); return ( // initial={false}: a dialog that starts open is simply there, with no entrance <AnimatePresence initial={false}> {open ? ( <DialogPrimitive.Portal data-slot="dialog-portal" {...props} forceMount> {children} </DialogPrimitive.Portal> ) : null} </AnimatePresence> ); } function DialogOverlay({ className, style, ...props }: ComponentProps<typeof DialogPrimitive.Overlay>) { const { open } = useDialog("DialogOverlay"); return ( <DialogPrimitive.Overlay data-slot="dialog-overlay" className={cn(dialogStyles.overlay, className)} // Radix keeps the backdrop clickable; while it fades out, clicks go through to the page style={{ pointerEvents: open ? "auto" : "none", ...style }} {...props} asChild forceMount > <motion.div {...dialogOverlayMotion} /> </DialogPrimitive.Overlay> ); } export type DialogContentProps = ComponentProps<typeof DialogPrimitive.Content> & { /** Show a close button in the top corner */ showCloseButton?: boolean; }; function DialogContent({ className, children, showCloseButton = true, onCloseAutoFocus, ...props }: DialogContentProps) { const dialog = useDialog("DialogContent"); const windowMotion = useDialogWindow(dialog.trigger); return ( <DialogPortal> <DialogOverlay /> <div data-slot="dialog-positioner" className={dialogStyles.positioner}> <DialogPrimitive.Content data-slot="dialog-content" className={cn( dialogStyles.window, // The header stops short of the close button, so a long title never runs under it showCloseButton && "**:data-[slot=dialog-header]:pe-8", className, )} onCloseAutoFocus={(event) => { onCloseAutoFocus?.(event); keepMovedFocus(event); }} {...props} asChild forceMount > <motion.div {...windowMotion}> {children} {showCloseButton ? ( <DialogPrimitive.Close asChild> {/* Centred on the title's line, its icon lined up with the window's padding */} <Button data-slot="dialog-close" variant="ghost" size="icon-sm" aria-label="Close" className="absolute end-4 top-5 text-muted-foreground hover:text-foreground" > <XIcon /> </Button> </DialogPrimitive.Close> ) : null} </motion.div> </DialogPrimitive.Content> </div> </DialogPortal> ); } function DialogHeader({ className, ...props }: ComponentProps<"div">) { return <div data-slot="dialog-header" className={cn(dialogStyles.header, className)} {...props} />; } /** Actions, stacked on phones with the main one on top, in a row at the end from the sm breakpoint */ function DialogFooter({ className, ...props }: ComponentProps<"div">) { return <div data-slot="dialog-footer" className={cn(dialogStyles.footer, className)} {...props} />; } function DialogTitle({ className, ...props }: ComponentProps<typeof DialogPrimitive.Title>) { return ( <DialogPrimitive.Title data-slot="dialog-title" className={cn(dialogStyles.title, className)} {...props} /> ); } function DialogDescription({ className, ...props }: ComponentProps<typeof DialogPrimitive.Description>) { return ( <DialogPrimitive.Description data-slot="dialog-description" className={cn(dialogStyles.description, className)} {...props} /> ); } function DialogClose(props: ComponentProps<typeof DialogPrimitive.Close>) { return <DialogPrimitive.Close data-slot="dialog-close" {...props} />; } export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, // Shared with Alert Dialog, so the two look and move the same dialogOverlayMotion, dialogStyles, keepMovedFocus, useDialogState, useDialogWindow, useMergedRef, };Update the import paths to match your project.
Examples
Long content
Long content scrolls between a header and a footer that stay in place. The window never grows past the screen; it keeps 16px clear on every side.
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
const terms = [
{
title: "Your account",
body: "You're responsible for what happens under your account. Keep your password safe and tell us if someone else gets in.",
},
{
title: "Your content",
body: "What you make stays yours. You give us permission to store and show it only so the service can work.",
},
{
title: "Fair use",
body: "Don't overload the service, try to break it, or use it to send spam. We may slow down or pause accounts that do.",
},
{
title: "Payments",
body: "Paid plans renew each month until you cancel. Cancelling stops the next charge; the current month stays paid.",
},
{
title: "Privacy",
body: "We collect only what the service needs, and never sell it. The privacy policy lists what we keep and for how long.",
},
{
title: "Changes",
body: "If these terms change in a way that matters, we'll tell you by email at least 30 days before they apply.",
},
{
title: "Ending",
body: "You can close your account at any time. We'll delete your content within 30 days, unless the law says we must keep it.",
},
];
/*
* Long content scrolls inside the dialog, between a header and a footer that stay in place. The scrolling
* area takes focus, so the arrow keys scroll it.
*/
export default function DialogScrolling() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Read the terms</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Terms of service</DialogTitle>
<DialogDescription>Please read these before you continue.</DialogDescription>
</DialogHeader>
<section
// biome-ignore lint/a11y/noNoninteractiveTabindex: a scrolling area must be reachable from the keyboard
tabIndex={0}
aria-label="Terms"
className="grid max-h-72 gap-4 overflow-y-auto rounded-md text-sm outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)"
>
{terms.map((term, index) => (
<div key={term.title} className="grid gap-1">
<h3 className="font-medium">
{index + 1}. {term.title}
</h3>
<p className="text-muted-foreground">{term.body}</p>
</div>
))}
</section>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Decline</Button>
</DialogClose>
<DialogClose asChild>
<Button>Accept</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}API reference
Dialog
The root. It keeps the open state, so the window can play its exit before it unmounts.
| Prop | Type | Default | Description |
|---|---|---|---|
open? | boolean | – | Whether it's open, when you control it. |
defaultOpen? | boolean | false | Whether it starts open. |
onOpenChange? | (open: boolean) => void | – | Called when it opens or closes: the trigger, a close button, Escape or the backdrop. |
modal? | boolean | true | Modal blocks the page behind, hides it from screen readers and keeps focus inside. Set false to let people use the page while it's open (there's no backdrop then). |
DialogTrigger
The button that opens it. Focus comes back here when it closes.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild? | boolean | false | Use your own element, such as a Button, as the trigger. |
DialogContent
The window, with its backdrop, in a portal. It comes through the blur, scaling up from the trigger's centre, and leaves quickly; with reduced motion it only fades. Long content scrolls inside it. Give it a DialogTitle, which names it. Also accepts every Radix Dialog Content prop.
| Prop | Type | Default | Description |
|---|---|---|---|
showCloseButton? | boolean | true | Shows a close button in the top corner, named Close. |
onOpenAutoFocus? | (event: Event) => void | – | Runs as focus moves in (to the first focusable element). Call event.preventDefault() to focus something else yourself. |
onCloseAutoFocus? | (event: Event) => void | – | Runs as focus goes back to the trigger. Call event.preventDefault() to send it elsewhere. |
onEscapeKeyDown? | (event: KeyboardEvent) => void | – | Runs when Escape is pressed. Call event.preventDefault() to keep it open. |
onInteractOutside? | (event: Event) => void | – | Runs on a click or focus outside the window. Call event.preventDefault() to keep it open. |
DialogHeader
Holds the title and description. It leaves room for the close button.
| Prop | Type | Default | Description |
|---|
DialogFooter
Holds the actions: stacked on phones with the last one on top, in a row at the end from the sm breakpoint. Put the main action last.
| Prop | Type | Default | Description |
|---|
DialogTitle
The heading, and the dialog's accessible name.
| Prop | Type | Default | Description |
|---|
DialogDescription
A line under the title, read out after the name.
| Prop | Type | Default | Description |
|---|
DialogClose
Closes the dialog. Use asChild to make your own Button close it, such as Cancel.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild? | boolean | false | Use your own element as the close button. |
DialogPortal
Renders its children into document.body while the dialog is open, and while it leaves. DialogContent already uses it.
| Prop | Type | Default | Description |
|---|---|---|---|
container? | HTMLElement | document.body | Where to render instead. |
DialogOverlay
The backdrop that dims the page. It only fades. DialogContent already includes it.
| Prop | Type | Default | Description |
|---|
Related components
An accessible React alert dialog for Next.js that asks people to confirm destructive actions, with focus starting on Cancel, built on Radix Alert Dialog.
An accessible React sheet for Next.js: a side panel that slides in from any edge of the screen, for forms, filters and navigation, built on Radix Dialog.
- Right-click here
An accessible React context menu for Next.js: a right-click menu with a gliding highlight, shortcuts, checkbox and radio items and sub-menus, built on Radix.