Center Morph Modal
A dialog that opens by growing out of its own middle: the full panel starts clipped to a small rounded point at the center and expands to every edge, then shrinks back on close. Traps Tab, closes on Escape or backdrop, and hands focus back to its trigger.
Preview
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;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} 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";
// karaui.kamisama-v0.my.id/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,
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
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—Updated