Modal
Because a modal is a result of a decisive user decision, I opted to include a subtle opacity animation with an ease out curve. A spring or ease in out curve would feel like the component is jumping out at the user instead of smoothly transitioning into its reveal. I believe there’s possibility for a small scale shift when the element enters and leaves but the scale should not be less than 0.95 to prevent the component from jumping at you. I chose to build a custom hook to ensure the component remains headless utility allowing for users to assign refs by themselves or decide what triggers the opening of the dialog.
Code
// Modal.tsx
import {useEffect, useRef, useState} from "react";
import {AnimatePresence, motion} from 'motion/react';
import {type HTMLMotionProps } from 'motion/react'
import Button from "@components/Button";
import { buttonVariant, buttonCopy } from "@types";
import useMergedRefs from "src/lib/utils/mergedRefs";
interface DialogProps extends HTMLMotionProps<"dialog"> {
content: string;
ref?: React.Ref<HTMLDialogElement>
isOpen?: boolean
}
const Modal = ({ content, ref, isOpen: externalOpen, ...props}: DialogProps) => {
const dialogRef = useRef<HTMLDialogElement>(null);
const mergedRefs = useMergedRefs(ref, dialogRef);
const [dialogOpen, setIsDialogOpen] = useState(false);
const isOpen = externalOpen ?? dialogOpen;
const handleOpen = () => setIsDialogOpen(true)
const handleClose = (e?: React.SyntheticEvent) => {
if(e) {
e.preventDefault()
}
setIsDialogOpen(false)
}
const handleClick = (e?: React.MouseEvent<HTMLDialogElement>) => {
const dialog = dialogRef.current;
if (!dialog) return;
const rect = dialog.getBoundingClientRect();
if(!e) return
const outsideClick = (
e.clientX < rect.left || e.clientX > rect.right ||
e.clientY < rect.top || e.clientY > rect.bottom
)
if(outsideClick) {
handleClose();
}
}
useEffect(() => {
const dialog = dialogRef.current;
if(!dialog || !isOpen) return;
if(!dialog.open) {
dialog.showModal()
}
}, [isOpen])
useEffect(() => {
if(isOpen) {
document.body.style.overflow = 'hidden'
}
else {
document.body.style.overflow = 'unset'
}
return () => {
document.body.style.overflow = 'unset'
}
}, [isOpen])
return (
<div className = "relative">
<Button
variant={buttonVariant.Primary}
buttonCopy={buttonCopy.OPEN_DIALOG}
onClick={handleOpen}
/>
<AnimatePresence>
{isOpen === true && (
<motion.dialog
{...props}
ref = {mergedRefs}
onCancel={handleClose}
onClose={handleClose}
onClick = {handleClick}
initial = {{opacity: 0}}
animate = {{opacity: 1}}
exit = {{opacity: 0}}
transition = {{ease: 'easeOut', duration: 0.2}}
onAnimationComplete = {(definition) => {
if(definition === "exit" && dialogRef.current?.open) {dialogRef.current.close()}
}}
className = "w-[calc(100%-2rem)] max-w-md p-6 border border-gray-200 rounded-lg"
>
<div>
<h3 className = "mb-4 text-lg font-medium">Confirm Changes</h3>
<p className = "mb-6 text-gray-700">{content}</p>
<div className = "flex flex-row flex-wrap justify-end gap-2">
<Button
variant = {buttonVariant.Secondary}
buttonCopy = {buttonCopy.CANCEL}
onClick={handleClose}
/>
<Button
variant = {buttonVariant.Primary}
buttonCopy = {buttonCopy.CONFIRM}
/>
</div>
</div>
</motion.dialog>
)}
</AnimatePresence>
</div>
)
}
export default Modal