Morphing Dialog
A trigger card that morphs into a centered dialog via shared-layout animation, with focus trapping, scroll lock, Esc to close and reduced-motion support.
Example
Installation
npx shadcn@latest add https://hirael.com/r/morphing-dialog.jsonAPI
<MorphingDialog />
| Prop | Type | Default |
|---|---|---|
children* | React.ReactNode | — |
open | boolean | — |
defaultOpen | boolean | — |
onOpenChange | ((open: boolean) => void) | — |
<MorphingDialogTrigger />
+ native element propsNo props of its own — forwards everything to the underlying element.
<MorphingDialogContent />
+ native element propsNo props of its own — forwards everything to the underlying element.
<MorphingDialogTitle />
+ native element propsNo props of its own — forwards everything to the underlying element.
<MorphingDialogDescription />
+ native element propsNo props of its own — forwards everything to the underlying element.
<MorphingDialogClose />
+ native element propsNo props of its own — forwards everything to the underlying element.
Component source
"use client";
import * as React from "react";
import { createPortal } from "react-dom";
import {
AnimatePresence,
HTMLMotionProps,
MotionConfig,
motion,
} from "motion/react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
type MorphingDialogContextValue = {
isOpen: boolean;
open: () => void;
close: () => void;
uniqueId: string;
titleId: string;
descriptionId: string;
hasTitle: boolean;
hasDescription: boolean;
setHasTitle: React.Dispatch<React.SetStateAction<boolean>>;
setHasDescription: React.Dispatch<React.SetStateAction<boolean>>;
triggerRef: React.RefObject<HTMLDivElement | null>;
};
const MorphingDialogContext =
React.createContext<MorphingDialogContextValue | null>(null);
function useMorphingDialog() {
const ctx = React.useContext(MorphingDialogContext);
if (!ctx) {
throw new Error(
"MorphingDialog parts must be used within <MorphingDialog>",
);
}
return ctx;
}
type MorphingDialogProps = {
children: React.ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
};
function MorphingDialog({
children,
open: openProp,
defaultOpen,
onOpenChange,
}: MorphingDialogProps) {
const [uncontrolled, setUncontrolled] = React.useState(defaultOpen ?? false);
const isOpen = openProp ?? uncontrolled;
const reactId = React.useId();
const triggerRef = React.useRef<HTMLDivElement | null>(null);
const [hasTitle, setHasTitle] = React.useState(false);
const [hasDescription, setHasDescription] = React.useState(false);
const setOpen = React.useCallback(
(next: boolean) => {
if (openProp === undefined) setUncontrolled(next);
onOpenChange?.(next);
},
[openProp, onOpenChange],
);
const value = React.useMemo<MorphingDialogContextValue>(
() => ({
isOpen,
open: () => setOpen(true),
close: () => setOpen(false),
uniqueId: reactId,
titleId: `${reactId}-title`,
descriptionId: `${reactId}-description`,
hasTitle,
hasDescription,
setHasTitle,
setHasDescription,
triggerRef,
}),
[isOpen, setOpen, reactId, hasTitle, hasDescription],
);
return (
<MorphingDialogContext.Provider value={value}>
<MotionConfig reducedMotion="user">{children}</MotionConfig>
</MorphingDialogContext.Provider>
);
}
type MorphingDialogTriggerProps = HTMLMotionProps<"div">;
function MorphingDialogTrigger({
className,
children,
style,
...props
}: MorphingDialogTriggerProps) {
const { open, isOpen, uniqueId, triggerRef } = useMorphingDialog();
return (
<motion.div
{...props}
ref={triggerRef}
layoutId={`morphing-dialog-${uniqueId}`}
data-slot="morphing-dialog-trigger"
role="button"
tabIndex={0}
aria-expanded={isOpen}
onClick={open}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
open();
}
}}
style={{ borderRadius: 12, ...style }}
className={cn(
"cursor-pointer overflow-hidden border border-border bg-card text-card-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
{children}
</motion.div>
);
}
function focusableWithin(container: HTMLElement) {
return Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])',
),
);
}
type MorphingDialogContentProps = HTMLMotionProps<"div">;
function MorphingDialogContent({
className,
children,
style,
...props
}: MorphingDialogContentProps) {
const {
isOpen,
close,
uniqueId,
titleId,
descriptionId,
hasTitle,
hasDescription,
triggerRef,
} = useMorphingDialog();
const panelRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!isOpen) return;
const panel = panelRef.current;
const trigger = triggerRef.current;
panel?.focus();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
close();
return;
}
if (event.key === "Tab" && panel) {
const focusables = focusableWithin(panel);
if (!focusables.length) {
event.preventDefault();
panel.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
if (event.shiftKey && (active === first || active === panel)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
}
};
document.addEventListener("keydown", onKeyDown);
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = previousOverflow;
trigger?.focus();
};
}, [isOpen, close, triggerRef]);
if (typeof document === "undefined") return null;
return createPortal(
<AnimatePresence>
{isOpen ? (
<div data-slot="morphing-dialog-portal">
<motion.div
aria-hidden
data-slot="morphing-dialog-overlay"
className="fixed inset-0 z-50 bg-background/70 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={close}
/>
<div
data-slot="morphing-dialog-positioner"
className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center p-4"
>
<motion.div
{...props}
ref={panelRef}
layoutId={`morphing-dialog-${uniqueId}`}
data-slot="morphing-dialog-content"
role="dialog"
aria-modal="true"
aria-labelledby={hasTitle ? titleId : undefined}
aria-describedby={hasDescription ? descriptionId : undefined}
tabIndex={-1}
style={{ borderRadius: 12, ...style }}
className={cn(
"pointer-events-auto relative overflow-hidden border border-border bg-card text-card-foreground shadow-lg focus:outline-none",
className,
)}
>
{children}
</motion.div>
</div>
</div>
) : null}
</AnimatePresence>,
document.body,
);
}
type MorphingDialogTitleProps = React.ComponentProps<"h2">;
function MorphingDialogTitle({
className,
...props
}: MorphingDialogTitleProps) {
const { titleId, setHasTitle } = useMorphingDialog();
React.useEffect(() => {
setHasTitle(true);
return () => setHasTitle(false);
}, [setHasTitle]);
return (
<h2
id={titleId}
data-slot="morphing-dialog-title"
className={cn(
"text-lg font-semibold tracking-tight text-foreground",
className,
)}
{...props}
/>
);
}
type MorphingDialogDescriptionProps = React.ComponentProps<"p">;
function MorphingDialogDescription({
className,
...props
}: MorphingDialogDescriptionProps) {
const { descriptionId, setHasDescription } = useMorphingDialog();
React.useEffect(() => {
setHasDescription(true);
return () => setHasDescription(false);
}, [setHasDescription]);
return (
<p
id={descriptionId}
data-slot="morphing-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
type MorphingDialogCloseProps = React.ComponentProps<"button">;
function MorphingDialogClose({
className,
children,
...props
}: MorphingDialogCloseProps) {
const { close } = useMorphingDialog();
return (
<button
type="button"
data-slot="morphing-dialog-close"
onClick={close}
aria-label="Close"
className={cn(
"absolute end-3 top-3 inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
{...props}
>
{children ?? <X className="size-4" />}
</button>
);
}
export {
MorphingDialog,
MorphingDialogTrigger,
MorphingDialogContent,
MorphingDialogTitle,
MorphingDialogDescription,
MorphingDialogClose,
};
Dependencies
npm
lucide-reactmotion