Center Morph Modal
NewA composable modal whose full-size surface unfolds from its exact center toward every edge, then folds back the same way with an inset close control.
Updated
"use client";
import { ArrowUpRight, Check } from "lucide-react";
import {
CenterMorphModal,
CenterMorphModalContent,
CenterMorphModalTrigger,
} from "@/components/motion/center-morph-modal";
export function CenterMorphModalPreview() {
return (
<div className="flex min-h-[420px] w-full items-center justify-center">
<CenterMorphModal>
<CenterMorphModalTrigger>
<button
type="button"
className="inline-flex h-10 items-center justify-center rounded-full bg-foreground px-5 text-sm font-medium text-background press focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
Open modal
</button>
</CenterMorphModalTrigger>
<CenterMorphModalContent
ariaLabel="beUI Pro"
ariaDescribedBy="center-morph-pro-description"
>
<div className="p-7 sm:p-8">
<p className="text-sm font-medium text-muted-foreground">
beUI Pro
</p>
<h2 className="mt-5 max-w-xs pr-8 text-2xl font-medium tracking-tight text-foreground">
Ship the whole experience.
</h2>
<p
id="center-morph-pro-description"
className="mt-3 text-sm leading-relaxed text-muted-foreground"
>
Go beyond individual components with premium animated sections
and complete Next.js templates.
</p>
<div className="mt-7 space-y-3 border-y border-border py-5">
{[
"Premium animated sections",
"Complete Next.js templates",
"Editable source and private registry",
].map((feature) => (
<div
key={feature}
className="flex items-center gap-3 text-sm text-foreground"
>
<Check
className="h-4 w-4 text-muted-foreground"
aria-hidden="true"
/>
<span>{feature}</span>
</div>
))}
</div>
<a
href="https://pro.beui.dev/?utm_source=beui&utm_medium=component_preview&utm_campaign=center_morph_modal"
target="_blank"
rel="noreferrer"
className="mt-7 inline-flex h-11 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-sm font-medium text-background press focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
Explore beUI Pro
<ArrowUpRight className="h-4 w-4" aria-hidden="true" />
</a>
</div>
</CenterMorphModalContent>
</CenterMorphModal>
</div>
);
}
"use client";
// beui.dev/components/motion/center-morph-modal
import { X } from "lucide-react";
import {
AnimatePresence,
motion,
useIsPresent,
useReducedMotion,
} from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type CenterMorphModalContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
triggerId: string;
contentId: string;
};
const CenterMorphModalContext =
createContext<CenterMorphModalContextValue | null>(null);
function useCenterMorphModalContext(component: string) {
const context = useContext(CenterMorphModalContext);
if (!context) {
throw new Error(`${component} must be used within <CenterMorphModal>`);
}
return context;
}
export interface CenterMorphModalProps {
children: ReactNode;
/** Controlled open state. */
open?: boolean;
/** Initial state when used uncontrolled. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
/**
* A modal whose full-size surface unfolds outward from its exact center.
* Supports controlled and uncontrolled state through composable primitives.
*/
export function CenterMorphModal({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
}: CenterMorphModalProps) {
const id = useId();
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
const value = useMemo<CenterMorphModalContextValue>(
() => ({
open,
setOpen,
triggerId: `${id}-trigger`,
contentId: `${id}-content`,
}),
[id, open, setOpen],
);
return (
<CenterMorphModalContext.Provider value={value}>
{children}
</CenterMorphModalContext.Provider>
);
}
export interface CenterMorphModalTriggerProps {
children: ReactElement;
}
/** Wraps one interactive element and opens or closes the modal. */
export function CenterMorphModalTrigger({
children,
}: CenterMorphModalTriggerProps) {
const context = useCenterMorphModalContext("CenterMorphModalTrigger");
if (!isValidElement(children)) return children;
const child = children as ReactElement<Record<string, unknown>>;
const childOnClick = child.props.onClick as
| ((event: React.MouseEvent<HTMLElement>) => void)
| undefined;
return cloneElement(child, {
id: context.triggerId,
onClick: (event: React.MouseEvent<HTMLElement>) => {
childOnClick?.(event);
if (!event.defaultPrevented) context.setOpen(!context.open);
},
"aria-haspopup": "dialog",
"aria-expanded": context.open,
"aria-controls": context.open ? context.contentId : undefined,
});
}
export interface CenterMorphModalCloseProps {
children: ReactElement;
}
/** Wraps one interactive element and closes the modal. */
export function CenterMorphModalClose({
children,
}: CenterMorphModalCloseProps) {
const context = useCenterMorphModalContext("CenterMorphModalClose");
if (!isValidElement(children)) return children;
const child = children as ReactElement<Record<string, unknown>>;
const childOnClick = child.props.onClick as
| ((event: React.MouseEvent<HTMLElement>) => void)
| undefined;
return cloneElement(child, {
onClick: (event: React.MouseEvent<HTMLElement>) => {
childOnClick?.(event);
if (!event.defaultPrevented) context.setOpen(false);
},
});
}
export interface CenterMorphModalContentProps {
children: ReactNode;
/** Accessible name announced by screen readers. */
ariaLabel: string;
/** Optional id of descriptive content inside the modal. */
ariaDescribedBy?: string;
/** Close on Escape or backdrop press. Default true. */
dismissible?: boolean;
/** Render the close control inside the panel's top-right corner. Default true. */
showCloseButton?: boolean;
closeButtonLabel?: string;
className?: string;
backdropClassName?: string;
}
const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(",");
const CENTER_FOLDED_CLIP =
"inset(48% 48% 48% 48% round 30px)";
const CENTER_OPEN_CLIP = "inset(0% 0% 0% 0% round 30px)";
// Complex clip-path strings can snap when a spring resolves its final distance.
// Keep the radius constant so the whole duration reads as surface unfolding,
// rather than finishing early and spending its last frames rounding corners.
const CENTER_UNFOLD_EASE = [0.2, 0, 0.2, 1] as const;
const CENTER_UNFOLD_TRANSITION = {
duration: 0.43,
ease: CENTER_UNFOLD_EASE,
} as const;
function getFocusableElements(root: HTMLElement | null) {
if (!root) return [];
return Array.from(
root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
).filter((element) => element.tabIndex >= 0);
}
function PresencePointerGate({
children,
}: {
children: (isPresent: boolean) => ReactNode;
}) {
return children(useIsPresent());
}
export function CenterMorphModalContent({
children,
ariaLabel,
ariaDescribedBy,
dismissible = true,
showCloseButton = true,
closeButtonLabel = "Close modal",
className,
backdropClassName,
}: CenterMorphModalContentProps) {
const context = useCenterMorphModalContext("CenterMorphModalContent");
const reduce = useReducedMotion() ?? false;
const [mounted, setMounted] = useState(false);
const overlayRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (!context.open) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const focusFrame = requestAnimationFrame(() => {
const [firstFocusable] = getFocusableElements(overlayRef.current);
(firstFocusable ?? panelRef.current)?.focus();
});
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && dismissible) {
event.preventDefault();
context.setOpen(false);
return;
}
if (event.key !== "Tab") return;
const focusable = getFocusableElements(overlayRef.current);
if (focusable.length === 0) {
event.preventDefault();
panelRef.current?.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
cancelAnimationFrame(focusFrame);
window.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = previousOverflow;
document.getElementById(context.triggerId)?.focus();
};
}, [context, dismissible]);
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{context.open ? (
<PresencePointerGate>
{(isPresent) => (
<div
ref={overlayRef}
className="pointer-events-none fixed inset-0 z-[100]"
>
<motion.button
type="button"
aria-label="Dismiss modal"
tabIndex={-1}
disabled={!dismissible}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ pointerEvents: isPresent ? "auto" : "none" }}
transition={{
duration: reduce ? 0.1 : 0.28,
ease: EASE_OUT,
}}
onClick={() => context.setOpen(false)}
className={cn(
"pointer-events-auto absolute inset-0 h-full w-full cursor-default bg-background/10 backdrop-blur-sm",
backdropClassName,
)}
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center overflow-y-auto p-4 drop-shadow-2xl">
{/* Drop-shadow reads the clipped child's alpha, so depth follows the
unfolding silhouette without introducing another panel layer. */}
<div className="flex w-full flex-col items-center py-8">
<motion.div
ref={panelRef}
id={context.contentId}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
tabIndex={-1}
initial={
reduce
? { opacity: 0, clipPath: CENTER_OPEN_CLIP }
: { opacity: 1, clipPath: CENTER_FOLDED_CLIP }
}
animate={{
opacity: 1,
clipPath: CENTER_OPEN_CLIP,
}}
exit={
reduce
? {
opacity: 0,
clipPath: CENTER_OPEN_CLIP,
}
: {
opacity: 1,
clipPath: CENTER_FOLDED_CLIP,
}
}
style={{ pointerEvents: isPresent ? "auto" : "none" }}
transition={
reduce
? { duration: 0.14, ease: EASE_OUT }
: CENTER_UNFOLD_TRANSITION
}
className={cn(
"pointer-events-auto relative w-full max-w-[26rem] origin-center overflow-hidden rounded-[30px] border border-border bg-background will-change-[clip-path]",
className,
)}
>
{children}
{showCloseButton ? (
<motion.button
type="button"
aria-label={closeButtonLabel}
onClick={() => context.setOpen(false)}
initial={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.8 }
}
animate={{ opacity: 1, scale: 1 }}
exit={{
opacity: 0,
scale: reduce ? 1 : 0.88,
transition: { duration: 0.1, ease: EASE_OUT },
}}
transition={{
delay: reduce ? 0 : 0.16,
duration: reduce ? 0.12 : 0.2,
ease: EASE_OUT,
}}
className="absolute right-4 top-4 inline-flex h-8 w-8 items-center justify-center rounded-full bg-foreground/[0.05] text-muted-foreground transition-colors hover:bg-foreground/[0.08] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="h-4 w-4" aria-hidden="true" />
</motion.button>
) : null}
</motion.div>
</div>
</div>
</div>
)}
</PresencePointerGate>
) : null}
</AnimatePresence>,
document.body,
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/center-morph-modal
import { X } from "lucide-react";
import {
AnimatePresence,
motion,
useIsPresent,
useReducedMotion,
} from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type CenterMorphModalContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
triggerId: string;
contentId: string;
};
const CenterMorphModalContext =
createContext<CenterMorphModalContextValue | null>(null);
function useCenterMorphModalContext(component: string) {
const context = useContext(CenterMorphModalContext);
if (!context) {
throw new Error(`${component} must be used within <CenterMorphModal>`);
}
return context;
}
export interface CenterMorphModalProps {
children: ReactNode;
/** Controlled open state. */
open?: boolean;
/** Initial state when used uncontrolled. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
/**
* A modal whose full-size surface unfolds outward from its exact center.
* Supports controlled and uncontrolled state through composable primitives.
*/
export function CenterMorphModal({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
}: CenterMorphModalProps) {
const id = useId();
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
const value = useMemo<CenterMorphModalContextValue>(
() => ({
open,
setOpen,
triggerId: `${id}-trigger`,
contentId: `${id}-content`,
}),
[id, open, setOpen],
);
return (
<CenterMorphModalContext.Provider value={value}>
{children}
</CenterMorphModalContext.Provider>
);
}
export interface CenterMorphModalTriggerProps {
children: ReactElement;
}
/** Wraps one interactive element and opens or closes the modal. */
export function CenterMorphModalTrigger({
children,
}: CenterMorphModalTriggerProps) {
const context = useCenterMorphModalContext("CenterMorphModalTrigger");
if (!isValidElement(children)) return children;
const child = children as ReactElement<Record<string, unknown>>;
const childOnClick = child.props.onClick as
| ((event: React.MouseEvent<HTMLElement>) => void)
| undefined;
return cloneElement(child, {
id: context.triggerId,
onClick: (event: React.MouseEvent<HTMLElement>) => {
childOnClick?.(event);
if (!event.defaultPrevented) context.setOpen(!context.open);
},
"aria-haspopup": "dialog",
"aria-expanded": context.open,
"aria-controls": context.open ? context.contentId : undefined,
});
}
export interface CenterMorphModalCloseProps {
children: ReactElement;
}
/** Wraps one interactive element and closes the modal. */
export function CenterMorphModalClose({
children,
}: CenterMorphModalCloseProps) {
const context = useCenterMorphModalContext("CenterMorphModalClose");
if (!isValidElement(children)) return children;
const child = children as ReactElement<Record<string, unknown>>;
const childOnClick = child.props.onClick as
| ((event: React.MouseEvent<HTMLElement>) => void)
| undefined;
return cloneElement(child, {
onClick: (event: React.MouseEvent<HTMLElement>) => {
childOnClick?.(event);
if (!event.defaultPrevented) context.setOpen(false);
},
});
}
export interface CenterMorphModalContentProps {
children: ReactNode;
/** Accessible name announced by screen readers. */
ariaLabel: string;
/** Optional id of descriptive content inside the modal. */
ariaDescribedBy?: string;
/** Close on Escape or backdrop press. Default true. */
dismissible?: boolean;
/** Render the close control inside the panel's top-right corner. Default true. */
showCloseButton?: boolean;
closeButtonLabel?: string;
className?: string;
backdropClassName?: string;
}
const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(",");
const CENTER_FOLDED_CLIP =
"inset(48% 48% 48% 48% round 30px)";
const CENTER_OPEN_CLIP = "inset(0% 0% 0% 0% round 30px)";
// Complex clip-path strings can snap when a spring resolves its final distance.
// Keep the radius constant so the whole duration reads as surface unfolding,
// rather than finishing early and spending its last frames rounding corners.
const CENTER_UNFOLD_EASE = [0.2, 0, 0.2, 1] as const;
const CENTER_UNFOLD_TRANSITION = {
duration: 0.43,
ease: CENTER_UNFOLD_EASE,
} as const;
function getFocusableElements(root: HTMLElement | null) {
if (!root) return [];
return Array.from(
root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
).filter((element) => element.tabIndex >= 0);
}
function PresencePointerGate({
children,
}: {
children: (isPresent: boolean) => ReactNode;
}) {
return children(useIsPresent());
}
export function CenterMorphModalContent({
children,
ariaLabel,
ariaDescribedBy,
dismissible = true,
showCloseButton = true,
closeButtonLabel = "Close modal",
className,
backdropClassName,
}: CenterMorphModalContentProps) {
const context = useCenterMorphModalContext("CenterMorphModalContent");
const reduce = useReducedMotion() ?? false;
const [mounted, setMounted] = useState(false);
const overlayRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (!context.open) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const focusFrame = requestAnimationFrame(() => {
const [firstFocusable] = getFocusableElements(overlayRef.current);
(firstFocusable ?? panelRef.current)?.focus();
});
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && dismissible) {
event.preventDefault();
context.setOpen(false);
return;
}
if (event.key !== "Tab") return;
const focusable = getFocusableElements(overlayRef.current);
if (focusable.length === 0) {
event.preventDefault();
panelRef.current?.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
cancelAnimationFrame(focusFrame);
window.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = previousOverflow;
document.getElementById(context.triggerId)?.focus();
};
}, [context, dismissible]);
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{context.open ? (
<PresencePointerGate>
{(isPresent) => (
<div
ref={overlayRef}
className="pointer-events-none fixed inset-0 z-[100]"
>
<motion.button
type="button"
aria-label="Dismiss modal"
tabIndex={-1}
disabled={!dismissible}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ pointerEvents: isPresent ? "auto" : "none" }}
transition={{
duration: reduce ? 0.1 : 0.28,
ease: EASE_OUT,
}}
onClick={() => context.setOpen(false)}
className={cn(
"pointer-events-auto absolute inset-0 h-full w-full cursor-default bg-background/10 backdrop-blur-sm",
backdropClassName,
)}
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center overflow-y-auto p-4 drop-shadow-2xl">
{/* Drop-shadow reads the clipped child's alpha, so depth follows the
unfolding silhouette without introducing another panel layer. */}
<div className="flex w-full flex-col items-center py-8">
<motion.div
ref={panelRef}
id={context.contentId}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
tabIndex={-1}
initial={
reduce
? { opacity: 0, clipPath: CENTER_OPEN_CLIP }
: { opacity: 1, clipPath: CENTER_FOLDED_CLIP }
}
animate={{
opacity: 1,
clipPath: CENTER_OPEN_CLIP,
}}
exit={
reduce
? {
opacity: 0,
clipPath: CENTER_OPEN_CLIP,
}
: {
opacity: 1,
clipPath: CENTER_FOLDED_CLIP,
}
}
style={{ pointerEvents: isPresent ? "auto" : "none" }}
transition={
reduce
? { duration: 0.14, ease: EASE_OUT }
: CENTER_UNFOLD_TRANSITION
}
className={cn(
"pointer-events-auto relative w-full max-w-[26rem] origin-center overflow-hidden rounded-[30px] border border-border bg-background will-change-[clip-path]",
className,
)}
>
{children}
{showCloseButton ? (
<motion.button
type="button"
aria-label={closeButtonLabel}
onClick={() => context.setOpen(false)}
initial={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.8 }
}
animate={{ opacity: 1, scale: 1 }}
exit={{
opacity: 0,
scale: reduce ? 1 : 0.88,
transition: { duration: 0.1, ease: EASE_OUT },
}}
transition={{
delay: reduce ? 0 : 0.16,
duration: reduce ? 0.12 : 0.2,
ease: EASE_OUT,
}}
className="absolute right-4 top-4 inline-flex h-8 w-8 items-center justify-center rounded-full bg-foreground/[0.05] text-muted-foreground transition-colors hover:bg-foreground/[0.08] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="h-4 w-4" aria-hidden="true" />
</motion.button>
) : null}
</motion.div>
</div>
</div>
</div>
)}
</PresencePointerGate>
) : null}
</AnimatePresence>,
document.body,
);
}
API Reference
CenterMorphModal
open?booleanControlled open state.
—defaultOpen?booleanInitial state when used uncontrolled.
falseonOpenChange?((open: boolean) => void)—CenterMorphModalContent
ariaLabelstringAccessible name announced by screen readers.
—ariaDescribedBy?stringOptional id of descriptive content inside the modal.
—dismissible?booleanClose on Escape or backdrop press. Default true.
trueshowCloseButton?booleanRender the close control inside the panel's top-right corner. Default true.
truecloseButtonLabel?stringClose modalclassName?string—backdropClassName?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.