cards
Matched Detail Transition
A measured card-to-detail transition that preserves geometry and content continuity inside its parent.
iOSAndroidExpo Go
portalreanimatedsafe area
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add @rn-primitives/portal react-native-reanimated react-native-safe-area-contextanatomy.tsx<MatchedDetailTransition> <MatchedDetailTransition.Content /> <MatchedDetailTransition.Detail /> <MatchedDetailTransition.PortalHost /></MatchedDetailTransition>Install tailwind variants
pnpm add tailwind-variantsUsage
MatchedDetailTransitionExample.tsximport { Pressable, Text, View } from "react-native";import { MatchedDetailTransition } from "@animations/ui/components/cards/matched-detail-transition/uniwind";export function MatchedDetailTransitionExample() { return ( <MatchedDetailTransition.PortalHost className="relative overflow-hidden" > <MatchedDetailTransition classNames={{ surface: "rounded-3xl bg-violet-600 shadow-xl", }} > <MatchedDetailTransition.Content> <View className="gap-2 p-5"> <Text className="font-extrabold text-white/60"> WEEKLY SIGNAL </Text> <Text className="text-3xl font-black text-white"> +24% activation </Text> </View> </MatchedDetailTransition.Content> <MatchedDetailTransition.Detail> {({ close }) => ( <View className="flex-1 gap-3 p-5"> <Text className="text-2xl font-black text-white"> Momentum is building. </Text> <Text className="text-white/60"> Activation increased across the full cohort. </Text> <Pressable onPress={close}> <Text className="font-bold text-white">Close</Text> </Pressable> </View> )} </MatchedDetailTransition.Detail> </MatchedDetailTransition> </MatchedDetailTransition.PortalHost> );}matched-detail-transition/config.tsexport const MATCHED_DETAIL_TRANSITION_CONFIG = { detailBorderRadius: 30, duration: 420, sourceBorderRadius: 24,} as const;matched-detail-transition/portal-host.tsximport { PortalHost } from "@rn-primitives/portal";import { createContext, type RefObject, useContext, useId, useRef,} from "react";import { View, type ViewProps } from "react-native";type MatchedDetailTransitionPortalContextValue = { name: string; ref: RefObject<View | null>;};const MatchedDetailTransitionPortalContext = createContext< MatchedDetailTransitionPortalContextValue | undefined >(undefined);export type MatchedDetailTransitionPortalHostProps = ViewProps;export function MatchedDetailTransitionPortalHost({ children, ...props}: MatchedDetailTransitionPortalHostProps) { const ref = useRef<View>(null); const name = useId(); return ( <MatchedDetailTransitionPortalContext value={{ name, ref }} > <View ref={ref} collapsable={false} {...props}> {children} <PortalHost name={name} /> </View> </MatchedDetailTransitionPortalContext> );}export function useMatchedDetailTransitionPortalHost() { return useContext(MatchedDetailTransitionPortalContext);}matched-detail-transition/types.tsimport type { ReactNode } from "react";export type MatchedDetailTransitionRenderState = { close: () => void; expanded: boolean; open: () => void; toggle: () => void;};export type MatchedDetailTransitionContent = | ReactNode | ((state: MatchedDetailTransitionRenderState) => ReactNode);export type MatchedDetailTransitionSlotProps = { children: MatchedDetailTransitionContent;};export type MatchedDetailTransitionBaseProps = { accessibilityLabel?: string; children: ReactNode; detailBorderRadius?: number; disabled?: boolean; duration?: number; expandedInsets?: { bottom?: number; left?: number; right?: number; top?: number; }; expanded?: boolean; onExpandedChange?: (expanded: boolean) => void; showBackdrop?: boolean; sourceBorderRadius?: number;};matched-detail-transition/uniwind/index.tsximport { Portal } from "@rn-primitives/portal";import { Children, createContext, isValidElement, useContext, useId, type ReactElement,} from "react";import { Pressable, View } from "react-native";import Animated from "react-native-reanimated";import type { SlotsToClasses } from "#shared/types/slots";import type { MatchedDetailTransitionBaseProps, MatchedDetailTransitionRenderState, MatchedDetailTransitionSlotProps} from "../types";import { MatchedDetailTransitionPortalHost as MatchedDetailTransitionPortalHostPrimitive, type MatchedDetailTransitionPortalHostProps, useMatchedDetailTransitionPortalHost,} from "../portal-host";import { useMatchedDetailTransition } from "../use-matched-detail-transition";import { matchedDetailTransitionVariants, type MatchedDetailTransitionSlot,} from "./variants";export type MatchedDetailTransitionProps = MatchedDetailTransitionBaseProps & { className?: string; classNames?: SlotsToClasses<MatchedDetailTransitionSlot>; };export type { MatchedDetailTransitionPortalHostProps } from "../portal-host";const MatchedDetailTransitionSlotContext = createContext<MatchedDetailTransitionRenderState | null>(null);function MatchedDetailTransitionPortalHost( props: MatchedDetailTransitionPortalHostProps,) { return <MatchedDetailTransitionPortalHostPrimitive {...props} />;}function MatchedDetailTransitionContentSlot( { children }: MatchedDetailTransitionSlotProps,) { const state = useContext(MatchedDetailTransitionSlotContext); if (!state) { throw new Error( "MatchedDetailTransition.Content must be used inside MatchedDetailTransition.", ); } return typeof children === "function" ? children(state) : children;}function MatchedDetailTransitionDetail( { children }: MatchedDetailTransitionSlotProps,) { const state = useContext(MatchedDetailTransitionSlotContext); if (!state) { throw new Error( "MatchedDetailTransition.Detail must be used inside MatchedDetailTransition.", ); } return typeof children === "function" ? children(state) : children;}function getMatchedDetailTransitionSlot( children: MatchedDetailTransitionProps["children"], type: | typeof MatchedDetailTransitionContentSlot | typeof MatchedDetailTransitionDetail,) { const slot = Children.toArray(children).find( (child): child is ReactElement<MatchedDetailTransitionSlotProps> => isValidElement(child) && child.type === type, ); return slot;}function MatchedDetailTransitionRoot({ accessibilityLabel = "Open detail", children, className, classNames, detailBorderRadius, disabled, duration, expanded, expandedInsets, onExpandedChange, showBackdrop = true, sourceBorderRadius,}: MatchedDetailTransitionProps) { const content = getMatchedDetailTransitionSlot( children, MatchedDetailTransitionContentSlot, ); const detail = getMatchedDetailTransitionSlot( children, MatchedDetailTransitionDetail, ); const portalName = useId(); const resolvedPortalHost = useMatchedDetailTransitionPortalHost(); const state = useMatchedDetailTransition({ detailBorderRadius, disabled, duration, expanded, expandedInsets, onExpandedChange, sourceBorderRadius, portalHostRef: resolvedPortalHost?.ref, }); const { backdrop, detail: detailClass, root, source, surface, } = matchedDetailTransitionVariants({ disabled }); const sourceState = { close: state.close, expanded: false, open: state.open, toggle: state.toggle, }; const overlayState = { close: state.close, expanded: state.overlayExpanded, open: state.open, toggle: state.toggle, }; return ( <View className={root({ className: [className, classNames?.root] })}> <Animated.View collapsable={false} ref={state.sourceRef} className={source({ className: [ surface({ className: classNames?.surface }), classNames?.source, ], })} style={state.sourceHidden ? { opacity: 0 } : undefined} > <Pressable accessibilityLabel={accessibilityLabel} accessibilityRole="button" disabled={disabled} onPress={state.open} > <MatchedDetailTransitionSlotContext.Provider value={sourceState}> {content} </MatchedDetailTransitionSlotContext.Provider> </Pressable> </Animated.View> {state.overlayVisible && ( <Portal hostName={resolvedPortalHost?.name} name={`${portalName}-matched-detail`} > <> {showBackdrop && ( <Animated.View className={backdrop({ className: classNames?.backdrop, })} style={state.backdropAnimatedStyle} /> )} <Animated.View className={surface({ className: classNames?.surface, })} onLayout={state.onOverlayLayout} style={[ { position: "absolute" }, state.surfaceAnimatedStyle, ]} > <MatchedDetailTransitionSlotContext.Provider value={overlayState}> {content} </MatchedDetailTransitionSlotContext.Provider> <Animated.View className={detailClass({ className: classNames?.detail, })} style={state.detailAnimatedStyle} > <MatchedDetailTransitionSlotContext.Provider value={overlayState}> {detail} </MatchedDetailTransitionSlotContext.Provider> </Animated.View> </Animated.View> </> </Portal> )} </View> );}export const MatchedDetailTransition = Object.assign( MatchedDetailTransitionRoot, { Content: MatchedDetailTransitionContentSlot, Detail: MatchedDetailTransitionDetail, PortalHost: MatchedDetailTransitionPortalHost, },);matched-detail-transition/uniwind/variants.tsimport { tv } from "tailwind-variants";export const matchedDetailTransitionSlots = { backdrop: "absolute inset-0 bg-black/35", detail: "flex-1", root: "w-full", source: "w-full overflow-hidden", surface: "overflow-hidden",} as const;export type MatchedDetailTransitionSlot = keyof typeof matchedDetailTransitionSlots;export const matchedDetailTransitionVariants = tv({ slots: matchedDetailTransitionSlots, variants: { disabled: { true: { root: "opacity-[0.45]", }, }, },});matched-detail-transition/use-matched-detail-transition.tsimport { type RefObject, useEffect, useRef, useState } from "react";import { Dimensions, type View } from "react-native";import { Easing, interpolate, useAnimatedStyle, useReducedMotion, useSharedValue, withTiming,} from "react-native-reanimated";import { scheduleOnRN } from "react-native-worklets";import { MATCHED_DETAIL_TRANSITION_CONFIG } from "./config";import type { MatchedDetailTransitionBaseProps } from "./types";export function useMatchedDetailTransition({ detailBorderRadius = MATCHED_DETAIL_TRANSITION_CONFIG.detailBorderRadius, disabled = false, duration = MATCHED_DETAIL_TRANSITION_CONFIG.duration, expanded, expandedInsets, onExpandedChange, portalHostRef, sourceBorderRadius = MATCHED_DETAIL_TRANSITION_CONFIG.sourceBorderRadius,}: Pick< MatchedDetailTransitionBaseProps, | "detailBorderRadius" | "disabled" | "duration" | "expanded" | "expandedInsets" | "onExpandedChange" | "sourceBorderRadius"> & { portalHostRef?: RefObject<View | null>;}) { const sourceRef = useRef<View>(null); const [overlayExpanded, setOverlayExpanded] = useState(false); const [overlayReady, setOverlayReady] = useState(false); const [overlayVisible, setOverlayVisible] = useState(false); const reducedMotion = useReducedMotion(); const progress = useSharedValue(0); const sourceHeight = useSharedValue(0); const sourceWidth = useSharedValue(0); const sourceX = useSharedValue(0); const sourceY = useSharedValue(0); const targetHeight = useSharedValue(0); const targetWidth = useSharedValue(0); const expandedInsetBottom = expandedInsets?.bottom ?? 0; const expandedInsetLeft = expandedInsets?.left ?? 0; const expandedInsetRight = expandedInsets?.right ?? 0; const expandedInsetTop = expandedInsets?.top ?? 0; const backdropAnimatedStyle = useAnimatedStyle(() => ({ opacity: progress.get(), })); const detailAnimatedStyle = useAnimatedStyle(() => ({ opacity: interpolate(progress.get(), [0, 0.5, 1], [0, 0, 1]), transform: [ { translateY: interpolate(progress.get(), [0, 1], [16, 0]), }, ], })); const surfaceAnimatedStyle = useAnimatedStyle(() => ({ borderRadius: interpolate( progress.get(), [0, 1], [sourceBorderRadius, detailBorderRadius], ), height: interpolate( progress.get(), [0, 1], [sourceHeight.get(), targetHeight.get()], ), left: interpolate(progress.get(), [0, 1], [sourceX.get(), 0]), paddingBottom: interpolate( progress.get(), [0, 1], [0, expandedInsetBottom], ), paddingLeft: interpolate( progress.get(), [0, 1], [0, expandedInsetLeft], ), paddingRight: interpolate( progress.get(), [0, 1], [0, expandedInsetRight], ), paddingTop: interpolate( progress.get(), [0, 1], [0, expandedInsetTop], ), top: interpolate(progress.get(), [0, 1], [sourceY.get(), 0]), width: interpolate( progress.get(), [0, 1], [sourceWidth.get(), targetWidth.get()], ), })); function animateTo( nextProgress: number, onComplete?: () => void, ) { if (reducedMotion) { progress.set(nextProgress); onComplete?.(); return; } progress.set( withTiming( nextProgress, { duration, easing: Easing.bezier(0.22, 1, 0.36, 1), }, (finished) => { if (finished && onComplete) { scheduleOnRN(onComplete); } }, ), ); } function open() { if (disabled || overlayVisible) { return; } measureFrames(); } function measureFrames() { sourceRef.current?.measureInWindow((x, y, width, height) => { if (!portalHostRef?.current) { const { height: targetHeightValue, width: targetWidthValue } = Dimensions.get("window"); sourceHeight.set(height); sourceWidth.set(width); sourceX.set(x); sourceY.set(y); targetHeight.set(targetHeightValue); targetWidth.set(targetWidthValue); showOverlay(); return; } portalHostRef.current?.measureInWindow( (hostX, hostY, hostWidth, hostHeight) => { sourceHeight.set(height); sourceWidth.set(width); sourceX.set(x - hostX); sourceY.set(y - hostY); targetHeight.set(hostHeight); targetWidth.set(hostWidth); showOverlay(); }, ); }); } function showOverlay() { setOverlayExpanded(true); setOverlayReady(false); setOverlayVisible(true); onExpandedChange?.(true); progress.set(0); } function close() { if (disabled || !overlayVisible) { return; } setOverlayExpanded(false); animateTo(0, finishClose); } function toggle() { if (overlayVisible) { close(); return; } open(); } function onOverlayLayout() { if (overlayReady) { return; } setOverlayReady(true); requestAnimationFrame(() => animateTo(1)); } function finishClose() { setOverlayVisible(false); setOverlayReady(false); onExpandedChange?.(false); } useEffect(() => { if (expanded === true && !overlayVisible) { open(); } if (expanded === false && overlayVisible) { close(); } }, [expanded]); return { backdropAnimatedStyle, close, detailAnimatedStyle, open, onOverlayLayout, overlayExpanded, overlayVisible, sourceRef, sourceHidden: overlayVisible && overlayReady, surfaceAnimatedStyle, toggle, };}MatchedDetailTransition
MatchedDetailTransition.Content
MatchedDetailTransition.Detail
MatchedDetailTransition.PortalHost
MatchedDetailTransition.PortalHost extends all props from MatchedDetailTransitionPortalHostProps, with the additional component-specific props shown below.
