Veltrix

cards

Matched Detail Transition

A measured card-to-detail transition that preserves geometry and content continuity inside its parent.

AppleiOSAndroidAndroidExpoExpo Go
portalreanimatedsafe area
GitHubOpen in GitHub

Installation

Install the dependencies, then copy the files for your preferred renderer.

pnpm add @rn-primitives/portal react-native-reanimated react-native-safe-area-context

Anatomy

1 file

anatomy.tsx
<MatchedDetailTransition>  <MatchedDetailTransition.Content />  <MatchedDetailTransition.Detail />  <MatchedDetailTransition.PortalHost /></MatchedDetailTransition>

Install tailwind variants

pnpm add tailwind-variants

Usage

MatchedDetailTransitionExample.tsx
import { 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>  );}

Component files

6 files

matched-detail-transition/config.ts
export const MATCHED_DETAIL_TRANSITION_CONFIG = {  detailBorderRadius: 30,  duration: 420,  sourceBorderRadius: 24,} as const;
matched-detail-transition/portal-host.tsx
import { 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.ts
import 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.tsx
import { 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.ts
import { 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.ts
import { 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,  };}

API reference

Generated directly from the exported component props.

MatchedDetailTransition

MatchedDetailTransition.Content

MatchedDetailTransition.Detail

MatchedDetailTransition.PortalHost

MatchedDetailTransition.PortalHost extends all props from MatchedDetailTransitionPortalHostProps, with the additional component-specific props shown below.