sona-ui
uianimated-dialogsource: ok · hit
Animated Dialog
A composable, accessible dialog modal built on Base UI with controlled state, direction-aware enter and exit motion, and reduced-motion support.
npx shadcn add "https://sonaui.com/r/animated-dialog.json"free
Where it goes
stats
Interaction
animated-countermodal
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumui-primitives@base-ui/react
animationmotion
Registry dependencies
@sona-ui/sona-utils@sona-ui/sona-motion
Files1 · source cached 10/14/2026, 3:22:41 PM
registry/sonaui/animated-dialog/animated-dialog.tsx
"use client";
import { Dialog } from "@base-ui/react/dialog";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { createContext, type ReactNode, useContext, useState } from "react";
import { motionTransition } from "@/lib/sona-motion";
import { cn } from "@/lib/sona-utils";
// ─── Context ─────────────────────────────────────────────────────────────────
interface DialogContextValue {
open: boolean;
setOpen: (open: boolean) => void;
}
const DialogContext = createContext<DialogContextValue | null>(null);
function useDialogContext() {
const ctx = useContext(DialogContext);
if (!ctx) {
throw new Error(
"AnimatedDialog subcomponents must be used within <AnimatedDialog>",
);
}
return ctx;
}
// ─── Types ────────────────────────────────────────────────────────────────────
export interface AnimatedDialogProps {
children: ReactNode;
open?: boolean;
/** Initial open state for uncontrolled usage. @default false */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Whether the dialog traps interaction inside the modal. @default true */
modal?: boolean;
}
export interface AnimatedDialogContentProps {
children: ReactNode;
/**
* Direction the dialog slides in from.
* @default "bottom"
*/
from?: "top" | "bottom" | "left" | "right" | "center";
/**
* Direction the dialog slides out to. Defaults to reversing the entrance.
*/
exitTo?: "top" | "bottom" | "left" | "right" | "center";
className?: string;
/**
* Class name for the backdrop overlay.
*/
backdropClassName?: string;
}
// ─── Root ─────────────────────────────────────────────────────────────────────
export function AnimatedDialog({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
modal = true,
}: AnimatedDialogProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const setOpen = (nextOpen: boolean) => {
if (!isControlled) {
setInternalOpen(nextOpen);
}
onOpenChange?.(nextOpen);
};
return (
<DialogContext.Provider value={{ open, setOpen }}>
<Dialog.Root
open={open}
defaultOpen={defaultOpen}
modal={modal}
onOpenChange={setOpen}
>
{children}
</Dialog.Root>
</DialogContext.Provider>
);
}
// ─── Trigger ──────────────────────────────────────────────────────────────────
export function AnimatedDialogTrigger({
children,
className,
...props
}: Dialog.Trigger.Props) {
return (
<Dialog.Trigger
className={cn(
"inline-flex items-center justify-center rounded-lg px-4 py-2",
"bg-foreground text-background text-sm font-medium",
"hover:bg-foreground/90 hover:cursor-pointer transition-colors duration-150",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
className,
)}
{...props}
>
{children}
</Dialog.Trigger>
);
}
// ─── Content ──────────────────────────────────────────────────────────────────
const motionVariants = {
initial: (from: string) => {
switch (from) {
case "top":
return { y: -24, opacity: 0, scale: 0.97 };
case "bottom":
return { y: 24, opacity: 0, scale: 0.97 };
case "left":
return { x: -24, opacity: 0, scale: 0.97 };
case "right":
return { x: 24, opacity: 0, scale: 0.97 };
default:
return { scale: 0.95, opacity: 0 };
}
},
animate: {
x: 0,
y: 0,
scale: 1,
opacity: 1,
},
exit: (exitTo: string) => {
switch (exitTo) {
case "top":
return { y: -20, opacity: 0, scale: 0.97 };
case "bottom":
return { y: 20, opacity: 0, scale: 0.97 };
case "left":
return { x: -20, opacity: 0, scale: 0.97 };
case "right":
return { x: 20, opacity: 0, scale: 0.97 };
default:
return { scale: 0.95, opacity: 0 };
}
},
};
export function AnimatedDialogContent({
children,
from = "bottom",
exitTo,
className,
backdropClassName,
}: AnimatedDialogContentProps) {
const { open } = useDialogContext();
const shouldReduceMotion = useReducedMotion();
const resolvedExitTo = exitTo ?? from;
return (
<Dialog.Portal>
<AnimatePresence custom={resolvedExitTo}>
{open && (
<>
{/* Backdrop Overlay */}
<Dialog.Backdrop
render={
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={
shouldReduceMotion
? motionTransition.reduced
: motionTransition.enter
}
className={cn(
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
backdropClassName,
)}
/>
}
/>
{/* Positioner centering the popup */}
<div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Popup
className="pointer-events-auto"
render={
<motion.div
custom={from}
variants={shouldReduceMotion ? {} : motionVariants}
initial="initial"
animate="animate"
exit="exit"
transition={
shouldReduceMotion
? motionTransition.reduced
: motionTransition.enter
}
className={cn(
"w-full max-w-md overflow-hidden rounded-2xl p-6",
"bg-popover text-popover-foreground shadow-2xl",
"border border-border/80",
className,
)}
>
{children}
</motion.div>
}
/>
</div>
</>
)}
</AnimatePresence>
</Dialog.Portal>
);
}
// ─── Title ────────────────────────────────────────────────────────────────────
export function AnimatedDialogTitle({
className,
...props
}: Dialog.Title.Props) {
return (
<Dialog.Title
className={cn(
"text-lg font-semibold tracking-tight text-foreground",
className,
)}
{...props}
/>
);
}
// ─── Description ──────────────────────────────────────────────────────────────
export function AnimatedDialogDescription({
className,
...props
}: Dialog.Description.Props) {
return (
<Dialog.Description
className={cn("mt-1.5 text-sm text-muted-foreground", className)}
{...props}
/>
);
}
// ─── Close ────────────────────────────────────────────────────────────────────
export function AnimatedDialogClose({
className,
...props
}: Dialog.Close.Props) {
return (
<Dialog.Close
className={cn(
"inline-flex items-center justify-center rounded-lg px-4 py-2",
"bg-secondary text-secondary-foreground text-sm font-medium",
"hover:bg-muted transition-colors duration-150",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
className,
)}
{...props}
/>
);
}