Linear Card Component

A Linear-inspired animated card component powered by Framer Motion that mimics the Linear app's hero section card opening effect. Features smooth expand animations, image reveals, and description content with elegant Framer Motion transitions.

Installation

npm install 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 'motion/react';
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
};