A morphing-dialog component from Motion Primitives.
1'use client';
2
3import React, {
4 useCallback,
5 useContext,
6 useEffect,
7 useId,
8 useMemo,
9 useRef,
10 useState,
11} from 'react';
12import {
13 motion,
14 AnimatePresence,
15 MotionConfig,
16 Transition,
17 Variant,
18} from 'motion/react';
19import { createPortal } from 'react-dom';
20import { cn } from '@/lib/utils';
21import { XIcon } from 'lucide-react';
22import useClickOutside from '@/hooks/useClickOutside';
23
24export type MorphingDialogContextType = {
25 isOpen: boolean;
26 setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
27 uniqueId: string;
28 triggerRef: React.RefObject<HTMLButtonElement | null>;
29};
30
31const MorphingDialogContext =
32 React.createContext<MorphingDialogContextType | null>(null);
33
34function useMorphingDialog() {
35 const context = useContext(MorphingDialogContext);
36 if (!context) {
37 throw new Error(
38 'useMorphingDialog must be used within a MorphingDialogProvider'
39 );
40 }
41 return context;
42}
43
44export type MorphingDialogProviderProps = {
45 children: React.ReactNode;
46 transition?: Transition;
47};
48
49function MorphingDialogProvider({
50 children,
51 transition,
52}: MorphingDialogProviderProps) {
53 const [isOpen, setIsOpen] = useState(false);
54 const uniqueId = useId();
55 const triggerRef = useRef<HTMLButtonElement>(null!);
56
57 const contextValue = useMemo(
58 () => ({
59 isOpen,
60 setIsOpen,
61 uniqueId,
62 triggerRef,
63 }),
64 [isOpen, uniqueId]
65 );
66
67 return (
68 <MorphingDialogContext.Provider value={contextValue}>
69 <MotionConfig transition={transition}>{children}</MotionConfig>
70 </MorphingDialogContext.Provider>
71 );
72}
73
74export type MorphingDialogProps = {
75 children: React.ReactNode;
76 transition?: Transition;
77};
78
79function MorphingDialog({ children, transition }: MorphingDialogProps) {
80 return (
81 <MorphingDialogProvider>
82 <MotionConfig transition={transition}>{children}</MotionConfig>
83 </MorphingDialogProvider>
84 );
85}
86
87export type MorphingDialogTriggerProps = {
88 children: React.ReactNode;
89 className?: string;
90 style?: React.CSSProperties;
91 triggerRef?: React.RefObject<HTMLButtonElement>;
92};
93
94function MorphingDialogTrigger({
95 children,
96 className,
97 style,
98 triggerRef,
99}: MorphingDialogTriggerProps) {
100 const { setIsOpen, isOpen, uniqueId } = useMorphingDialog();
101
102 const handleClick = useCallback(() => {
103 setIsOpen(!isOpen);
104 }, [isOpen, setIsOpen]);
105
106 const handleKeyDown = useCallback(
107 (event: React.KeyboardEvent) => {
108 if (event.key === 'Enter' || event.key === ' ') {
109 event.preventDefault();
110 setIsOpen(!isOpen);
111 }
112 },
113 [isOpen, setIsOpen]
114 );
115
116 return (
117 <motion.button
118 ref={triggerRef}
119 layoutId={`dialog-${uniqueId}`}
120 className={cn('relative cursor-pointer', className)}
121 onClick={handleClick}
122 onKeyDown={handleKeyDown}
123 style={style}
124 aria-haspopup='dialog'
125 aria-expanded={isOpen}
126 aria-controls={`motion-ui-morphing-dialog-content-${uniqueId}`}
127 aria-label={`Open dialog ${uniqueId}`}
128 >
129 {children}
130 </motion.button>
131 );
132}
133
134export type MorphingDialogContentProps = {
135 children: React.ReactNode;
136 className?: string;
137 style?: React.CSSProperties;
138};
139
140function MorphingDialogContent({
141 children,
142 className,
143 style,
144}: MorphingDialogContentProps) {
145 const { setIsOpen, isOpen, uniqueId, triggerRef } = useMorphingDialog();
146 const containerRef = useRef<HTMLDivElement>(null!);
147 const [firstFocusableElement, setFirstFocusableElement] =
148 useState<HTMLElement | null>(null);
149 const [lastFocusableElement, setLastFocusableElement] =
150 useState<HTMLElement | null>(null);
151
152 useEffect(() => {
153 const handleKeyDown = (event: KeyboardEvent) => {
154 if (event.key === 'Escape') {
155 setIsOpen(false);
156 }
157 if (event.key === 'Tab') {
158 if (!firstFocusableElement || !lastFocusableElement) return;
159
160 if (event.shiftKey) {
161 if (document.activeElement === firstFocusableElement) {
162 event.preventDefault();
163 lastFocusableElement.focus();
164 }
165 } else {
166 if (document.activeElement === lastFocusableElement) {
167 event.preventDefault();
168 firstFocusableElement.focus();
169 }
170 }
171 }
172 };
173
174 document.addEventListener('keydown', handleKeyDown);
175
176 return () => {
177 document.removeEventListener('keydown', handleKeyDown);
178 };
179 }, [setIsOpen, firstFocusableElement, lastFocusableElement]);
180
181 useEffect(() => {
182 if (isOpen) {
183 document.body.classList.add('overflow-hidden');
184 const focusableElements = containerRef.current?.querySelectorAll(
185 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
186 );
187 if (focusableElements && focusableElements.length > 0) {
188 setFirstFocusableElement(focusableElements[0] as HTMLElement);
189 setLastFocusableElement(
190 focusableElements[focusableElements.length - 1] as HTMLElement
191 );
192 (focusableElements[0] as HTMLElement).focus();
193 }
194 } else {
195 document.body.classList.remove('overflow-hidden');
196 triggerRef.current?.focus();
197 }
198 }, [isOpen, triggerRef]);
199
200 useClickOutside(containerRef, () => {
201 if (isOpen) {
202 setIsOpen(false);
203 }
204 });
205
206 return (
207 <motion.div
208 ref={containerRef}
209 layoutId={`dialog-${uniqueId}`}
210 className={cn('overflow-hidden', className)}
211 style={style}
212 role='dialog'
213 aria-modal='true'
214 aria-labelledby={`motion-ui-morphing-dialog-title-${uniqueId}`}
215 aria-describedby={`motion-ui-morphing-dialog-description-${uniqueId}`}
216 >
217 {children}
218 </motion.div>
219 );
220}
221
222export type MorphingDialogContainerProps = {
223 children: React.ReactNode;
224 className?: string;
225 style?: React.CSSProperties;
226};
227
228function MorphingDialogContainer({ children }: MorphingDialogContainerProps) {
229 const { isOpen, uniqueId } = useMorphingDialog();
230 const [mounted, setMounted] = useState(false);
231
232 useEffect(() => {
233 setMounted(true);
234 return () => setMounted(false);
235 }, []);
236
237 if (!mounted) return null;
238
239 return createPortal(
240 <AnimatePresence initial={false} mode='sync'>
241 {isOpen && (
242 <>
243 <motion.div
244 key={`backdrop-${uniqueId}`}
245 className='fixed inset-0 h-full w-full bg-white/40 backdrop-blur-xs dark:bg-black/40'
246 initial={{ opacity: 0 }}
247 animate={{ opacity: 1 }}
248 exit={{ opacity: 0 }}
249 />
250 <div className='fixed inset-0 z-50 flex items-center justify-center'>
251 {children}
252 </div>
253 </>
254 )}
255 </AnimatePresence>,
256 document.body
257 );
258}
259
260export type MorphingDialogTitleProps = {
261 children: React.ReactNode;
262 className?: string;
263 style?: React.CSSProperties;
264};
265
266function MorphingDialogTitle({
267 children,
268 className,
269 style,
270}: MorphingDialogTitleProps) {
271 const { uniqueId } = useMorphingDialog();
272
273 return (
274 <motion.div
275 layoutId={`dialog-title-container-${uniqueId}`}
276 className={className}
277 style={style}
278 layout
279 >
280 {children}
281 </motion.div>
282 );
283}
284
285export type MorphingDialogSubtitleProps = {
286 children: React.ReactNode;
287 className?: string;
288 style?: React.CSSProperties;
289};
290
291function MorphingDialogSubtitle({
292 children,
293 className,
294 style,
295}: MorphingDialogSubtitleProps) {
296 const { uniqueId } = useMorphingDialog();
297
298 return (
299 <motion.div
300 layoutId={`dialog-subtitle-container-${uniqueId}`}
301 className={className}
302 style={style}
303 >
304 {children}
305 </motion.div>
306 );
307}
308
309export type MorphingDialogDescriptionProps = {
310 children: React.ReactNode;
311 className?: string;
312 disableLayoutAnimation?: boolean;
313 variants?: {
314 initial: Variant;
315 animate: Variant;
316 exit: Variant;
317 };
318};
319
320function MorphingDialogDescription({
321 children,
322 className,
323 variants,
324 disableLayoutAnimation,
325}: MorphingDialogDescriptionProps) {
326 const { uniqueId } = useMorphingDialog();
327
328 return (
329 <motion.div
330 key={`dialog-description-${uniqueId}`}
331 layoutId={
332 disableLayoutAnimation
333 ? undefined
334 : `dialog-description-content-${uniqueId}`
335 }
336 variants={variants}
337 className={className}
338 initial='initial'
339 animate='animate'
340 exit='exit'
341 id={`dialog-description-${uniqueId}`}
342 >
343 {children}
344 </motion.div>
345 );
346}
347
348export type MorphingDialogImageProps = {
349 src: string;
350 alt: string;
351 className?: string;
352 style?: React.CSSProperties;
353};
354
355function MorphingDialogImage({
356 src,
357 alt,
358 className,
359 style,
360}: MorphingDialogImageProps) {
361 const { uniqueId } = useMorphingDialog();
362
363 return (
364 <motion.img
365 src={src}
366 alt={alt}
367 className={cn(className)}
368 layoutId={`dialog-img-${uniqueId}`}
369 style={style}
370 />
371 );
372}
373
374export type MorphingDialogCloseProps = {
375 children?: React.ReactNode;
376 className?: string;
377 variants?: {
378 initial: Variant;
379 animate: Variant;
380 exit: Variant;
381 };
382};
383
384function MorphingDialogClose({
385 children,
386 className,
387 variants,
388}: MorphingDialogCloseProps) {
389 const { setIsOpen, uniqueId } = useMorphingDialog();
390
391 const handleClose = useCallback(() => {
392 setIsOpen(false);
393 }, [setIsOpen]);
394
395 return (
396 <motion.button
397 onClick={handleClose}
398 type='button'
399 aria-label='Close dialog'
400 key={`dialog-close-${uniqueId}`}
401 className={cn('absolute top-6 right-6', className)}
402 initial='initial'
403 animate='animate'
404 exit='exit'
405 variants={variants}
406 >
407 {children || <XIcon size={24} />}
408 </motion.button>
409 );
410}
411
412export {
413 MorphingDialog,
414 MorphingDialogTrigger,
415 MorphingDialogContainer,
416 MorphingDialogContent,
417 MorphingDialogClose,
418 MorphingDialogTitle,
419 MorphingDialogSubtitle,
420 MorphingDialogDescription,
421 MorphingDialogImage,
422};
423