A dialog that uses layout animations to transition content into a focused view.
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<HTMLDivElement>;
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<HTMLDivElement>(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<HTMLDivElement>;
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.div
118 ref={triggerRef}
119 layoutId={`dialog-${uniqueId}`}
120 className={cn("relative cursor-pointer", className)}
121 onClick={handleClick}
122 onKeyDown={handleKeyDown}
123 style={style}
124 role="button"
125 aria-haspopup="dialog"
126 aria-expanded={isOpen}
127 aria-controls={`motion-ui-morphing-dialog-content-${uniqueId}`}
128 aria-label={`Open dialog ${uniqueId}`}
129 >
130 {children}
131 </motion.div>
132 );
133}
134
135export type MorphingDialogContentProps = {
136 children: React.ReactNode;
137 className?: string;
138 style?: React.CSSProperties;
139};
140
141function MorphingDialogContent({
142 children,
143 className,
144 style,
145}: MorphingDialogContentProps) {
146 const { setIsOpen, isOpen, uniqueId, triggerRef } = useMorphingDialog();
147 const containerRef = useRef<HTMLDivElement>(null!);
148 const [firstFocusableElement, setFirstFocusableElement] =
149 useState<HTMLElement | null>(null);
150 const [lastFocusableElement, setLastFocusableElement] =
151 useState<HTMLElement | null>(null);
152
153 useEffect(() => {
154 const handleKeyDown = (event: KeyboardEvent) => {
155 if (event.key === "Escape") {
156 setIsOpen(false);
157 }
158 if (event.key === "Tab") {
159 if (!firstFocusableElement || !lastFocusableElement) return;
160
161 if (event.shiftKey) {
162 if (document.activeElement === firstFocusableElement) {
163 event.preventDefault();
164 lastFocusableElement.focus();
165 }
166 } else {
167 if (document.activeElement === lastFocusableElement) {
168 event.preventDefault();
169 firstFocusableElement.focus();
170 }
171 }
172 }
173 };
174
175 document.addEventListener("keydown", handleKeyDown);
176
177 return () => {
178 document.removeEventListener("keydown", handleKeyDown);
179 };
180 }, [setIsOpen, firstFocusableElement, lastFocusableElement]);
181
182 useEffect(() => {
183 if (isOpen) {
184 document.body.classList.add("overflow-hidden");
185 const focusableElements = containerRef.current?.querySelectorAll(
186 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
187 );
188 if (focusableElements && focusableElements.length > 0) {
189 setFirstFocusableElement(focusableElements[0] as HTMLElement);
190 setLastFocusableElement(
191 focusableElements[focusableElements.length - 1] as HTMLElement,
192 );
193 (focusableElements[0] as HTMLElement).focus();
194 }
195 } else {
196 document.body.classList.remove("overflow-hidden");
197 triggerRef.current?.focus();
198 }
199 }, [isOpen, triggerRef]);
200
201 useClickOutside(containerRef, () => {
202 if (isOpen) {
203 setIsOpen(false);
204 }
205 });
206
207 return (
208 <motion.div
209 ref={containerRef}
210 layoutId={`dialog-${uniqueId}`}
211 className={cn("overflow-hidden", className)}
212 style={style}
213 role="dialog"
214 aria-modal="true"
215 aria-labelledby={`motion-ui-morphing-dialog-title-${uniqueId}`}
216 aria-describedby={`motion-ui-morphing-dialog-description-${uniqueId}`}
217 >
218 {children}
219 </motion.div>
220 );
221}
222
223export type MorphingDialogContainerProps = {
224 children: React.ReactNode;
225 className?: string;
226 style?: React.CSSProperties;
227};
228
229function MorphingDialogContainer({ children }: MorphingDialogContainerProps) {
230 const { isOpen, uniqueId } = useMorphingDialog();
231 const [mounted, setMounted] = useState(false);
232
233 useEffect(() => {
234 setMounted(true);
235 return () => setMounted(false);
236 }, []);
237
238 if (!mounted) return null;
239
240 return createPortal(
241 <AnimatePresence initial={false} mode="sync">
242 {isOpen && (
243 <>
244 <motion.div
245 key={`backdrop-${uniqueId}`}
246 className="backdrop-blur-xs fixed inset-0 h-full w-full bg-white/40 dark:bg-black/40"
247 initial={{ opacity: 0 }}
248 animate={{ opacity: 1 }}
249 exit={{ opacity: 0 }}
250 />
251 <div className="fixed inset-0 z-50 flex items-center justify-center">
252 {children}
253 </div>
254 </>
255 )}
256 </AnimatePresence>,
257 document.body,
258 );
259}
260
261export type MorphingDialogTitleProps = {
262 children: React.ReactNode;
263 className?: string;
264 style?: React.CSSProperties;
265};
266
267function MorphingDialogTitle({
268 children,
269 className,
270 style,
271}: MorphingDialogTitleProps) {
272 const { uniqueId } = useMorphingDialog();
273
274 return (
275 <motion.div
276 layoutId={`dialog-title-container-${uniqueId}`}
277 className={className}
278 style={style}
279 layout
280 >
281 {children}
282 </motion.div>
283 );
284}
285
286export type MorphingDialogSubtitleProps = {
287 children: React.ReactNode;
288 className?: string;
289 style?: React.CSSProperties;
290};
291
292function MorphingDialogSubtitle({
293 children,
294 className,
295 style,
296}: MorphingDialogSubtitleProps) {
297 const { uniqueId } = useMorphingDialog();
298
299 return (
300 <motion.div
301 layoutId={`dialog-subtitle-container-${uniqueId}`}
302 className={className}
303 style={style}
304 >
305 {children}
306 </motion.div>
307 );
308}
309
310export type MorphingDialogDescriptionProps = {
311 children: React.ReactNode;
312 className?: string;
313 disableLayoutAnimation?: boolean;
314 variants?: {
315 initial: Variant;
316 animate: Variant;
317 exit: Variant;
318 };
319};
320
321function MorphingDialogDescription({
322 children,
323 className,
324 variants,
325 disableLayoutAnimation,
326}: MorphingDialogDescriptionProps) {
327 const { uniqueId } = useMorphingDialog();
328
329 return (
330 <motion.div
331 key={`dialog-description-${uniqueId}`}
332 layoutId={
333 disableLayoutAnimation
334 ? undefined
335 : `dialog-description-content-${uniqueId}`
336 }
337 variants={variants}
338 className={className}
339 initial="initial"
340 animate="animate"
341 exit="exit"
342 id={`dialog-description-${uniqueId}`}
343 >
344 {children}
345 </motion.div>
346 );
347}
348
349export type MorphingDialogImageProps = {
350 src: string;
351 alt: string;
352 className?: string;
353 style?: React.CSSProperties;
354};
355
356function MorphingDialogImage({
357 src,
358 alt,
359 className,
360 style,
361}: MorphingDialogImageProps) {
362 const { uniqueId } = useMorphingDialog();
363
364 return (
365 <motion.img
366 src={src}
367 alt={alt}
368 className={cn(className)}
369 layoutId={`dialog-img-${uniqueId}`}
370 style={style}
371 />
372 );
373}
374
375export type MorphingDialogCloseProps = {
376 children?: React.ReactNode;
377 className?: string;
378 variants?: {
379 initial: Variant;
380 animate: Variant;
381 exit: Variant;
382 };
383};
384
385function MorphingDialogClose({
386 children,
387 className,
388 variants,
389}: MorphingDialogCloseProps) {
390 const { setIsOpen, uniqueId } = useMorphingDialog();
391
392 const handleClose = useCallback(() => {
393 setIsOpen(false);
394 }, [setIsOpen]);
395
396 return (
397 <motion.button
398 onClick={handleClose}
399 type="button"
400 aria-label="Close dialog"
401 key={`dialog-close-${uniqueId}`}
402 className={cn("absolute right-6 top-6", className)}
403 initial="initial"
404 animate="animate"
405 exit="exit"
406 variants={variants}
407 >
408 {children || <XIcon size={24} />}
409 </motion.button>
410 );
411}
412
413export {
414 MorphingDialog,
415 MorphingDialogTrigger,
416 MorphingDialogContainer,
417 MorphingDialogContent,
418 MorphingDialogClose,
419 MorphingDialogTitle,
420 MorphingDialogSubtitle,
421 MorphingDialogDescription,
422 MorphingDialogImage,
423};
424