A morphing-popover component from Motion Primitives.
1'use client';
2
3import {
4 useState,
5 useId,
6 useRef,
7 useEffect,
8 createContext,
9 useContext,
10 isValidElement,
11} from 'react';
12import {
13 AnimatePresence,
14 MotionConfig,
15 motion,
16 Transition,
17 Variants,
18} from 'motion/react';
19import useClickOutside from '@/hooks/useClickOutside';
20import { cn } from '@/lib/utils';
21
22const TRANSITION = {
23 type: 'spring',
24 bounce: 0.1,
25 duration: 0.4,
26};
27
28type MorphingPopoverContextValue = {
29 isOpen: boolean;
30 open: () => void;
31 close: () => void;
32 uniqueId: string;
33 variants?: Variants;
34};
35
36const MorphingPopoverContext =
37 createContext<MorphingPopoverContextValue | null>(null);
38
39function usePopoverLogic({
40 defaultOpen = false,
41 open: controlledOpen,
42 onOpenChange,
43}: {
44 defaultOpen?: boolean;
45 open?: boolean;
46 onOpenChange?: (open: boolean) => void;
47} = {}) {
48 const uniqueId = useId();
49 const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
50
51 const isOpen = controlledOpen ?? uncontrolledOpen;
52
53 const open = () => {
54 if (controlledOpen === undefined) {
55 setUncontrolledOpen(true);
56 }
57 onOpenChange?.(true);
58 };
59
60 const close = () => {
61 if (controlledOpen === undefined) {
62 setUncontrolledOpen(false);
63 }
64 onOpenChange?.(false);
65 };
66
67 return { isOpen, open, close, uniqueId };
68}
69
70export type MorphingPopoverProps = {
71 children: React.ReactNode;
72 transition?: Transition;
73 defaultOpen?: boolean;
74 open?: boolean;
75 onOpenChange?: (open: boolean) => void;
76 variants?: Variants;
77 className?: string;
78} & React.ComponentProps<'div'>;
79
80function MorphingPopover({
81 children,
82 transition = TRANSITION,
83 defaultOpen,
84 open,
85 onOpenChange,
86 variants,
87 className,
88 ...props
89}: MorphingPopoverProps) {
90 const popoverLogic = usePopoverLogic({ defaultOpen, open, onOpenChange });
91
92 return (
93 <MorphingPopoverContext.Provider value={{ ...popoverLogic, variants }}>
94 <MotionConfig transition={transition}>
95 <div
96 className={cn('relative flex items-center justify-center', className)}
97 key={popoverLogic.uniqueId}
98 {...props}
99 >
100 {children}
101 </div>
102 </MotionConfig>
103 </MorphingPopoverContext.Provider>
104 );
105}
106
107export type MorphingPopoverTriggerProps = {
108 asChild?: boolean;
109 children: React.ReactNode;
110 className?: string;
111} & React.ComponentProps<typeof motion.button>;
112
113function MorphingPopoverTrigger({
114 children,
115 className,
116 asChild = false,
117 ...props
118}: MorphingPopoverTriggerProps) {
119 const context = useContext(MorphingPopoverContext);
120 if (!context) {
121 throw new Error(
122 'MorphingPopoverTrigger must be used within MorphingPopover'
123 );
124 }
125
126 if (asChild && isValidElement(children)) {
127 const MotionComponent = motion.create(
128 children.type as React.ForwardRefExoticComponent<any>
129 );
130 const childProps = children.props as Record<string, unknown>;
131
132 return (
133 <MotionComponent
134 {...childProps}
135 onClick={context.open}
136 layoutId={`popover-trigger-${context.uniqueId}`}
137 className={childProps.className}
138 key={context.uniqueId}
139 aria-expanded={context.isOpen}
140 aria-controls={`popover-content-${context.uniqueId}`}
141 />
142 );
143 }
144
145 return (
146 <motion.div
147 key={context.uniqueId}
148 layoutId={`popover-trigger-${context.uniqueId}`}
149 onClick={context.open}
150 >
151 <motion.button
152 {...props}
153 layoutId={`popover-label-${context.uniqueId}`}
154 key={context.uniqueId}
155 className={className}
156 aria-expanded={context.isOpen}
157 aria-controls={`popover-content-${context.uniqueId}`}
158 >
159 {children}
160 </motion.button>
161 </motion.div>
162 );
163}
164
165export type MorphingPopoverContentProps = {
166 children: React.ReactNode;
167 className?: string;
168} & React.ComponentProps<typeof motion.div>;
169
170function MorphingPopoverContent({
171 children,
172 className,
173 ...props
174}: MorphingPopoverContentProps) {
175 const context = useContext(MorphingPopoverContext);
176 if (!context)
177 throw new Error(
178 'MorphingPopoverContent must be used within MorphingPopover'
179 );
180
181 const ref = useRef<HTMLDivElement>(null);
182 useClickOutside(ref, context.close);
183
184 useEffect(() => {
185 if (!context.isOpen) return;
186
187 const handleKeyDown = (event: KeyboardEvent) => {
188 if (event.key === 'Escape') context.close();
189 };
190
191 document.addEventListener('keydown', handleKeyDown);
192 return () => document.removeEventListener('keydown', handleKeyDown);
193 }, [context.isOpen, context.close]);
194
195 return (
196 <AnimatePresence>
197 {context.isOpen && (
198 <>
199 <motion.div
200 {...props}
201 ref={ref}
202 layoutId={`popover-trigger-${context.uniqueId}`}
203 key={context.uniqueId}
204 id={`popover-content-${context.uniqueId}`}
205 role='dialog'
206 aria-modal='true'
207 className={cn(
208 'absolute overflow-hidden rounded-md border border-zinc-950/10 bg-white p-2 text-zinc-950 shadow-md dark:border-zinc-50/10 dark:bg-zinc-700 dark:text-zinc-50',
209 className
210 )}
211 initial='initial'
212 animate='animate'
213 exit='exit'
214 variants={context.variants}
215 >
216 {children}
217 </motion.div>
218 </>
219 )}
220 </AnimatePresence>
221 );
222}
223
224export { MorphingPopover, MorphingPopoverTrigger, MorphingPopoverContent };
225