Linear Card

An animated dialog component powered by Framer Motion, offering smooth transitions and interactive visual effects for modal windows

Installation

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