Morphing Modal
A modal for multi-step flows, inspired by the Family app: change viewId and one panel resizes to fit the next screen while the old content blurs out and the new content blurs in. Anchors to the bottom like a sheet or sits centered.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @karaui/morphing-modal
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// 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;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/morphing-modal.tsx
"use client";
// karaui.kamisama-v0.my.id/components/motion/morphing-modal
import {
AnimatePresence,
motion,
useReducedMotion,
} from "motion/react";
import { type ReactNode, useEffect } from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface MorphingModalProps {
/** Which view is currently shown. `null` closes the modal. */
viewId: string | null;
onClose: () => void;
children: ReactNode;
/** "bottom" anchors to the viewport bottom (mobile-like). "center" centers vertically. */
placement?: "bottom" | "center";
className?: string;
}
export function MorphingModal({
viewId,
onClose,
children,
placement = "bottom",
className,
}: MorphingModalProps) {
const open = viewId !== null;
const reduce = useReducedMotion();
const enterY = reduce ? 0 : placement === "bottom" ? 40 : 20;
const enterScale = reduce ? 1 : 0.97;
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
return (
<div
aria-hidden={!open}
inert={!open}
className={cn(
"fixed inset-0 z-[80]",
open ? "pointer-events-auto" : "pointer-events-none",
)}
>
<motion.button
type="button"
aria-label="Close modal"
initial={false}
animate={{ opacity: open ? 1 : 0 }}
transition={{ duration: 0.2, ease: EASE_OUT }}
onClick={onClose}
className={cn(
"absolute inset-0 bg-background/5 [backdrop-filter:blur(14px)_saturate(140%)] [-webkit-backdrop-filter:blur(14px)_saturate(140%)]",
open ? "pointer-events-auto" : "pointer-events-none",
)}
/>
<div
className={cn(
"pointer-events-none absolute inset-0 flex justify-center px-4",
placement === "bottom" ? "items-end pb-8" : "items-center",
)}
>
<AnimatePresence initial={false}>
{open ? (
<motion.div
key="panel"
layout
initial={{ opacity: 0, y: enterY, scale: enterScale }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{
opacity: 0,
y: enterY,
scale: reduce ? 1 : 0.98,
transition: { duration: 0.18, ease: EASE_OUT },
}}
transition={SPRING_PANEL}
className={cn(
"pointer-events-auto relative w-full max-w-sm overflow-hidden rounded-3xl border border-border bg-background shadow-2xl will-change-transform",
className,
)}
>
<motion.div layout="position" className="p-5">
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={viewId}
initial={
reduce
? { opacity: 0 }
: { opacity: 0, y: 8, filter: "blur(4px)" }
}
animate={
reduce
? {
opacity: 1,
transition: {
duration: 0.18,
ease: EASE_OUT,
},
}
: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: {
duration: 0.24,
ease: EASE_OUT,
},
}
}
exit={
reduce
? {
opacity: 0,
transition: {
duration: 0.14,
ease: EASE_OUT,
},
}
: {
opacity: 0,
y: -8,
filter: "blur(4px)",
transition: {
duration: 0.16,
ease: EASE_OUT,
},
}
}
>
{children}
</motion.div>
</AnimatePresence>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
);
}
@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
viewIdstring | nullWhich view is currently shown. `null` closes the modal.
—onClose() => void—placement?"bottom" | "center""bottom" anchors to the viewport bottom (mobile-like). "center" centers vertically.
bottomclassName?string—Updated