Motion Primitives
uidialogsource: ok · hit
Dialog
A modal dialog component with smooth entrance and exit animations.
npx shadcn add "https://motion-primitives.com/c/dialog.json"free
Interaction
modal
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Files2 · source cached 10/15/2026, 6:24:18 AM
dialog.tsx
'use client';
import { AnimatePresence, motion, Transition, Variants } from 'motion/react';
import React, { createContext, useContext, useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
import { useId } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { usePreventScroll } from '@/hooks/usePreventScroll';
const DialogContext = createContext<{
isOpen: boolean;
setIsOpen: (open: boolean) => void;
dialogRef: React.RefObject<HTMLDialogElement | null>;
variants: Variants;
transition?: Transition;
ids: {
dialog: string;
title: string;
description: string;
};
onAnimationComplete: (definition: string) => void;
handleTrigger: () => void;
} | null>(null);
const defaultVariants: Variants = {
initial: {
opacity: 0,
scale: 0.9,
},
animate: {
opacity: 1,
scale: 1,
},
};
const defaultTransition: Transition = {
ease: 'easeOut',
duration: 0.2,
};
export type DialogProps = {
children: React.ReactNode;
variants?: Variants;
transition?: Transition;
className?: string;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
open?: boolean;
};
function Dialog({
children,
variants = defaultVariants,
transition = defaultTransition,
defaultOpen,
onOpenChange,
open,
}: DialogProps) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
defaultOpen || false
);
const dialogRef = useRef<HTMLDialogElement>(null);
const isOpen = open !== undefined ? open : uncontrolledOpen;
// prevent scroll when dialog is open on iOS
usePreventScroll({
isDisabled: !isOpen,
});
const setIsOpen = React.useCallback(
(value: boolean) => {
setUncontrolledOpen(value);
onOpenChange?.(value);
},
[onOpenChange]
);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) {
document.body.classList.add('overflow-hidden');
} else {
document.body.classList.remove('overflow-hidden');
}
const handleCancel = (e: Event) => {
e.preventDefault();
if (isOpen) {
setIsOpen(false);
}
};
dialog.addEventListener('cancel', handleCancel);
return () => {
dialog.removeEventListener('cancel', handleCancel);
document.body.classList.remove('overflow-hidden');
};
}, [dialogRef, isOpen, setIsOpen]);
useEffect(() => {
if (isOpen && dialogRef.current) {
dialogRef.current.showModal();
}
}, [isOpen]);
const handleTrigger = () => {
setIsOpen(true);
};
const onAnimationComplete = (definition: string) => {
if (definition === 'exit' && !isOpen) {
dialogRef.current?.close();
}
};
const baseId = useId();
const ids = {
dialog: `motion-ui-dialog-${baseId}`,
title: `motion-ui-dialog-title-${baseId}`,
description: `motion-ui-dialog-description-${baseId}`,
};
return (
<DialogContext.Provider
value={{
isOpen,
setIsOpen,
dialogRef,
variants,
transition,
ids,
onAnimationComplete,
handleTrigger,
}}
>
{children}
</DialogContext.Provider>
);
}
export type DialogTriggerProps = {
children: React.ReactNode;
className?: string;
};
function DialogTrigger({ children, className }: DialogTriggerProps) {
const context = useContext(DialogContext);
if (!context) throw new Error('DialogTrigger must be used within Dialog');
return (
<button
onClick={context.handleTrigger}
className={cn(
'inline-flex items-center justify-center rounded-md text-sm font-medium',
'transition-colors focus-visible:ring-2 focus-visible:outline-hidden',
'focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
className
)}
>
{children}
</button>
);
}
export type DialogPortalProps = {
children: React.ReactNode;
container?: HTMLElement | null;
};
function DialogPortal({
children,
container = typeof window !== 'undefined' ? document.body : null,
}: DialogPortalProps) {
const [mounted, setMounted] = React.useState(false);
const [portalContainer, setPortalContainer] =
React.useState<HTMLElement | null>(null);
useEffect(() => {
setMounted(true);
setPortalContainer(container || document.body);
return () => setMounted(false);
}, [container]);
if (!mounted || !portalContainer) {
return null;
}
return createPortal(children, portalContainer);
}
export type DialogContentProps = {
children: React.ReactNode;
className?: string;
container?: HTMLElement;
};
function DialogContent({ children, className, container }: DialogContentProps) {
const context = useContext(DialogContext);
if (!context) throw new Error('DialogContent must be used within Dialog');
const {
isOpen,
setIsOpen,
dialogRef,
variants,
transition,
ids,
onAnimationComplete,
} = context;
const content = (
<AnimatePresence mode='wait'>
{isOpen && (
<motion.dialog
key={ids.dialog}
ref={dialogRef as React.RefObject<HTMLDialogElement>}
id={ids.dialog}
aria-labelledby={ids.title}
aria-describedby={ids.description}
aria-modal='true'
role='dialog'
onClick={(e) => {
if (e.target === dialogRef.current) {
setIsOpen(false);
}
}}
initial='initial'
animate='animate'
exit='exit'
variants={variants}
transition={transition}
onAnimationComplete={onAnimationComplete}
className={cn(
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform rounded-lg border border-zinc-200 p-0 shadow-lg dark:border dark:border-zinc-700',
'backdrop:bg-black/50 backdrop:backdrop-blur-xs',
'open:flex open:flex-col',
className
)}
>
<div className='w-full'>{children}</div>
</motion.dialog>
)}
</AnimatePresence>
);
return <DialogPortal container={container}>{content}</DialogPortal>;
}
export type DialogHeaderProps = {
children: React.ReactNode;
className?: string;
};
function DialogHeader({ children, className }: DialogHeaderProps) {
return (
<div className={cn('flex flex-col space-y-1.5', className)}>{children}</div>
);
}
export type DialogTitleProps = {
children: React.ReactNode;
className?: string;
};
function DialogTitle({ children, className }: DialogTitleProps) {
const context = useContext(DialogContext);
if (!context) throw new Error('DialogTitle must be used within Dialog');
return (
<h2
id={context.ids.title}
className={cn('text-base font-medium', className)}
>
{children}
</h2>
);
}
export type DialogDescriptionProps = {
children: React.ReactNode;
className?: string;
};
function DialogDescription({ children, className }: DialogDescriptionProps) {
const context = useContext(DialogContext);
if (!context) throw new Error('DialogDescription must be used within Dialog');
return (
<p
id={context.ids.description}
className={cn('text-base text-zinc-500', className)}
>
{children}
</p>
);
}
export type DialogCloseProps = {
className?: string;
children?: React.ReactNode;
disabled?: boolean;
};
function DialogClose({ className, children, disabled }: DialogCloseProps) {
const context = useContext(DialogContext);
if (!context) throw new Error('DialogClose must be used within Dialog');
return (
<button
onClick={() => context.setIsOpen(false)}
type='button'
aria-label='Close dialog'
className={cn(
'absolute top-4 right-4 rounded-xs opacity-70 transition-opacity',
'hover:opacity-100 focus:ring-2 focus:outline-hidden',
'focus:ring-zinc-500 focus:ring-offset-2 disabled:pointer-events-none',
className
)}
disabled={disabled}
>
{children || <X className='h-4 w-4' />}
<span className='sr-only'>Close</span>
</button>
);
}
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogClose,
};
hooks/usePreventScroll.tsx
// This code comes from https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/overlays/src/usePreventScroll.ts
import { useEffect, useLayoutEffect } from 'react';
function isMac(): boolean | undefined {
return testPlatform(/^Mac/);
}
function isIPhone(): boolean | undefined {
return testPlatform(/^iPhone/);
}
function isIPad(): boolean | undefined {
return (
testPlatform(/^iPad/) ||
// iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support.
(isMac() && navigator.maxTouchPoints > 1)
);
}
function isIOS(): boolean | undefined {
return isIPhone() || isIPad();
}
function testPlatform(re: RegExp): boolean | undefined {
return typeof window !== 'undefined' && window.navigator != null
? re.test(window.navigator.platform)
: undefined;
}
const KEYBOARD_BUFFER = 24;
export const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
interface PreventScrollOptions {
/** Whether the scroll lock is disabled. */
isDisabled?: boolean;
focusCallback?: () => void;
}
function chain(...callbacks: any[]): (...args: any[]) => void {
return (...args: any[]) => {
for (let callback of callbacks) {
if (typeof callback === 'function') {
callback(...args);
}
}
};
}
// @ts-ignore
const visualViewport = typeof document !== 'undefined' && window.visualViewport;
export function isScrollable(node: Element): boolean {
let style = window.getComputedStyle(node);
return /(auto|scroll)/.test(
style.overflow + style.overflowX + style.overflowY
);
}
export function getScrollParent(node: Element): Element {
if (isScrollable(node)) {
node = node.parentElement as HTMLElement;
}
while (node && !isScrollable(node)) {
node = node.parentElement as HTMLElement;
}
return node || document.scrollingElement || document.documentElement;
}
// HTML input types that do not cause the software keyboard to appear.
const nonTextInputTypes = new Set([
'checkbox',
'radio',
'range',
'color',
'file',
'image',
'button',
'submit',
'reset',
]);
// The number of active usePreventScroll calls. Used to determine whether to revert back to the original page style/scroll position
let preventScrollCount = 0;
let restore: () => void;
/**
* Prevents scrolling on the document body on mount, and
* restores it on unmount. Also ensures that content does not
* shift due to the scrollbars disappearing.
*/
export function usePreventScroll(options: PreventScrollOptions = {}) {
let { isDisabled } = options;
useIsomorphicLayoutEffect(() => {
if (isDisabled) {
return;
}
preventScrollCount++;
if (preventScrollCount === 1) {
if (isIOS()) {
restore = preventScrollMobileSafari();
}
}
return () => {
preventScrollCount--;
if (preventScrollCount === 0) {
restore?.();
}
};
}, [isDisabled]);
}
// Mobile Safari is a whole different beast. Even with overflow: hidden,
// it still scrolls the page in many situations:
//
// 1. When the bottom toolbar and address bar are collapsed, page scrolling is always allowed.
// 2. When the keyboard is visible, the viewport does not resize. Instead, the keyboard covers part of
// it, so it becomes scrollable.
// 3. When tapping on an input, the page always scrolls so that the input is centered in the visual viewport.
// This may cause even fixed position elements to scroll off the screen.
// 4. When using the next/previous buttons in the keyboard to navigate between inputs, the whole page always
// scrolls, even if the input is inside a nested scrollable element that could be scrolled instead.
//
// In order to work around these cases, and prevent scrolling without jankiness, we do a few things:
//
// 1. Prevent default on `touchmove` events that are not in a scrollable element. This prevents touch scrolling
// on the window.
// 2. Prevent default on `touchmove` events inside a scrollable element when the scroll position is at the
// top or bottom. This avoids the whole page scrolling instead, but does prevent overscrolling.
// 3. Prevent default on `touchend` events on input elements and handle focusing the element ourselves.
// 4. When focusing an input, apply a transform to trick Safari into thinking the input is at the top
// of the page, which prevents it from scrolling the page. After the input is focused, scroll the element
// into view ourselves, without scrolling the whole page.
// 5. Offset the body by the scroll position using a negative margin and scroll to the top. This should appear the
// same visually, but makes the actual scroll position always zero. This is required to make all of the
// above work or Safari will still try to scroll the page when focusing an input.
// 6. As a last resort, handle window scroll events, and scroll back to the top. This can happen when attempting
// to navigate to an input with the next/previous buttons that's outside a modal.
function preventScrollMobileSafari() {
let scrollable: Element;
let lastY = 0;
let onTouchStart = (e: TouchEvent) => {
// Store the nearest scrollable parent element from the element that the user touched.
scrollable = getScrollParent(e.target as Element);
if (
scrollable === document.documentElement &&
scrollable === document.body
) {
return;
}
lastY = e.changedTouches[0].pageY;
};
let onTouchMove = (e: TouchEvent) => {
// Prevent scrolling the window.
if (
!scrollable ||
scrollable === document.documentElement ||
scrollable === document.body
) {
e.preventDefault();
return;
}
// Prevent scrolling up when at the top and scrolling down when at the bottom
// of a nested scrollable area, otherwise mobile Safari will start scrolling
// the window instead. Unfortunately, this disables bounce scrolling when at
// the top but it's the best we can do.
let y = e.changedTouches[0].pageY;
let scrollTop = scrollable.scrollTop;
let bottom = scrollable.scrollHeight - scrollable.clientHeight;
if (bottom === 0) {
return;
}
if ((scrollTop <= 0 && y > lastY) || (scrollTop >= bottom && y < lastY)) {
e.preventDefault();
}
lastY = y;
};
let onTouchEnd = (e: TouchEvent) => {
let target = e.target as HTMLElement;
// Apply this change if we're not already focused on the target element
if (isInput(target) && target !== document.activeElement) {
e.preventDefault();
// Apply a transform to trick Safari into thinking the input is at the top of the page
// so it doesn't try to scroll it into view. When tapping on an input, this needs to
// be done before the "focus" event, so we have to focus the element ourselves.
target.style.transform = 'translateY(-2000px)';
target.focus();
requestAnimationFrame(() => {
target.style.transform = '';
});
}
};
let onFocus = (e: FocusEvent) => {
let target = e.target as HTMLElement;
if (isInput(target)) {
// Transform also needs to be applied in the focus event in cases where focus moves
// other than tapping on an input directly, e.g. the next/previous buttons in the
// software keyboard. In these cases, it seems applying the transform in the focus event
// is good enough, whereas when tapping an input, it must be done before the focus event. 🤷♂️
target.style.transform = 'translateY(-2000px)';
requestAnimationFrame(() => {
target.style.transform = '';
// This will have prevented the browser from scrolling the focused element into view,
// so we need to do this ourselves in a way that doesn't cause the whole page to scroll.
if (visualViewport) {
if (visualViewport.height < window.innerHeight) {
// If the keyboard is already visible, do this after one additional frame
// to wait for the transform to be removed.
requestAnimationFrame(() => {
scrollIntoView(target);
});
} else {
// Otherwise, wait for the visual viewport to resize before scrolling so we can
// measure the correct position to scroll to.
visualViewport.addEventListener(
'resize',
() => scrollIntoView(target),
{ once: true }
);
}
}
});
}
};
let onWindowScroll = () => {
// Last resort. If the window scrolled, scroll it back to the top.
// It should always be at the top because the body will have a negative margin (see below).
window.scrollTo(0, 0);
};
// Record the original scroll position so we can restore it.
// Then apply a negative margin to the body to offset it by the scroll position. This will
// enable us to scroll the window to the top, which is required for the rest of this to work.
let scrollX = window.pageXOffset;
let scrollY = window.pageYOffset;
let restoreStyles = chain(
setStyle(
document.documentElement,
'paddingRight',
`${window.innerWidth - document.documentElement.clientWidth}px`
)
// setStyle(document.documentElement, 'overflow', 'hidden'),
// setStyle(document.body, 'marginTop', `-${scrollY}px`),
);
// Scroll to the top. The negative margin on the body will make this appear the same.
window.scrollTo(0, 0);
let removeEvents = chain(
addEvent(document, 'touchstart', onTouchStart, {
passive: false,
capture: true,
}),
addEvent(document, 'touchmove', onTouchMove, {
passive: false,
capture: true,
}),
addEvent(document, 'touchend', onTouchEnd, {
passive: false,
capture: true,
}),
addEvent(document, 'focus', onFocus, true),
addEvent(window, 'scroll', onWindowScroll)
);
return () => {
// Restore styles and scroll the page back to where it was.
restoreStyles();
removeEvents();
window.scrollTo(scrollX, scrollY);
};
}
// Sets a CSS property on an element, and returns a function to revert it to the previous value.
function setStyle(
element: HTMLElement,
style: keyof React.CSSProperties,
value: string
) {
// https://github.com/microsoft/TypeScript/issues/17827#issuecomment-391663310
// @ts-ignore
let cur = element.style[style];
// @ts-ignore
element.style[style] = value;
return () => {
// @ts-ignore
element.style[style] = cur;
};
}
// Adds an event listener to an element, and returns a function to remove it.
function addEvent<K extends keyof GlobalEventHandlersEventMap>(
target: EventTarget,
event: K,
handler: (this: Document, ev: GlobalEventHandlersEventMap[K]) => any,
options?: boolean | AddEventListenerOptions
) {
// @ts-ignore
target.addEventListener(event, handler, options);
return () => {
// @ts-ignore
target.removeEventListener(event, handler, options);
};
}
function scrollIntoView(target: Element) {
let root = document.scrollingElement || document.documentElement;
while (target && target !== root) {
// Find the parent scrollable element and adjust the scroll position if the target is not already in view.
let scrollable = getScrollParent(target);
if (
scrollable !== document.documentElement &&
scrollable !== document.body &&
scrollable !== target
) {
let scrollableTop = scrollable.getBoundingClientRect().top;
let targetTop = target.getBoundingClientRect().top;
let targetBottom = target.getBoundingClientRect().bottom;
// Buffer is needed for some edge cases
const keyboardHeight =
scrollable.getBoundingClientRect().bottom + KEYBOARD_BUFFER;
if (targetBottom > keyboardHeight) {
scrollable.scrollTop += targetTop - scrollableTop;
}
}
// @ts-ignore
target = scrollable.parentElement;
}
}
export function isInput(target: Element) {
return (
(target instanceof HTMLInputElement &&
!nonTextInputTypes.has(target.type)) ||
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement && target.isContentEditable)
);
}