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