Linear Card
An animated dialog component powered by Framer Motion, offering smooth transitions and interactive visual effects for modal windows
An animated dialog component powered by Framer Motion, offering smooth transitions and interactive visual effects for modal windows
npm install framer-motion
1'use client';23import React, {4useCallback,5useContext,6useEffect,7useId,8useMemo,9useRef,10useState,11} from 'react';12import {13motion,14AnimatePresence,15MotionConfig,16Transition,17Variant,18} from 'framer-motion';19import { createPortal } from 'react-dom';20import { cn } from '@/lib/utils';21// import useClickOutside from '@/hooks/useClickOutside';22import { XIcon } from 'lucide-react';2324interface DialogContextType {25isOpen: boolean;26setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;27uniqueId: string;28triggerRef: React.RefObject<HTMLDivElement>;29}3031const DialogContext = React.createContext<DialogContextType | null>(null);3233function useDialog() {34const context = useContext(DialogContext);35if (!context) {36throw new Error('useDialog must be used within a DialogProvider');37}38return context;39}4041type DialogProviderProps = {42children: React.ReactNode;43transition?: Transition;44};4546function DialogProvider({ children, transition }: DialogProviderProps) {47const [isOpen, setIsOpen] = useState(false);48const uniqueId = useId();49const triggerRef = useRef<HTMLDivElement>(null);5051const contextValue = useMemo(52() => ({ isOpen, setIsOpen, uniqueId, triggerRef }),53[isOpen, uniqueId]54);5556return (57<DialogContext.Provider value={contextValue}>58<MotionConfig transition={transition}>{children}</MotionConfig>59</DialogContext.Provider>60);61}6263type DialogProps = {64children: React.ReactNode;65transition?: Transition;66};6768function Dialog({ children, transition }: DialogProps) {69return (70<DialogProvider>71<MotionConfig transition={transition}>{children}</MotionConfig>72</DialogProvider>73);74}7576type DialogTriggerProps = {77children: React.ReactNode;78className?: string;79style?: React.CSSProperties;80triggerRef?: React.RefObject<HTMLDivElement>;81};8283function DialogTrigger({84children,85className,86style,87triggerRef,88}: DialogTriggerProps) {89const { setIsOpen, isOpen, uniqueId } = useDialog();9091const handleClick = useCallback(() => {92setIsOpen(!isOpen);93}, [isOpen, setIsOpen]);9495const handleKeyDown = useCallback(96(event: React.KeyboardEvent) => {97if (event.key === 'Enter' || event.key === ' ') {98event.preventDefault();99setIsOpen(!isOpen);100}101},102[isOpen, setIsOpen]103);104105return (106<motion.div107ref={triggerRef}108layoutId={`dialog-${uniqueId}`}109className={cn('relative cursor-pointer', className)}110onClick={handleClick}111onKeyDown={handleKeyDown}112style={style}113role='button'114aria-haspopup='dialog'115aria-expanded={isOpen}116aria-controls={`dialog-content-${uniqueId}`}117>118{children}119</motion.div>120);121}122123type DialogContent = {124children: React.ReactNode;125className?: string;126style?: React.CSSProperties;127};128129function DialogContent({ children, className, style }: DialogContent) {130const { setIsOpen, isOpen, uniqueId, triggerRef } = useDialog();131const containerRef = useRef<HTMLDivElement>(null);132const [firstFocusableElement, setFirstFocusableElement] =133useState<HTMLElement | null>(null);134const [lastFocusableElement, setLastFocusableElement] =135useState<HTMLElement | null>(null);136137useEffect(() => {138const handleKeyDown = (event: KeyboardEvent) => {139if (event.key === 'Escape') {140setIsOpen(false);141}142if (event.key === 'Tab') {143if (!firstFocusableElement || !lastFocusableElement) return;144145if (event.shiftKey) {146if (document.activeElement === firstFocusableElement) {147event.preventDefault();148lastFocusableElement.focus();149}150} else {151if (document.activeElement === lastFocusableElement) {152event.preventDefault();153firstFocusableElement.focus();154}155}156}157};158159document.addEventListener('keydown', handleKeyDown);160161return () => {162document.removeEventListener('keydown', handleKeyDown);163};164}, [setIsOpen, firstFocusableElement, lastFocusableElement]);165166useEffect(() => {167if (isOpen) {168document.body.classList.add('overflow-hidden');169const focusableElements = containerRef.current?.querySelectorAll(170'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'171);172if (focusableElements && focusableElements.length > 0) {173setFirstFocusableElement(focusableElements[0] as HTMLElement);174setLastFocusableElement(175focusableElements[focusableElements.length - 1] as HTMLElement176);177(focusableElements[0] as HTMLElement).focus();178}179// Scroll to the top when dialog opens180if (containerRef.current) {181containerRef.current.scrollTop = 0;182}183} else {184document.body.classList.remove('overflow-hidden');185triggerRef.current?.focus();186}187}, [isOpen, triggerRef]);188189return (190<>191<motion.div192ref={containerRef}193layoutId={`dialog-${uniqueId}`}194className={cn('overflow-hidden', className)}195style={style}196role='dialog'197aria-modal='true'198aria-labelledby={`dialog-title-${uniqueId}`}199aria-describedby={`dialog-description-${uniqueId}`}200>201{children}202</motion.div>203</>204);205}206207type DialogContainerProps = {208children: React.ReactNode;209className?: string;210style?: React.CSSProperties;211};212213function DialogContainer({ children, className }: DialogContainerProps) {214const { isOpen, setIsOpen, uniqueId } = useDialog();215const [mounted, setMounted] = useState(false);216217useEffect(() => {218if (isOpen) {219window.scrollTo(0, 0);220}221setMounted(true);222return () => setMounted(false);223}, []);224225if (!mounted) return null;226// createPortal(227return (228<AnimatePresence initial={false} mode='sync'>229{isOpen && (230<>231<motion.div232key={`backdrop-${uniqueId}`}233className='fixed inset-0 h-full z-50 w-full bg-white/40 backdrop-blur-sm dark:bg-black/40 '234initial={{ opacity: 0 }}235animate={{ opacity: 1 }}236exit={{ opacity: 0 }}237onClick={() => 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.body247// )248}249250type DialogTitleProps = {251children: React.ReactNode;252className?: string;253style?: React.CSSProperties;254};255256function DialogTitle({ children, className, style }: DialogTitleProps) {257const { uniqueId } = useDialog();258259return (260<motion.div261layoutId={`dialog-title-container-${uniqueId}`}262className={className}263style={style}264layout265>266{children}267</motion.div>268);269}270271type DialogSubtitleProps = {272children: React.ReactNode;273className?: string;274style?: React.CSSProperties;275};276277function DialogSubtitle({ children, className, style }: DialogSubtitleProps) {278const { uniqueId } = useDialog();279280return (281<motion.div282layoutId={`dialog-subtitle-container-${uniqueId}`}283className={className}284style={style}285>286{children}287</motion.div>288);289}290291type DialogDescriptionProps = {292children: React.ReactNode;293className?: string;294disableLayoutAnimation?: boolean;295variants?: {296initial: Variant;297animate: Variant;298exit: Variant;299};300};301302function DialogDescription({303children,304className,305variants,306disableLayoutAnimation,307}: DialogDescriptionProps) {308const { uniqueId } = useDialog();309310return (311<motion.div312key={`dialog-description-${uniqueId}`}313layoutId={314disableLayoutAnimation315? undefined316: `dialog-description-content-${uniqueId}`317}318variants={variants}319className={className}320initial='initial'321animate='animate'322exit='exit'323id={`dialog-description-${uniqueId}`}324>325{children}326</motion.div>327);328}329330type DialogImageProps = {331src: string;332alt: string;333className?: string;334style?: React.CSSProperties;335};336337function DialogImage({ src, alt, className, style }: DialogImageProps) {338const { uniqueId } = useDialog();339340return (341<motion.img342src={src}343alt={alt}344className={cn(className)}345layoutId={`dialog-img-${uniqueId}`}346style={style}347/>348);349}350351type DialogCloseProps = {352children?: React.ReactNode;353className?: string;354variants?: {355initial: Variant;356animate: Variant;357exit: Variant;358};359};360361function DialogClose({ children, className, variants }: DialogCloseProps) {362const { setIsOpen, uniqueId } = useDialog();363364const handleClose = useCallback(() => {365setIsOpen(false);366}, [setIsOpen]);367368return (369<motion.button370onClick={handleClose}371type='button'372aria-label='Close dialog'373key={`dialog-close-${uniqueId}`}374className={cn('absolute right-6 top-6', className)}375initial='initial'376animate='animate'377exit='exit'378variants={variants}379>380{children || <XIcon size={24} />}381</motion.button>382);383}384385export {386Dialog,387DialogTrigger,388DialogContainer,389DialogContent,390DialogClose,391DialogTitle,392DialogSubtitle,393DialogDescription,394DialogImage,395};