motion-primitives
components
ui
animation
motion

dialog

Customize the dialog with variants and transition.

animated
button
dialog
effect
flex
form
hover
list
modal
positioning
scroll
text
transform
transition
View Docs

Source Code

Files
dialog.tsx
1'use client';
2import { AnimatePresence, motion, Transition, Variants } from 'motion/react';
3import React, { createContext, useContext, useEffect, useRef } from 'react';
4import { cn } from '@/lib/utils';
5import { useId } from 'react';
6import { createPortal } from 'react-dom';
7import { X } from 'lucide-react';
8import { usePreventScroll } from '@/hooks/usePreventScroll';
9
10const DialogContext = createContext<{
11  isOpen: boolean;
12  setIsOpen: (open: boolean) => void;
13  dialogRef: React.RefObject<HTMLDialogElement | null>;
14  variants: Variants;
15  transition?: Transition;
16  ids: {
17    dialog: string;
18    title: string;
19    description: string;
20  };
21  onAnimationComplete: (definition: string) => void;
22  handleTrigger: () => void;
23} | null>(null);
24
25const defaultVariants: Variants = {
26  initial: {
27    opacity: 0,
28    scale: 0.9,
29  },
30  animate: {
31    opacity: 1,
32    scale: 1,
33  },
34};
35
36const defaultTransition: Transition = {
37  ease: 'easeOut',
38  duration: 0.2,
39};
40
41export type DialogProps = {
42  children: React.ReactNode;
43  variants?: Variants;
44  transition?: Transition;
45  className?: string;
46  defaultOpen?: boolean;
47  onOpenChange?: (open: boolean) => void;
48  open?: boolean;
49};
50
51function Dialog({
52  children,
53  variants = defaultVariants,
54  transition = defaultTransition,
55  defaultOpen,
56  onOpenChange,
57  open,
58}: DialogProps) {
59  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
60    defaultOpen || false
61  );
62  const dialogRef = useRef<HTMLDialogElement>(null);
63  const isOpen = open !== undefined ? open : uncontrolledOpen;
64
65  // prevent scroll when dialog is open on iOS
66  usePreventScroll({
67    isDisabled: !isOpen,
68  });
69
70  const setIsOpen = React.useCallback(
71    (value: boolean) => {
72      setUncontrolledOpen(value);
73      onOpenChange?.(value);
74    },
75    [onOpenChange]
76  );
77
78  useEffect(() => {
79    const dialog = dialogRef.current;
80    if (!dialog) return;
81
82    if (isOpen) {
83      document.body.classList.add('overflow-hidden');
84    } else {
85      document.body.classList.remove('overflow-hidden');
86    }
87
88    const handleCancel = (e: Event) => {
89      e.preventDefault();
90      if (isOpen) {
91        setIsOpen(false);
92      }
93    };
94
95    dialog.addEventListener('cancel', handleCancel);
96    return () => {
97      dialog.removeEventListener('cancel', handleCancel);
98      document.body.classList.remove('overflow-hidden');
99    };
100  }, [dialogRef, isOpen, setIsOpen]);
101
102  useEffect(() => {
103    if (isOpen && dialogRef.current) {
104      dialogRef.current.showModal();
105    }
106  }, [isOpen]);
107
108  const handleTrigger = () => {
109    setIsOpen(true);
110  };
111
112  const onAnimationComplete = (definition: string) => {
113    if (definition === 'exit' && !isOpen) {
114      dialogRef.current?.close();
115    }
116  };
117
118  const baseId = useId();
119  const ids = {
120    dialog: `motion-ui-dialog-${baseId}`,
121    title: `motion-ui-dialog-title-${baseId}`,
122    description: `motion-ui-dialog-description-${baseId}`,
123  };
124
125  return (
126    <DialogContext.Provider
127      value={{
128        isOpen,
129        setIsOpen,
130        dialogRef,
131        variants,
132        transition,
133        ids,
134        onAnimationComplete,
135        handleTrigger,
136      }}
137    >
138      {children}
139    </DialogContext.Provider>
140  );
141}
142
143export type DialogTriggerProps = {
144  children: React.ReactNode;
145  className?: string;
146};
147
148function DialogTrigger({ children, className }: DialogTriggerProps) {
149  const context = useContext(DialogContext);
150  if (!context) throw new Error('DialogTrigger must be used within Dialog');
151
152  return (
153    <button
154      onClick={context.handleTrigger}
155      className={cn(
156        'inline-flex items-center justify-center rounded-md text-sm font-medium',
157        'transition-colors focus-visible:ring-2 focus-visible:outline-hidden',
158        'focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
159        className
160      )}
161    >
162      {children}
163    </button>
164  );
165}
166
167export type DialogPortalProps = {
168  children: React.ReactNode;
169  container?: HTMLElement | null;
170};
171
172function DialogPortal({
173  children,
174  container = typeof window !== 'undefined' ? document.body : null,
175}: DialogPortalProps) {
176  const [mounted, setMounted] = React.useState(false);
177  const [portalContainer, setPortalContainer] =
178    React.useState<HTMLElement | null>(null);
179
180  useEffect(() => {
181    setMounted(true);
182    setPortalContainer(container || document.body);
183    return () => setMounted(false);
184  }, [container]);
185
186  if (!mounted || !portalContainer) {
187    return null;
188  }
189
190  return createPortal(children, portalContainer);
191}
192export type DialogContentProps = {
193  children: React.ReactNode;
194  className?: string;
195  container?: HTMLElement;
196};
197
198function DialogContent({ children, className, container }: DialogContentProps) {
199  const context = useContext(DialogContext);
200  if (!context) throw new Error('DialogContent must be used within Dialog');
201  const {
202    isOpen,
203    setIsOpen,
204    dialogRef,
205    variants,
206    transition,
207    ids,
208    onAnimationComplete,
209  } = context;
210
211  const content = (
212    <AnimatePresence mode='wait'>
213      {isOpen && (
214        <motion.dialog
215          key={ids.dialog}
216          ref={dialogRef as React.RefObject<HTMLDialogElement>}
217          id={ids.dialog}
218          aria-labelledby={ids.title}
219          aria-describedby={ids.description}
220          aria-modal='true'
221          role='dialog'
222          onClick={(e) => {
223            if (e.target === dialogRef.current) {
224              setIsOpen(false);
225            }
226          }}
227          initial='initial'
228          animate='animate'
229          exit='exit'
230          variants={variants}
231          transition={transition}
232          onAnimationComplete={onAnimationComplete}
233          className={cn(
234            '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',
235            'backdrop:bg-black/50 backdrop:backdrop-blur-xs',
236            'open:flex open:flex-col',
237            className
238          )}
239        >
240          <div className='w-full'>{children}</div>
241        </motion.dialog>
242      )}
243    </AnimatePresence>
244  );
245
246  return <DialogPortal container={container}>{content}</DialogPortal>;
247}
248
249export type DialogHeaderProps = {
250  children: React.ReactNode;
251  className?: string;
252};
253
254function DialogHeader({ children, className }: DialogHeaderProps) {
255  return (
256    <div className={cn('flex flex-col space-y-1.5', className)}>{children}</div>
257  );
258}
259
260export type DialogTitleProps = {
261  children: React.ReactNode;
262  className?: string;
263};
264
265function DialogTitle({ children, className }: DialogTitleProps) {
266  const context = useContext(DialogContext);
267  if (!context) throw new Error('DialogTitle must be used within Dialog');
268
269  return (
270    <h2
271      id={context.ids.title}
272      className={cn('text-base font-medium', className)}
273    >
274      {children}
275    </h2>
276  );
277}
278
279export type DialogDescriptionProps = {
280  children: React.ReactNode;
281  className?: string;
282};
283
284function DialogDescription({ children, className }: DialogDescriptionProps) {
285  const context = useContext(DialogContext);
286  if (!context) throw new Error('DialogDescription must be used within Dialog');
287
288  return (
289    <p
290      id={context.ids.description}
291      className={cn('text-base text-zinc-500', className)}
292    >
293      {children}
294    </p>
295  );
296}
297
298export type DialogCloseProps = {
299  className?: string;
300  children?: React.ReactNode;
301  disabled?: boolean;
302};
303
304function DialogClose({ className, children, disabled }: DialogCloseProps) {
305  const context = useContext(DialogContext);
306  if (!context) throw new Error('DialogClose must be used within Dialog');
307
308  return (
309    <button
310      onClick={() => context.setIsOpen(false)}
311      type='button'
312      aria-label='Close dialog'
313      className={cn(
314        'absolute top-4 right-4 rounded-xs opacity-70 transition-opacity',
315        'hover:opacity-100 focus:ring-2 focus:outline-hidden',
316        'focus:ring-zinc-500 focus:ring-offset-2 disabled:pointer-events-none',
317        className
318      )}
319      disabled={disabled}
320    >
321      {children || <X className='h-4 w-4' />}
322      <span className='sr-only'>Close</span>
323    </button>
324  );
325}
326
327export {
328  Dialog,
329  DialogTrigger,
330  DialogContent,
331  DialogHeader,
332  DialogTitle,
333  DialogDescription,
334  DialogClose,
335};
336