Height-morphing modal: the surface springs between measured content heights while outgoing content dissolves into the incoming tree via a blur-style opacity/scale cross-fade. Controlled open state, backdrop dismiss, swipe-to-dismiss, optional close button, safe-area aware, reduced-motion support.
components/ui/morphing-modal/morphing-modal.tsx@lumioui/core@latest@lumioui/ui@latestreact-native-reanimated@^4.0.0react-native-gesture-handler@^2.20.0react-native-safe-area-context@*expo-haptics@*| open | boolean | |
| onOpenChange | (open: boolean) => void | |
| title? | string | Modal title (also used as the accessible name). |
| description? | string | Optional description shown under the title. |
| showCloseButton? | boolean | Show a close button in the top-right. Default true. |
| dismissible? | boolean | Close on backdrop press. Default true. |
| swipeToDismiss? | boolean | Enable swipe-to-dismiss. Default true. |
| placement? | "center" | "bottom" | Anchor: "center" unfolds from the middle, "bottom" rises from the bottom. |
| className? | string | |
| children? | React.ReactNode |
Height-morphing modal: the surface springs between measured content heights while outgoing content dissolves into the incoming tree via a blur-style opacity/scale cross-fade. Controlled open state, backdrop dismiss, swipe-to-dismiss, optional close button, safe-area aware, reduced-motion support.
import { MorphingModal } from "@/components/ui/morphing-modal/morphing-modal";
export function Example() {
return (
<MorphingModal />
);
} import * as React from "react";
import { forwardRef, useCallback, useMemo } from "react";
import { Modal, View, Text, Pressable, type LayoutChangeEvent } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withDelay,
withTiming,
withSpring,
runOnJS,
interpolate,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { cn, useReducedMotion, spring, timing, useHaptic, usePressAnimation, AnimatedPressable } from "@lumioui/core";
import { Icon, Glass } from "@lumioui/ui";
import { useSafeAreaInsets } from "react-native-safe-area-context";
/**
* MorphingModal — a height-morphing modal with a blur-style cross-fade.
* When `children` change while open, the surface springs to the new content
* height (measured via onLayout) while the outgoing content fades + scales
* out and the incoming content fades + scales in — approximating a blur
* cross-fade without an expo-blur dependency.
* <MorphingModal open={open} onOpenChange={setOpen} title="Settings">
* {children}
* </MorphingModal>
* Swipe down on the panel to dismiss (bottom placement) or swipe in any
* direction (center placement). Reduced motion skips the gesture and the
* morph collapses to a plain swap.
*/
export interface MorphingModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Modal title (also used as the accessible name). */
title?: string;
/** Optional description shown under the title. */
description?: string;
/** Show a close button in the top-right. Default true. */
showCloseButton?: boolean;
/** Close on backdrop press. Default true. */
dismissible?: boolean;
/** Enable swipe-to-dismiss. Default true. */
swipeToDismiss?: boolean;
/** Anchor: "center" unfolds from the middle, "bottom" rises from the bottom. */
placement?: "center" | "bottom";
className?: string;
children?: React.ReactNode;
}
const DISMISS_THRESHOLD = 120;
const DISMISS_VELOCITY = 800;
/**
* Morph stagger — the incoming tree holds back ~70ms so the height spring
* visibly leads the swap (beui: move the shape first, then introduce its
* label). Bespoke: it's a choreography offset, not a duration.
*/
const MORPH_LEAD_MS = 70;
export const MorphingModal = forwardRef<React.ElementRef<typeof View>, MorphingModalProps>(
function MorphingModal(
{ open, onOpenChange, title, description, showCloseButton = true, dismissible = true, swipeToDismiss = true, placement = "center", className, children },
ref
) {
const reduce = useReducedMotion();
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [mounted, setMounted] = React.useState(open);
const closePress = usePressAnimation({ pressedScale: 0.9 });
// Entrance: scale from 0.92 -> 1 + fade. Exit reverses.
const scale = useSharedValue(open ? 1 : 0.92);
const opacity = useSharedValue(open ? 1 : 0);
const translateY = useSharedValue(open ? 0 : placement === "bottom" ? 40 : 20);
// --- Height morph + content cross-fade ---
// `rendered` is the incoming content, `outgoing` the previous tree kept
// mounted for the cross-fade. `contentH` is the last measured content
// height (0 = unmeasured → the surface hugs content).
const [rendered, setRendered] = React.useState(children);
const [outgoing, setOutgoing] = React.useState<React.ReactNode | null>(null);
const [contentH, setContentH] = React.useState(0);
const prevChildren = React.useRef(children);
const contentHeight = useSharedValue(-1); // <0 = auto height until measured
// Split cross-fade: `outFade` drives the outgoing tree (fast exit),
// `inFade` the incoming one (normal entrance, delayed so the height
// morph leads). 1/0 resting = only the incoming tree visible.
const inFade = useSharedValue(1);
const outFade = useSharedValue(0);
// Body entrance — content staggers up a beat behind the header so the
// panel assembles top-down on open.
const bodyIn = useSharedValue(0);
// Drag offset layered on top of entrance translateY
const dragX = useSharedValue(0);
const dragY = useSharedValue(0);
const ctxStartX = useSharedValue(0);
const ctxStartY = useSharedValue(0);
const isDragging = useSharedValue(false);
React.useEffect(() => {
if (open) {
setMounted(true);
haptics.selection();
// Reopening lands on the current children — drop any morph left
// mid-flight by a previous close.
setOutgoing(null);
inFade.value = 1;
outFade.value = 0;
dragX.value = 0;
dragY.value = 0;
if (reduce) {
scale.value = 1;
opacity.value = 1;
translateY.value = 0;
bodyIn.value = 1;
} else {
scale.value = 0.92;
opacity.value = 0;
translateY.value = placement === "bottom" ? 40 : 20;
scale.value = withSpring(1, spring.surface);
opacity.value = withTiming(1, timing.normal);
translateY.value = withSpring(0, spring.surface);
bodyIn.value = 0;
bodyIn.value = withDelay(MORPH_LEAD_MS, withTiming(1, timing.normal));
}
} else if (mounted) {
// Exits run faster than entrances (timing.fast vs normal).
if (reduce) {
opacity.value = withTiming(0, timing.fast, (finished) => {
if (finished) runOnJS(setMounted)(false);
});
} else {
scale.value = withTiming(0.92, timing.fast);
opacity.value = withTiming(0, timing.fast);
translateY.value = withTiming(placement === "bottom" ? 40 : 20, timing.fast, (finished) => {
if (finished) runOnJS(setMounted)(false);
});
}
}
}, [open, reduce, placement, mounted, scale, opacity, translateY, haptics, dragX, dragY, inFade, outFade, bodyIn]);
// Content swap → morph: outgoing tree fades out over the incoming one
// while the measured-height effect below springs the surface to fit.
// Closed or reduced-motion: plain swap, no morph.
React.useEffect(() => {
if (prevChildren.current === children) return;
prevChildren.current = children;
if (!open || reduce) {
setOutgoing(null);
setRendered(children);
inFade.value = 1;
outFade.value = 0;
return;
}
setOutgoing(rendered);
setRendered(children);
inFade.value = 0;
outFade.value = 1;
}, [children, open, reduce, rendered, inFade, outFade]);
// Runs the actual cross-fade once `outgoing` is mounted, then clears
// it. The outgoing tree exits on timing.fast — it dissolves ahead of
// the incoming tree's delayed timing.normal arrival, so the height
// spring (fired by onLayout above) leads the whole swap.
React.useEffect(() => {
if (!outgoing) return;
outFade.value = withTiming(0, timing.fast, (finished) => {
if (finished) runOnJS(setOutgoing)(null);
});
inFade.value = withDelay(MORPH_LEAD_MS, withTiming(1, timing.normal));
}, [outgoing, inFade, outFade]);
// Height morph — spring the surface between measured content heights.
React.useEffect(() => {
if (contentH <= 0) return;
// First measurement snaps (no visible travel during the entrance);
// later changes spring between heights.
if (contentHeight.value < 0 || reduce) contentHeight.value = contentH;
else contentHeight.value = withSpring(contentH, spring.surface);
}, [contentH, contentHeight, reduce]);
const onContentLayout = useCallback((e: LayoutChangeEvent) => {
const h = e.nativeEvent.layout.height;
if (h > 0 && h !== contentH) setContentH(h);
}, [contentH]);
// Single close haptic — backdrop press, Android back, and the
// swipe-dismiss all funnel through here.
const handleClose = useCallback(() => {
if (!dismissible) return;
haptics.light();
onOpenChange(false);
}, [dismissible, onOpenChange, haptics]);
// Swipe-to-dismiss pan gesture
const pan = useMemo(() => {
if (!swipeToDismiss || reduce) return null;
return Gesture.Pan()
.activeOffsetX([-10, 10])
.activeOffsetY([-10, 10])
.onStart(() => {
"worklet";
ctxStartX.value = dragX.value;
ctxStartY.value = dragY.value;
isDragging.value = true;
})
.onUpdate((e) => {
"worklet";
dragX.value = ctxStartX.value + e.translationX;
// For bottom placement, only allow downward drag.
// For center placement, allow any direction.
if (placement === "bottom") {
dragY.value = Math.max(0, ctxStartY.value + e.translationY);
} else {
dragY.value = ctxStartY.value + e.translationY;
}
// Fade the panel as it's dragged away
const dist = Math.sqrt(dragX.value * dragX.value + dragY.value * dragY.value);
opacity.value = interpolate(dist, [0, 300], [1, 0.3], "clamp");
})
.onEnd((e) => {
"worklet";
isDragging.value = false;
const dist = Math.sqrt(dragX.value * dragX.value + dragY.value * dragY.value);
const velocity = Math.sqrt(e.velocityX * e.velocityX + e.velocityY * e.velocityY);
if (dist > DISMISS_THRESHOLD || velocity > DISMISS_VELOCITY) {
// Dismiss — fly away on the normal band (long travel needs the full beat).
const dir = Math.atan2(dragY.value || 1, dragX.value || 0);
dragX.value = withTiming(Math.cos(dir) * 600, timing.normal);
dragY.value = withTiming(Math.sin(dir) * 600, timing.normal);
opacity.value = withTiming(0, timing.normal);
runOnJS(handleClose)();
} else {
// Snap back — the selection haptic fires when the spring
// settles, so it marks the landing, not the grab.
dragX.value = withSpring(0, spring.press, (finished) => {
if (finished) runOnJS(haptics.selection)();
});
dragY.value = withSpring(0, spring.press);
opacity.value = withTiming(1, timing.normal);
}
});
}, [swipeToDismiss, reduce, ctxStartX, ctxStartY, dragX, dragY, opacity, isDragging, placement, haptics, handleClose]);
const panelStyle = useAnimatedStyle(() => ({
transform: [
{ scale: scale.value },
{ translateX: dragX.value },
{ translateY: translateY.value + dragY.value },
],
opacity: opacity.value,
}));
const backdropStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
// Fixed measured height while morphing. The shared value's <0 sentinel
// means "unmeasured" — a worklet must return a number, so it falls back
// to the last measured JS height (0 on the very first frame, which is
// fine: the panel is still fading in from opacity 0).
const sizeStyle = useAnimatedStyle(
() => ({
height: contentHeight.value < 0 ? contentH : contentHeight.value,
overflow: "hidden" as const,
}),
[contentH]
);
// Blur cross-fade approximation: opacity swap plus a slight counter-scale
// so the outgoing tree feels like it dissolves forward.
const incomingStyle = useAnimatedStyle(() => ({
opacity: inFade.value,
transform: [{ scale: interpolate(inFade.value, [0, 1], [0.97, 1], "clamp") }],
}));
const outgoingStyle = useAnimatedStyle(() => ({
opacity: outFade.value,
transform: [{ scale: interpolate(outFade.value, [0, 1], [1.04, 1], "clamp") }],
}));
const bodyStyle = useAnimatedStyle(() => ({
opacity: bodyIn.value,
transform: [{ translateY: (1 - bodyIn.value) * 8 }],
}));
if (!mounted) return null;
const panel = (
<Animated.View
ref={ref}
style={[
{
// Same sheet shadow as Dialog — black, soft, centered. A bare
// shadow-lg class renders as a hard offset slab on web.
shadowColor: "#000",
shadowOpacity: 0.24,
shadowRadius: 24,
shadowOffset: { width: 0, height: 12 },
elevation: 12,
},
panelStyle,
]}
className={cn(
"w-full max-w-sm self-center overflow-hidden rounded-3xl border border-border",
className
)}
// RN's accessibilityRole enum has no "dialog" — `role` carries
// the ARIA dialog semantics instead (matches Dialog/Popover).
role="dialog"
aria-modal
accessibilityLabel={title ?? "Modal"}
accessibilityLiveRegion="polite"
>
<Glass effect="regular" className="flex-1 rounded-3xl">
{(title || showCloseButton) && (
<View className="flex-row items-center justify-between px-5 pt-5 pb-2">
{title ? (
<Text className="text-lg font-semibold leading-tight text-card-foreground">{title}</Text>
) : <View />}
{showCloseButton ? (
<AnimatedPressable
onPress={() => {
haptics.light();
onOpenChange(false);
}}
onPressIn={closePress.onPressIn}
onPressOut={closePress.onPressOut}
style={closePress.animatedStyle}
hitSlop={8}
className="h-8 w-8 items-center justify-center rounded-full bg-foreground/5"
accessibilityRole="button"
accessibilityLabel="Close"
>
<Icon name="x" size="sm" color="mutedForeground" />
</AnimatedPressable>
) : null}
</View>
)}
{description ? (
<Text className="px-5 pb-2 text-sm text-muted-foreground">{description}</Text>
) : null}
<Animated.View style={bodyStyle} className="px-5 pb-5 pt-2">
<Animated.View style={sizeStyle}>
{/* Incoming content stays in flow so its onLayout measures the
natural height the surface morphs toward. */}
<Animated.View style={incomingStyle} onLayout={onContentLayout}>
{rendered}
</Animated.View>
{outgoing ? (
<Animated.View
pointerEvents="none"
style={[{ position: "absolute", top: 0, left: 0, right: 0 }, outgoingStyle]}
>
{outgoing}
</Animated.View>
) : null}
</Animated.View>
</Animated.View>
</Glass>
</Animated.View>
);
return (
<Modal transparent visible={mounted} animationType="none" onRequestClose={handleClose}>
<View className="flex-1" style={{ width: "100%", height: "100%" }} pointerEvents="box-none">
{/* Backdrop */}
<Animated.View
style={[{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }, backdropStyle]}
className="bg-overlay"
pointerEvents="auto"
>
{dismissible ? (
<Pressable
style={{ width: "100%", height: "100%" }}
onPress={handleClose}
accessibilityRole="button"
accessibilityLabel="Close modal"
/>
) : null}
</Animated.View>
{/* Panel */}
<View
className="flex-1"
style={{
justifyContent: placement === "bottom" ? "flex-end" : "center",
paddingBottom: placement === "bottom" ? Math.max(40, insets.bottom + 16) : 0,
paddingHorizontal: 16,
}}
pointerEvents="box-none"
>
{pan ? (
<GestureDetector gesture={pan}>
<View collapsable={false}>{panel}</View>
</GestureDetector>
) : (
panel
)}
</View>
</View>
</Modal>
);
}
);
MorphingModal.displayName = "MorphingModal";