Animated Dialog

PreviousNext

Modal dialog with backdrop fade and spring animation

Docs
uitripledcomponent

Preview

Loading preview…
components/modals/animated-dialog.tsx
"use client";

import { Button } from "@/components/ui/button";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { X } from "lucide-react";
import { useState } from "react";

export function AnimatedDialog() {
  const [isOpen, setIsOpen] = useState(false);
  const shouldReduceMotion = useReducedMotion();

  const dialogAnimation = shouldReduceMotion
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
    : {
        initial: { opacity: 0, scale: 0.97, y: 16 },
        animate: { opacity: 1, scale: 1, y: 0 },
        exit: { opacity: 0, scale: 0.97, y: 16 },
      };

  return (
    <div>
      <Button className="w-full" onClick={() => setIsOpen(true)}>
        Open Dialog
      </Button>

      <AnimatePresence>
        {isOpen && (
          <>
            <motion.div
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.25, ease: "easeOut" }}
              className="fixed inset-0 z-50 bg-background/70 backdrop-blur-sm"
              onClick={() => setIsOpen(false)}
            />

            <div className="fixed inset-0 z-50 flex items-center justify-center px-4 py-10">
              <motion.div
                {...dialogAnimation}
                transition={{
                  duration: shouldReduceMotion ? 0.2 : 0.4,
                  ease: shouldReduceMotion ? "linear" : [0.16, 1, 0.3, 1],
                }}
                className="relative w-full max-w-md rounded-3xl border border-border/60 bg-card/90 p-6 shadow-[0_30px_120px_-40px_rgba(15,23,42,0.75)] backdrop-blur-xl"
                onClick={(event) => event.stopPropagation()}
                role="dialog"
                aria-modal="true"
                aria-labelledby="dialog-title"
                aria-describedby="dialog-description"
              >
                <button
                  onClick={() => setIsOpen(false)}
                  className="absolute right-4 top-4 rounded-full border border-border/40 bg-white/5 p-2 text-muted-foreground transition-colors hover:text-foreground"
                  aria-label="Close dialog"
                >
                  <motion.span
                    animate={
                      shouldReduceMotion
                        ? undefined
                        : { rotate: [0, 15, -15, 0] }
                    }
                    transition={
                      shouldReduceMotion
                        ? undefined
                        : { duration: 1.8, repeat: Infinity, ease: "easeInOut" }
                    }
                  >
                    <X className="h-4 w-4" />
                  </motion.span>
                </button>

                <motion.div
                  initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 10 }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{
                    delay: shouldReduceMotion ? 0 : 0.08,
                    duration: 0.25,
                  }}
                >
                  <div className="mb-3 inline-flex items-center gap-2 rounded-full border border-border/60 bg-white/5 px-3 py-1 text-xs uppercase tracking-[0.28em] text-muted-foreground">
                    Confirm
                  </div>
                  <h2
                    id="dialog-title"
                    className="text-xl font-semibold text-foreground"
                  >
                    Confirm Action
                  </h2>
                  <p
                    id="dialog-description"
                    className="mt-2 text-sm leading-relaxed text-muted-foreground"
                  >
                    Are you sure you want to perform this action? This can’t be
                    undone and may affect related data.
                  </p>
                </motion.div>

                <motion.div
                  className="mt-6 flex gap-3"
                  initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 8 }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{
                    delay: shouldReduceMotion ? 0 : 0.14,
                    duration: 0.25,
                  }}
                >
                  <Button
                    variant="outline"
                    onClick={() => setIsOpen(false)}
                    className="flex-1 rounded-full border-border/60 bg-card/60 text-foreground"
                  >
                    Cancel
                  </Button>
                  <Button
                    onClick={() => setIsOpen(false)}
                    className="flex-1 rounded-full bg-primary text-primary-foreground shadow-[0_15px_35px_-20px_rgba(79,70,229,0.6)]"
                  >
                    Confirm
                  </Button>
                </motion.div>
              </motion.div>
            </div>
          </>
        )}
      </AnimatePresence>
    </div>
  );
}

Installation

npx shadcn@latest add @uitripled/animated-dialog

Usage

import { AnimatedDialog } from "@/components/animated-dialog"
<AnimatedDialog />