Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion, type PanInfo } from "motion/react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";

export interface AnimatedModalProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  title: React.ReactNode;
  description?: React.ReactNode;
  children?: React.ReactNode;
  /** Action row (buttons) pinned to the bottom. */
  footer?: React.ReactNode;
  /** "auto" = centred dialog on wide screens, bottom sheet below `sheetBreakpoint`. */
  variant?: "auto" | "dialog" | "sheet";
  /** Width in px under which "auto" switches to the bottom sheet. */
  sheetBreakpoint?: number;
  size?: "sm" | "md" | "lg";
  /** Close when the backdrop is clicked. */
  dismissible?: boolean;
  /** Element to focus when opened (defaults to the first focusable element). */
  initialFocus?: React.RefObject<HTMLElement | null>;
  showClose?: boolean;
  className?: string;
}

const FOCUSABLE =
  'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]):not([type="hidden"]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';

function useMediaQuery(query: string) {
  const subscribe = React.useCallback(
    (cb: () => void) => {
      const mq = window.matchMedia(query);
      mq.addEventListener("change", cb);
      return () => mq.removeEventListener("change", cb);
    },
    [query],
  );
  return React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);
}

const widths = { sm: "sm:max-w-sm", md: "sm:max-w-md", lg: "sm:max-w-lg" };

export function AnimatedModal({
  open,
  onOpenChange,
  title,
  description,
  children,
  footer,
  variant = "auto",
  sheetBreakpoint = 640,
  size = "md",
  dismissible = true,
  initialFocus,
  showClose = true,
  className,
}: AnimatedModalProps) {
  const reduce = useReducedMotion();
  const narrow = useMediaQuery(`(max-width: ${sheetBreakpoint - 0.02}px)`);
  const sheet = variant === "sheet" || (variant === "auto" && narrow);
  const panelRef = React.useRef<HTMLDivElement>(null);
  const titleId = React.useId();
  const descId = React.useId();
  const close = React.useCallback(() => onOpenChange(false), [onOpenChange]);

  // Remember what had focus, move focus in, lock scroll; undo all of it on close.
  React.useEffect(() => {
    if (!open) return;
    const previous = document.activeElement as HTMLElement | null;
    const { overflow } = document.body.style;
    document.body.style.overflow = "hidden";
    const id = requestAnimationFrame(() => {
      const panel = panelRef.current;
      // Prefer the first control in the body (e.g. an input) over the close button.
      const target =
        initialFocus?.current ??
        panel?.querySelector<HTMLElement>(`[data-modal-body] :is(${FOCUSABLE})`) ??
        panel?.querySelector<HTMLElement>(FOCUSABLE) ??
        panel;
      target?.focus({ preventScroll: true });
    });
    return () => {
      cancelAnimationFrame(id);
      document.body.style.overflow = overflow;
      previous?.focus?.({ preventScroll: true });
    };
  }, [open, initialFocus]);

  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    if (e.key === "Escape") {
      e.stopPropagation();
      close();
      return;
    }
    if (e.key !== "Tab" || !panelRef.current) return;
    const items = Array.from(panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)).filter((el) => el.offsetParent !== null);
    if (items.length === 0) {
      e.preventDefault();
      return;
    }
    const first = items[0];
    const last = items[items.length - 1];
    if (e.shiftKey && (document.activeElement === first || document.activeElement === panelRef.current)) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  };

  const onDragEnd = (_: PointerEvent | MouseEvent | TouchEvent, info: PanInfo) => {
    if (info.offset.y > 120 || info.velocity.y > 700) close();
  };

  const spring = { type: "spring", stiffness: 380, damping: 30, mass: 0.8 } as const;
  const dialogMotion = reduce
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
    : {
        initial: { opacity: 0, scale: 0.9, y: 16, filter: "blur(10px)" },
        animate: { opacity: 1, scale: 1, y: 0, filter: "blur(0px)" },
        exit: { opacity: 0, scale: 0.95, y: 8, filter: "blur(6px)", transition: { duration: 0.18 } },
      };
  const sheetMotion = reduce
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
    : { initial: { y: "100%" }, animate: { y: 0 }, exit: { y: "100%", transition: { duration: 0.25, ease: [0.4, 0, 1, 1] as const } } };

  return (
    <AnimatePresence>
      {open && (
        <div className={cn("fixed inset-0 z-50 flex justify-center", sheet ? "items-end" : "items-center p-4")} onKeyDown={onKeyDown}>
          <motion.div
            aria-hidden
            className="absolute inset-0 bg-black/40 backdrop-blur-[3px]"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0, transition: { duration: 0.2 } }}
            onClick={dismissible ? close : undefined}
          />
          <motion.div
            ref={panelRef}
            key={sheet ? "sheet" : "dialog"}
            role="dialog"
            aria-modal="true"
            aria-labelledby={titleId}
            aria-describedby={description ? descId : undefined}
            tabIndex={-1}
            {...(sheet ? sheetMotion : dialogMotion)}
            transition={spring}
            drag={sheet && !reduce ? "y" : false}
            dragConstraints={{ top: 0, bottom: 0 }}
            dragElastic={{ top: 0.05, bottom: 0.9 }}
            onDragEnd={sheet ? onDragEnd : undefined}
            className={cn(
              "relative flex w-full flex-col bg-popover text-popover-foreground shadow-2xl outline-none",
              sheet
                ? "max-h-[88dvh] rounded-t-3xl border-t pb-[max(1rem,env(safe-area-inset-bottom))]"
                : cn("max-h-[calc(100dvh-2rem)] rounded-2xl border", widths[size]),
              className,
            )}
          >
            {sheet && (
              <div className="flex cursor-grab justify-center pb-1 pt-3 active:cursor-grabbing" aria-hidden>
                <span className="h-1.5 w-10 rounded-full bg-muted-foreground/30" />
              </div>
            )}
            <div className={cn("flex items-start gap-4 px-6", sheet ? "pt-2" : "pt-6")}>
              <div className="min-w-0 flex-1">
                <h2 id={titleId} className="text-lg font-semibold tracking-tight">
                  {title}
                </h2>
                {description && (
                  <p id={descId} className="mt-1 text-sm text-muted-foreground">
                    {description}
                  </p>
                )}
              </div>
              {showClose && (
                <button
                  type="button"
                  onClick={close}
                  aria-label="Close"
                  className="-mr-2 -mt-1 rounded-full p-2 text-muted-foreground outline-none transition hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
                >
                  <X className="size-4" />
                </button>
              )}
            </div>
            {children && (
              <div data-modal-body className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
                {children}
              </div>
            )}
            {footer && (
              <div className={cn("flex flex-col-reverse gap-2 px-6 pb-6 sm:flex-row sm:justify-end", !children && "pt-4")}>{footer}</div>
            )}
          </motion.div>
        </div>
      )}
    </AnimatePresence>
  );
}

More in Overlays & Feedback

View all →