Morphing Modal
Preview
Click a row. The modal morphs height to match new content.
"use client";
import { Ban, Lock, ScanFace, ScrollText, ShieldCheck, Trash2 } from "lucide-react";
import { useState } from "react";
import { MorphingModal } from "@/components/motion/morphing-modal";
type View = "options" | "private-key" | "recovery" | null;
export function MorphingModalPreview() {
const [view, setView] = useState<View>(null);
return (
<div className="flex flex-col items-center gap-3">
<button
type="button"
onClick={() => setView("options")}
className="inline-flex h-10 items-center rounded-full border border-border bg-card px-5 text-sm font-medium text-foreground press hover:border-(--color-border-strong)"
>
Open wallet options
</button>
<p className="text-xs text-muted-foreground">Click a row. The modal morphs height to match new content.</p>
<MorphingModal viewId={view} onClose={() => setView(null)}>
{view === "options" ? (
<Options
onPrivateKey={() => setView("private-key")}
onRecovery={() => setView("recovery")}
onClose={() => setView(null)}
/>
) : view === "private-key" ? (
<PrivateKey onBack={() => setView("options")} />
) : view === "recovery" ? (
<Recovery onBack={() => setView("options")} />
) : null}
</MorphingModal>
</div>
);
}
function Header({ title, onClose }: { title: string; onClose: () => void }) {
return (
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">{title}</h2>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-muted-foreground hover:bg-foreground/[0.06]"
>
✕
</button>
</div>
);
}
function Row({
icon: Icon,
label,
destructive,
onClick,
}: {
icon: typeof Lock;
label: string;
destructive?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-sm font-medium transition-colors press ${
destructive
? "bg-destructive/10 text-destructive hover:bg-destructive/15"
: "bg-foreground/[0.04] text-foreground hover:bg-foreground/[0.08]"
}`}
>
<Icon className="h-4 w-4" />
{label}
</button>
);
}
function Options({
onPrivateKey,
onRecovery,
onClose,
}: {
onPrivateKey: () => void;
onRecovery: () => void;
onClose: () => void;
}) {
return (
<div>
<Header title="Options" onClose={onClose} />
<div className="flex flex-col gap-2">
<Row icon={Lock} label="View Private Key" onClick={onPrivateKey} />
<Row icon={ScrollText} label="View Recovery Phrase" onClick={onRecovery} />
<Row icon={Trash2} label="Remove Wallet" destructive onClick={onClose} />
</div>
</div>
);
}
function PrivateKey({ onBack }: { onBack: () => void }) {
return (
<div>
<div className="mb-3 flex items-start justify-between">
<Lock className="h-5 w-5 text-foreground" />
<button
type="button"
onClick={onBack}
aria-label="Back"
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-muted-foreground hover:bg-foreground/[0.06]"
>
✕
</button>
</div>
<h2 className="text-xl font-semibold tracking-tight text-foreground">Private Key</h2>
<p className="mt-2 text-sm text-muted-foreground">
Your Private Key is the key used to back up your wallet. Keep it secret and secure at all times.
</p>
<hr className="my-4 border-border" />
<ul className="flex flex-col gap-2.5 text-sm text-muted-foreground">
<li className="flex items-center gap-2.5"><ShieldCheck className="h-4 w-4" /> Keep your private key safe</li>
<li className="flex items-center gap-2.5"><ScrollText className="h-4 w-4" /> Don't share it with anyone else</li>
<li className="flex items-center gap-2.5"><Ban className="h-4 w-4" /> If you lose it, we can't recover it</li>
</ul>
<div className="mt-5 flex gap-2">
<button
type="button"
onClick={onBack}
className="inline-flex h-10 flex-1 items-center justify-center rounded-full bg-foreground/[0.06] text-sm font-medium text-foreground press"
>
Cancel
</button>
<button
type="button"
onClick={onBack}
className="inline-flex h-10 flex-1 items-center justify-center gap-2 rounded-full bg-foreground text-sm font-medium text-background press"
>
<ScanFace className="h-4 w-4" />
Reveal
</button>
</div>
</div>
);
}
function Recovery({ onBack }: { onBack: () => void }) {
return (
<div>
<div className="mb-3 flex items-start justify-between">
<ScrollText className="h-5 w-5 text-foreground" />
<button
type="button"
onClick={onBack}
aria-label="Back"
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-muted-foreground hover:bg-foreground/[0.06]"
>
✕
</button>
</div>
<h2 className="text-xl font-semibold tracking-tight text-foreground">Recovery Phrase</h2>
<p className="mt-2 text-sm text-muted-foreground">
12 words you can use to restore your wallet on any device. Write them down somewhere safe.
</p>
<div className="mt-4 grid grid-cols-3 gap-2">
{["mountain", "river", "candle", "harbor", "amber", "violet", "spring", "ocean", "marble", "thunder", "willow", "crystal"].map((w, i) => (
<div key={w} className="rounded-lg border border-border bg-background/40 px-2 py-1.5 text-xs text-foreground">
<span className="mr-1 text-muted-foreground">{i + 1}.</span>
{w}
</div>
))}
</div>
<button
type="button"
onClick={onBack}
className="mt-5 inline-flex h-10 w-full items-center justify-center rounded-full bg-foreground text-sm font-medium text-background press"
>
Done
</button>
</div>
);
}
"use client";
// beui.dev/components/motion/morphing-modal
import {
AnimatePresence,
motion,
useReducedMotion,
} from "motion/react";
import {
type ReactNode,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { focusablesIn, useFocusTrap } from "@/lib/hooks/use-focus-trap";
import { PresenceGate } from "@/lib/presence-gate";
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";
/**
* Accessible name for the dialog. Without it, the dialog is named by the
* first heading in the current view.
*/
ariaLabel?: string;
className?: string;
}
export function MorphingModal({
viewId,
onClose,
children,
placement = "bottom",
ariaLabel,
className,
}: MorphingModalProps) {
const open = viewId !== null;
const reduce = useReducedMotion();
const panelRef = useRef<HTMLDivElement>(null);
const uid = useId();
const [headingId, setHeadingId] = useState<string>();
// Focus moves into the panel on open, Tab stays inside it, Escape closes,
// and focus returns to the opener on close.
useFocusTrap(open, panelRef, { onEscape: onClose });
// The current view, not the one still fading out under it.
const currentView = () =>
Array.from(
panelRef.current?.querySelectorAll<HTMLElement>("[data-view]") ?? [],
).find((element) => element.dataset.view === viewId);
// Name the dialog by the current view's first heading unless a label is set.
useLayoutEffect(() => {
if (!viewId || ariaLabel) return;
const heading = currentView()?.querySelector<HTMLElement>(
"h1, h2, h3, h4, h5, h6",
);
if (heading && !heading.id) heading.id = `${uid}-${viewId}-title`;
setHeadingId(heading?.id);
});
// The focused control leaves with the old view, so a view change hands focus
// to the first control of the new one.
const shownView = useRef(viewId);
useEffect(() => {
const previous = shownView.current;
shownView.current = viewId;
if (!viewId || !previous || previous === viewId) return;
const panel = panelRef.current;
const active = document.activeElement;
if (!panel || (active && active !== document.body && !panel.contains(active)))
return;
const view = currentView();
(focusablesIn(view ?? null)[0] ?? panel).focus({ preventScroll: true });
});
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]);
// Mounted only while open, and while open the chrome is two fixed siblings
// rather than one wrapper: the backdrop spans the viewport edges but carries
// the scrim colour, and the layer positioning the panel sits inset off every
// edge (`inset-4`, with the bottom placement's `pb-4` on top of it). Both hang
// off `PresenceGate`, so interaction releases in the same commit that starts
// the exit rather than when it ends — `open` is already false for those
// frames.
return (
<AnimatePresence initial={false}>
{open ? (
<PresenceGate key="backdrop">
{({ gate }) => (
<motion.button
type="button"
aria-label="Close modal"
// Outside the panel, so the focus trap never lands on it; it is
// a pointer target, and Escape closes from the keyboard.
tabIndex={-1}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: EASE_OUT }}
{...gate}
onClick={onClose}
className="pointer-events-auto fixed inset-0 z-[80] bg-background/5 [backdrop-filter:blur(14px)_saturate(140%)] [-webkit-backdrop-filter:blur(14px)_saturate(140%)]"
/>
)}
</PresenceGate>
) : null}
{open ? (
<PresenceGate key="panel-layer">
{({ isPresent, gate }) => (
// The layer itself never takes pointer events, so it carries
// `inert` alone rather than the gate's pointer-events value.
<div
inert={!isPresent}
className={cn(
"pointer-events-none fixed inset-4 z-[80] flex justify-center",
placement === "bottom" ? "items-end pb-4" : "items-center",
)}
>
<motion.div
key="panel"
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : headingId}
tabIndex={-1}
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}
{...gate}
className={cn(
"pointer-events-auto relative w-full max-w-sm overflow-hidden rounded-3xl border border-border bg-background shadow-2xl outline-none will-change-transform",
className,
)}
>
<motion.div layout="position" className="p-5">
<AnimatePresence mode="popLayout" initial={false}>
{/* The motion element is the direct child so popLayout can
take its ref and lift the outgoing view out of flow;
otherwise both views stack for the exit and the panel
briefly grows to their combined height. */}
<motion.div
key={viewId}
data-view={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,
},
}
}
>
<PresenceGate>
{({ isPresent: viewPresent }) => (
// The outgoing view stays for its exit; inert keeps
// the trap and assistive technology on the incoming one.
<div inert={!viewPresent}>{children}</div>
)}
</PresenceGate>
</motion.div>
</AnimatePresence>
</motion.div>
</motion.div>
</div>
)}
</PresenceGate>
) : null}
</AnimatePresence>
);
}
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;
"use client";
import { type RefObject, useEffect, useLayoutEffect, useRef } from "react";
const FOCUSABLE =
'button:not(:disabled), a[href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])';
/** Elements inside `container` that Tab can reach, in DOM order. */
export function focusablesIn(container: Element | null) {
return Array.from(
container?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? [],
).filter((element) => element.tabIndex >= 0 && !element.closest("[inert]"));
}
/**
* Open traps, innermost last. Only the innermost one answers Tab, Escape and
* stray focus: a sheet opened from inside a drawer portals outside the drawer,
* and a drawer that still pulled focus home would fight the sheet for it.
*/
const traps: object[] = [];
export interface FocusTrapOptions {
/** Called on Escape while this is the innermost open trap. */
onEscape?: () => void;
}
/**
* Moves focus into `panel` when `open` turns true, keeps Tab cycling inside it,
* pulls focus back if it lands outside, and returns focus to whatever held it
* before opening once `open` turns false.
*
* For overlays rendered in place, where `useModalScope` cannot inert the rest
* of the page. Pass `open` rather than presence: an exiting panel is already
* inert through `PresenceGate`, so the trap releases with the same commit.
* Give the panel `tabIndex={-1}` so it can take focus when it holds nothing
* focusable.
*/
export function useFocusTrap(
open: boolean,
panel: RefObject<HTMLElement | null>,
{ onEscape }: FocusTrapOptions = {},
) {
const escapeRef = useRef(onEscape);
useLayoutEffect(() => {
escapeRef.current = onEscape;
});
useEffect(() => {
if (!open || !panel.current) return;
const self = {};
traps.push(self);
const innermost = () => traps[traps.length - 1] === self;
const returnFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const focusables = () => focusablesIn(panel.current); const focusFirst = () =>
(focusables()[0] ?? panel.current)?.focus({ preventScroll: true });
if (!panel.current.contains(document.activeElement)) focusFirst();
const onFocus = (event: FocusEvent) => {
if (!innermost() || !panel.current || panel.current.closest("[inert]"))
return;
if (event.target instanceof Node && !panel.current.contains(event.target))
focusFirst();
};
const onKey = (event: KeyboardEvent) => {
if (!innermost() || event.defaultPrevented) return;
if (event.key === "Escape" && escapeRef.current) {
event.preventDefault();
escapeRef.current();
return;
}
if (event.key !== "Tab") return;
const elements = focusables();
const first = elements[0];
const last = elements[elements.length - 1];
const active = document.activeElement;
if (!first || !last) {
event.preventDefault();
panel.current?.focus({ preventScroll: true });
} else if (event.shiftKey && (active === first || active === panel.current)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("focusin", onFocus);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("focusin", onFocus);
document.removeEventListener("keydown", onKey);
traps.splice(traps.indexOf(self), 1);
if (returnFocus?.isConnected && !returnFocus.closest("[inert]"))
returnFocus.focus({ preventScroll: true });
};
}, [open, panel]);
}
"use client";
import { useIsPresent } from "motion/react";
import type { ReactNode } from "react";
export interface PresenceGateRenderProps {
/**
* False from the render that starts the exit animation onward. An overlay
* kept in the tree by `AnimatePresence` is still the topmost thing on the
* page, so anything it decides from `open` alone stays true for the whole
* exit — this is the boolean that already knows the overlay is leaving.
*/
isPresent: boolean;
/**
* Spread onto every layer that takes pointer events while the overlay is
* open. Interaction releases in the same commit that starts the exit while
* the visual exit keeps playing: pointer events stop landing, and `inert`
* drops the subtree from focus order, from tab order and from the
* accessibility tree — an exiting dialog is not a dialog you can still type
* into. A layer that never takes pointer events (a wrapper that only centres
* the panel) takes `inert={!isPresent}` alone, so its own
* `pointer-events-none` is not overwritten.
*/
gate: {
inert: boolean;
style: { pointerEvents: "auto" | "none" };
};
}
export interface PresenceGateProps {
children: (props: PresenceGateRenderProps) => ReactNode;
}
/**
* Reads the presence of the subtree it renders and hands it down.
*
* `useIsPresent` only answers inside the `AnimatePresence` subtree, and the
* components that own an overlay render the `AnimatePresence` themselves, so
* the boolean has to be read one component further down: this is that
* component, and the render prop is how it reaches the layers.
*/
export function PresenceGate({ children }: PresenceGateProps) {
const isPresent = useIsPresent();
return children({
isPresent,
gate: {
inert: !isPresent,
style: { pointerEvents: isPresent ? "auto" : "none" },
},
});
}
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/morphing-modal
import {
AnimatePresence,
motion,
useReducedMotion,
} from "motion/react";
import {
type ReactNode,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { focusablesIn, useFocusTrap } from "@/lib/hooks/use-focus-trap";
import { PresenceGate } from "@/lib/presence-gate";
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";
/**
* Accessible name for the dialog. Without it, the dialog is named by the
* first heading in the current view.
*/
ariaLabel?: string;
className?: string;
}
export function MorphingModal({
viewId,
onClose,
children,
placement = "bottom",
ariaLabel,
className,
}: MorphingModalProps) {
const open = viewId !== null;
const reduce = useReducedMotion();
const panelRef = useRef<HTMLDivElement>(null);
const uid = useId();
const [headingId, setHeadingId] = useState<string>();
// Focus moves into the panel on open, Tab stays inside it, Escape closes,
// and focus returns to the opener on close.
useFocusTrap(open, panelRef, { onEscape: onClose });
// The current view, not the one still fading out under it.
const currentView = () =>
Array.from(
panelRef.current?.querySelectorAll<HTMLElement>("[data-view]") ?? [],
).find((element) => element.dataset.view === viewId);
// Name the dialog by the current view's first heading unless a label is set.
useLayoutEffect(() => {
if (!viewId || ariaLabel) return;
const heading = currentView()?.querySelector<HTMLElement>(
"h1, h2, h3, h4, h5, h6",
);
if (heading && !heading.id) heading.id = `${uid}-${viewId}-title`;
setHeadingId(heading?.id);
});
// The focused control leaves with the old view, so a view change hands focus
// to the first control of the new one.
const shownView = useRef(viewId);
useEffect(() => {
const previous = shownView.current;
shownView.current = viewId;
if (!viewId || !previous || previous === viewId) return;
const panel = panelRef.current;
const active = document.activeElement;
if (!panel || (active && active !== document.body && !panel.contains(active)))
return;
const view = currentView();
(focusablesIn(view ?? null)[0] ?? panel).focus({ preventScroll: true });
});
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]);
// Mounted only while open, and while open the chrome is two fixed siblings
// rather than one wrapper: the backdrop spans the viewport edges but carries
// the scrim colour, and the layer positioning the panel sits inset off every
// edge (`inset-4`, with the bottom placement's `pb-4` on top of it). Both hang
// off `PresenceGate`, so interaction releases in the same commit that starts
// the exit rather than when it ends — `open` is already false for those
// frames.
return (
<AnimatePresence initial={false}>
{open ? (
<PresenceGate key="backdrop">
{({ gate }) => (
<motion.button
type="button"
aria-label="Close modal"
// Outside the panel, so the focus trap never lands on it; it is
// a pointer target, and Escape closes from the keyboard.
tabIndex={-1}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: EASE_OUT }}
{...gate}
onClick={onClose}
className="pointer-events-auto fixed inset-0 z-[80] bg-background/5 [backdrop-filter:blur(14px)_saturate(140%)] [-webkit-backdrop-filter:blur(14px)_saturate(140%)]"
/>
)}
</PresenceGate>
) : null}
{open ? (
<PresenceGate key="panel-layer">
{({ isPresent, gate }) => (
// The layer itself never takes pointer events, so it carries
// `inert` alone rather than the gate's pointer-events value.
<div
inert={!isPresent}
className={cn(
"pointer-events-none fixed inset-4 z-[80] flex justify-center",
placement === "bottom" ? "items-end pb-4" : "items-center",
)}
>
<motion.div
key="panel"
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : headingId}
tabIndex={-1}
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}
{...gate}
className={cn(
"pointer-events-auto relative w-full max-w-sm overflow-hidden rounded-3xl border border-border bg-background shadow-2xl outline-none will-change-transform",
className,
)}
>
<motion.div layout="position" className="p-5">
<AnimatePresence mode="popLayout" initial={false}>
{/* The motion element is the direct child so popLayout can
take its ref and lift the outgoing view out of flow;
otherwise both views stack for the exit and the panel
briefly grows to their combined height. */}
<motion.div
key={viewId}
data-view={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,
},
}
}
>
<PresenceGate>
{({ isPresent: viewPresent }) => (
// The outgoing view stays for its exit; inert keeps
// the trap and assistive technology on the incoming one.
<div inert={!viewPresent}>{children}</div>
)}
</PresenceGate>
</motion.div>
</AnimatePresence>
</motion.div>
</motion.div>
</div>
)}
</PresenceGate>
) : null}
</AnimatePresence>
);
}
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.
bottomariaLabel?stringAccessible name for the dialog. Without it, the dialog is named by the first heading in the current view.
—className?string—Related components
Popover
Gooey popover whose panel oozes out of the trigger through an SVG goo filter with a liquid neck that stretches and pinches, then crisp content fades in on top. The Morph variant reveals its full-size panel from the corner nearest the trigger on one synchronized spring clip. Click or hover trigger, controlled or uncontrolled.
Drawer
Side panel that slides in from the left or right with a spring, backdrop blur, body scroll lock and esc-to-close.
Tooltip
Composable hover, focus and tap tooltip with a subtle scale and fade, collision-aware side and alignment, an optional arrow, a pointer corridor onto hoverable content, one label at a time, keyboard-only focus reveal and optional cursor tracking.
Updated