Modal Dialog
A premium modal with cursor-tracking spotlight effects and focus management.
Overview
A modern modal dialog component with a magnetic spotlight border that follows the cursor. Includes automatic focus trapping, portal rendering, and smooth spring-based animations.
Features
- Cursor-tracking spotlight border effect
- Automatic focus trap and restoration
- Portal rendering to avoid z-index conflicts
- Escape key and backdrop click to close
- Compound component API for flexibility
Preview
Installation
Method 1: CLI (Recommended)
Install via the ZenBlocks CLI to automatically handle dependencies:
npx shadcn@latest add https://zenblocks-three.vercel.app/r/modal-dialog.jsonMethod 2: Manual
-
Install Dependencies
npm install framer-motion react-dom -
Copy the Source Code
Copy the code below into
components/zenblocks/modal-dialog.tsx.Click to expand source
"use client"; import React, { createContext, useContext, useEffect, useRef, useState, useCallback, } from "react"; import { createPortal } from "react-dom"; import { AnimatePresence, motion, HTMLMotionProps } from "framer-motion"; import { X, Sparkles } from "lucide-react"; import { cn } from "@/lib/utils"; /* -------------------------------------------------------------------------- */ /* TYPES */ /* -------------------------------------------------------------------------- */ interface ModalContextValue { open: boolean; setOpen: (open: boolean) => void; titleId: string; descriptionId: string; } /* -------------------------------------------------------------------------- */ /* CONTEXT */ /* -------------------------------------------------------------------------- */ const ModalContext = createContext<ModalContextValue | undefined>(undefined); function useModal() { const context = useContext(ModalContext); if (!context) { throw new Error("Modal compound components must be used within <ModalDialog>"); } return context; } /* -------------------------------------------------------------------------- */ /* ROOT COMPONENT */ /* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */ /* Fix: Explicitly type children as ReactNode to avoid framer-motion clash */ /* -------------------------------------------------------------------------- */ interface ModalDialogProps { children: React.ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; defaultOpen?: boolean; } export function ModalDialog({ children, open: controlledOpen, onOpenChange, defaultOpen = false, }: ModalDialogProps) { const [internalOpen, setInternalOpen] = useState(defaultOpen); const isControlled = controlledOpen !== undefined; const open = isControlled ? controlledOpen : internalOpen; const setOpen = useCallback( (newOpen: boolean) => { if (!isControlled) { setInternalOpen(newOpen); } onOpenChange?.(newOpen); }, [isControlled, onOpenChange] ); const titleId = React.useId(); const descriptionId = React.useId(); return ( <ModalContext.Provider value={{ open: !!open, setOpen, titleId, descriptionId }}> {children} </ModalContext.Provider> ); } /* -------------------------------------------------------------------------- */ /* TRIGGER */ /* -------------------------------------------------------------------------- */ export function ModalTrigger({ children, asChild = false, className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }) { const { setOpen } = useModal(); if (asChild && React.isValidElement(children)) { return React.cloneElement(children as React.ReactElement<any>, { onClick: (e: React.MouseEvent) => { (children as React.ReactElement<any>).props.onClick?.(e); setOpen(true); }, ...props, }); } return ( <button onClick={() => setOpen(true)} className={className} {...props}> {children} </button> ); } /* -------------------------------------------------------------------------- */ /* PORTAL */ /* -------------------------------------------------------------------------- */ function Portal({ children }: { children: React.ReactNode }) { const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); if (!mounted || typeof document === "undefined") return null; return createPortal(children, document.body); } /* -------------------------------------------------------------------------- */ /* CONTENT */ /* -------------------------------------------------------------------------- */ interface ModalContentProps extends HTMLMotionProps<"div"> { children: React.ReactNode; overlayClassName?: string; hideCloseButton?: boolean; spotlightColor?: string; } export function ModalContent({ children, className, overlayClassName, hideCloseButton = false, spotlightColor = "rgba(255, 255, 255, 0.1)", ...props }: ModalContentProps) { const { open, setOpen, titleId, descriptionId } = useModal(); const contentRef = useRef<HTMLDivElement>(null); const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }); // Update spotlight on mouse move const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => { const rect = contentRef.current?.getBoundingClientRect(); if (rect) { setMousePosition({ x: e.clientX - rect.left, y: e.clientY - rect.top, }); } }; // Focus Trap useEffect(() => { if (!open) return; const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; const modal = contentRef.current; if (!modal) return; const firstElement = modal.querySelectorAll<HTMLElement>(focusableElements)[0]; const timer = setTimeout(() => firstElement?.focus(), 50); const handleTab = (e: KeyboardEvent) => { if (e.key === "Tab") { const focusable = modal.querySelectorAll<HTMLElement>(focusableElements); const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); last?.focus(); } } else { if (document.activeElement === last) { e.preventDefault(); first?.focus(); } } } }; modal.addEventListener("keydown", handleTab); return () => { modal.removeEventListener("keydown", handleTab); clearTimeout(timer); }; }, [open]); // Scroll Lock useEffect(() => { if (open) { document.body.style.overflow = "hidden"; document.body.style.paddingRight = "var(--scrollbar-width, 0px)"; } else { document.body.style.overflow = ""; document.body.style.paddingRight = ""; } return () => { document.body.style.overflow = ""; document.body.style.paddingRight = ""; }; }, [open]); // Escape Key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape" && open) { setOpen(false); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [open, setOpen]); return ( <Portal> <AnimatePresence> {open && ( <> {/* Spotlight Backdrop */} <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.3 }} className={cn( "fixed inset-0 z-[100] bg-zinc-950/60 backdrop-blur-md", overlayClassName )} onClick={() => setOpen(false)} aria-hidden="true" > <div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-indigo-500/10 via-transparent to-transparent opacity-50" /> </motion.div> {/* Dialog */} <div className="fixed inset-0 z-[101] flex items-center justify-center pointer-events-none p-4"> <motion.div ref={contentRef} role="dialog" aria-modal="true" aria-labelledby={titleId} aria-describedby={descriptionId} initial={{ opacity: 0, scale: 0.8, y: 20, rotateX: 10 }} animate={{ opacity: 1, scale: 1, y: 0, rotateX: 0 }} exit={{ opacity: 0, scale: 0.8, y: 20, transition: { duration: 0.2 } }} transition={{ type: "spring", stiffness: 300, damping: 25 }} className={cn( "pointer-events-auto relative w-full max-w-lg rounded-3xl bg-zinc-900 border border-white/10 shadow-2xl isolate max-h-[calc(100vh-2rem)] overflow-y-auto", className )} onMouseMove={handleMouseMove} {...props} > {/* Spotlight Cursor Effect */} <div className="absolute -inset-px pointer-events-none opacity-50 transition-opacity duration-300" style={{ background: `radial-gradient(600px circle at ${mousePosition.x}px ${mousePosition.y}px, ${spotlightColor}, transparent 40%)` }} /> <div className="relative z-10 bg-zinc-950/90 w-full min-h-full"> {children} </div> {!hideCloseButton && ( <button onClick={() => setOpen(false)} className="absolute right-4 top-4 z-50 rounded-full p-2 text-zinc-400 hover:text-white hover:bg-white/10 transition-colors" > <X className="h-4 w-4" /> <span className="sr-only">Close</span> </button> )} </motion.div> </div> </> )} </AnimatePresence> </Portal> ); } /* -------------------------------------------------------------------------- */ /* SUBCOMPONENTS */ /* -------------------------------------------------------------------------- */ export function ModalHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { return ( <div className={cn("flex flex-col space-y-1.5 p-6 pb-2 text-center sm:text-left", className)} {...props} /> ); } export function ModalTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) { const { titleId } = useModal(); return ( <h2 id={titleId} className={cn("text-xl font-bold tracking-tight text-white flex items-center gap-2", className)} {...props}> {props.children} </h2> ); } export function ModalDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) { const { descriptionId } = useModal(); return ( <p id={descriptionId} className={cn("text-sm text-zinc-400 leading-relaxed", className)} {...props} /> ); } export function ModalFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { return ( <div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3 px-6 pb-6 pt-4 bg-zinc-900/50 border-t border-white/5", className)} {...props} /> ); } export function ModalClose({ children, asChild, className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }) { const { setOpen } = useModal(); if (asChild && React.isValidElement(children)) { return React.cloneElement(children as React.ReactElement<any>, { onClick: (e: React.MouseEvent) => { (children as React.ReactElement<any>).props.onClick?.(e); setOpen(false); }, ...props, }); } return ( <button onClick={() => setOpen(false)} className={className} {...props}> {children} </button> ); } // Namespace exports for easier imports ModalDialog.Trigger = ModalTrigger; ModalDialog.Content = ModalContent; ModalDialog.Header = ModalHeader; ModalDialog.Title = ModalTitle; ModalDialog.Description = ModalDescription; ModalDialog.Footer = ModalFooter; ModalDialog.Close = ModalClose;
Usage
import { ModalDialog } from "@/components/zenblocks/modal-dialog";
<ModalDialog>
<ModalDialog.Trigger>
<button>Open</button>
</ModalDialog.Trigger>
<ModalDialog.Content>
<ModalDialog.Header>
<ModalDialog.Title>Title</ModalDialog.Title>
<ModalDialog.Description>Description</ModalDialog.Description>
</ModalDialog.Header>
<div className="p-6">
Your content here
</div>
<ModalDialog.Footer>
<ModalDialog.Close>Cancel</ModalDialog.Close>
<button>Confirm</button>
</ModalDialog.Footer>
</ModalDialog.Content>
</ModalDialog>Props
ModalDialog
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | - | Controlled open state |
| onOpenChange | (open: boolean) => void | - | Open state change handler |
| defaultOpen | boolean | false | Initial open state |
ModalDialog.Content
| Prop | Type | Default | Description |
|---|---|---|---|
| spotlightColor | string | rgba(255,255,255,0.1) | Spotlight glow color |
| overlayClassName | string | - | Backdrop styling classes |
| hideCloseButton | boolean | false | Hide the X close button |
Accessibility
- Uses
role="dialog"andaria-modal="true" - Focus trap cycles Tab navigation within modal
- Escape key closes modal
- Focus returns to trigger on close
- Proper
aria-labelledbyandaria-describedbyassociations
Customization
Spotlight Color
<ModalDialog.Content spotlightColor="rgba(99, 102, 241, 0.3)">
Content
</ModalDialog.Content>Backdrop Style
<ModalDialog.Content overlayClassName="bg-black/80 backdrop-blur-xl">
Content
</ModalDialog.Content>Motion Behavior
- Entry: Modal scales from 0.8 to 1.0 with spring physics (
stiffness: 300, damping: 25) - Backdrop: Fades in with 0.3s duration
- Spotlight: Radial gradient follows
mousemoveevents relative to modal bounds - Exit: Reverse scale animation with 0.2s duration
Performance Notes
- Uses
React.createPortalto render at document.body level - Scroll lock applied to body when modal is open
- Event listeners cleaned up on unmount
- Spotlight uses CSS
backgroundproperty (GPU-accelerated)
Examples
With Form
<ModalDialog>
<ModalDialog.Trigger>Subscribe</ModalDialog.Trigger>
<ModalDialog.Content>
<form onSubmit={handleSubmit}>
<input type="email" placeholder="Email" />
<button type="submit">Join</button>
</form>
</ModalDialog.Content>
</ModalDialog>Notes
- Requires
framer-motionandreact-dom - Portal ensures modal renders above all content
- Focus trap uses Tab key cycling logic
- Compound components can be used independently