Skip to content

Instantly share code, notes, and snippets.

@Jordan-Gilliam
Last active August 6, 2024 19:33
Show Gist options
  • Save Jordan-Gilliam/3817e9b85e7071f93c9fa0f5db78aaaa to your computer and use it in GitHub Desktop.
Save Jordan-Gilliam/3817e9b85e7071f93c9fa0f5db78aaaa to your computer and use it in GitHub Desktop.
"use client";
import React, { useEffect, useId, useState } from "react";
import { motion, AnimatePresence, MotionConfig } from "framer-motion";
import { PlusIcon, XIcon } from "lucide-react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { buttonVariants } from "../button";
const transition = {
type: "spring",
bounce: 0.05,
duration: 0.3,
};
type WithClassName = {
className?: string;
};
type WithUrl = {
url?: string;
};
// DialogContext
interface DialogContextType {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
uniqueId: string;
}
const DialogContext = React.createContext<DialogContextType | null>(null);
// DialogRoot
const DialogRoot: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const uniqueId = useId();
return (
<DialogContext.Provider value={{ isOpen, setIsOpen, uniqueId }}>
<MotionConfig transition={transition}>{children}</MotionConfig>
</DialogContext.Provider>
);
};
// DialogTrigger
const DialogTrigger: React.FC<React.PropsWithChildren<WithClassName>> = ({
children,
className,
}) => {
const context = React.useContext(DialogContext);
if (!context)
throw new Error("DialogTrigger must be used within a DialogRoot");
return (
<motion.div
className={cn("relative cursor-pointer", className)}
initial="initial"
whileHover="animate"
animate={context.isOpen ? "open" : "closed"}
onClick={() => context.setIsOpen(!context.isOpen)}
>
<motion.div
layoutId={`dialog-container-${context.uniqueId}`}
className={cn(
"flex flex-col overflow-hidden border bg-card border-border"
)}
style={{ borderRadius: context.isOpen ? "24px" : "12px" }}
>
{children}
</motion.div>
</motion.div>
);
};
// DialogContent
const DialogContent: React.FC<React.PropsWithChildren<WithClassName>> = ({
children,
className,
}) => {
const context = React.useContext(DialogContext);
if (!context)
throw new Error("DialogContent must be used within a DialogRoot");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
useEffect(() => {
if (context.isOpen) {
document.body.classList.add("overflow-hidden");
} else {
document.body.classList.remove("overflow-hidden");
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
context.setIsOpen(false);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [context.isOpen]);
if (!mounted) return null;
return createPortal(
<AnimatePresence initial={false} mode="sync">
{context.isOpen && (
<>
<motion.div
key={`backdrop-${context.uniqueId}`}
className="fixed inset-0 z-50 backdrop-blur-sm bg-black/40 dark:bg-white/40"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => context.setIsOpen(false)}
/>
<div className="fixed inset-0 z-50 flex items-center justify-center">
<motion.div
layoutId={`dialog-container-${context.uniqueId}`}
className={cn(
"w-full max-w-lg overflow-hidden bg-card",
className
)}
style={{ borderRadius: "24px" }}
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
>
{children}
</motion.div>
</div>
</>
)}
</AnimatePresence>,
document.body
);
};
// DialogImage
const DialogImage: React.FC<{
src: string;
alt?: string;
className?: string;
}> = ({ src, alt, className }) => {
const context = React.useContext(DialogContext);
if (!context) throw new Error("DialogImage must be used within a DialogRoot");
return (
<motion.div
layoutId={`dialog-img-${context.uniqueId}`}
className={cn(className)}
>
<motion.img
src={src}
alt={alt}
className={cn("w-full h-auto")}
layout
transition={transition}
/>
</motion.div>
);
};
// DialogTitle
const DialogTitle: React.FC<React.PropsWithChildren<WithClassName>> = ({
children,
className,
}) => {
const context = React.useContext(DialogContext);
if (!context) throw new Error("DialogTitle must be used within a DialogRoot");
return (
<motion.div
layoutId={`dialog-title-${context.uniqueId}`}
className={cn("text-2xl text-zinc-950 dark:text-zinc-50", className)}
layout
transition={transition}
>
{children}
</motion.div>
);
};
// DialogSubtitle
const DialogSubtitle: React.FC<React.PropsWithChildren<WithClassName>> = ({
children,
className,
}) => {
const context = React.useContext(DialogContext);
if (!context)
throw new Error("DialogSubtitle must be used within a DialogRoot");
return (
<motion.div
layoutId={`dialog-subtitle-${context.uniqueId}`}
className={cn("text-primary", className)}
layout
transition={transition}
>
{children}
</motion.div>
);
};
// DialogDescription
const DialogDescription: React.FC<React.PropsWithChildren<WithClassName>> = ({
children,
className,
}) => {
const context = React.useContext(DialogContext);
if (!context)
throw new Error("DialogDescription must be used within a DialogRoot");
return (
<motion.div
className={cn(className)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ delay: 0.2, ...transition }}
>
{children}
</motion.div>
);
};
// DialogBody
const DialogBody: React.FC<React.PropsWithChildren<WithClassName>> = ({
children,
className,
}) => {
const context = React.useContext(DialogContext);
if (!context) throw new Error("DialogBody must be used within a DialogRoot");
return (
<motion.div className={cn("p-6", className)} layout>
{children}
</motion.div>
);
};
// DialogClose
const DialogClose: React.FC<React.PropsWithChildren<WithClassName>> = ({
children,
className,
}) => {
const context = React.useContext(DialogContext);
if (!context) throw new Error("DialogClose must be used within a DialogRoot");
return (
<button
onClick={() => context.setIsOpen(false)}
className={cn("absolute right-6 top-6 text-primary", className)}
type="button"
aria-label="Close dialog"
>
{children}
</button>
);
};
const DialogOpen: React.FC<React.PropsWithChildren<WithClassName>> = ({
children,
className,
}) => {
const context = React.useContext(DialogContext);
if (!context) throw new Error("DialogClose must be used within a DialogRoot");
return (
<motion.button
type="button"
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"rounded-full ",
className
)}
aria-label="Open dialog"
initial={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{children}
</motion.button>
);
};
const DialogTriggerContent: React.FC<
React.PropsWithChildren<WithClassName>
> = ({ children, className }) => {
const context = React.useContext(DialogContext);
if (!context) throw new Error("DialogClose must be used within a DialogRoot");
return (
<motion.div
className={cn(
"flex flex-grow flex-row items-end justify-between p-2",
className
)}
>
{children}
</motion.div>
);
};
const DialogLink: React.FC<
React.PropsWithChildren<WithClassName> & WithUrl
> = ({ children, className, url }) => {
const context = React.useContext(DialogContext);
if (!context) throw new Error("DialogClose must be used within a DialogRoot");
return (
<motion.a
className={cn(
"mt-2 inline-flex text-muted-foreground underline",
className
)}
href={url}
target="_blank"
rel="noopener noreferrer"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ delay: 0.3 }}
>
{children}
</motion.a>
);
};
// Usage example
function HeadlessDialogPrimitive() {
return (
<DialogRoot>
<DialogTrigger>
<DialogImage src="/basic-img.png" className="max-w-96" />
<DialogTriggerContent>
<div>
<DialogTitle>EB27</DialogTitle>
<DialogSubtitle>Edouard Wilfrid Buquet</DialogSubtitle>
</div>
<DialogOpen>
<PlusIcon size={12} />
</DialogOpen>
</DialogTriggerContent>
</DialogTrigger>
<DialogContent>
<div className="relative">
<DialogImage src="/basic-img.png" alt="EB27 lamp" />
<DialogBody>
<DialogTitle>EB27</DialogTitle>
<DialogSubtitle>Edouard Wilfrid Buquet</DialogSubtitle>
<DialogDescription>
<p className="mt-2 text-muted-foreground">
Little is known about the life of Édouard-Wilfrid Buquet. He was
born in France in 1866, but the time and place of his death is
unfortunately a mystery.
</p>
<p className="mt-2 text-muted-foreground">
Research conducted in the 1970s revealed that he'd designed the
"EB 27" double-arm desk lamp in 1925, handcrafting it from
nickel-plated brass, aluminium and varnished wood.
</p>
<DialogLink url="https://www.are.na/block/12759029" />
</DialogDescription>
</DialogBody>
<DialogClose>
<XIcon size={24} />
</DialogClose>
</div>
</DialogContent>
</DialogRoot>
);
}
export default HeadlessDialogPrimitive;
export {
DialogRoot,
DialogTrigger,
DialogContent,
DialogClose,
DialogImage,
DialogTitle,
DialogSubtitle,
DialogDescription,
DialogBody,
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment