import { Children, type Key, type ReactElement, type ReactNode, cloneElement, isValidElement, useEffect, useRef, useState, } from "react"; import { useReducedMotion } from "../../core/ReducedMotionContext"; import { View } from "../primitives/View"; export interface PresenceBaseProps { /** * Identity of the current child. When it changes, the previous child is kept * mounted (with `exitClassName`) for `exitDurationMs` so it can animate out * while the new child animates in — an AnimatePresence-style swap done with * pure CSS animations, no animation library. */ activeKey: Key; /** How long to keep the exiting child mounted — match the exit animation. */ exitDurationMs: number; /** Animation class applied to the entering (current) child. */ enterClassName?: string; /** Animation class applied to the exiting (previous) child. */ exitClassName?: string; /** Class applied to every item (e.g. `absolute inset-0` to overlap). */ className?: string; } interface Snapshot { key: Key; node: ReactNode; } function joinClasses(...classes: (string | undefined)[]): string { return classes.filter(Boolean).join(" "); } /** * Shared keyed-swap logic. Keeps the previously rendered child as a snapshot * after `activeKey` changes so an exit animation can play, then removes it once * `exitDurationMs` has elapsed. The entering child is keyed by `activeKey`, so * it remounts on each change and replays its enter animation. * * Assumes one child whose content is derived from `activeKey` (the common * keyed-swap case). Updates to the active child between key changes are shown * live but not snapshotted for the next exit. * * Under reduced motion nothing is snapshotted: the swap is immediate. */ function usePresence( activeKey: Key, exitDurationMs: number, children: ReactNode, reducedMotion: boolean, ): Snapshot[] { const [exiting, setExiting] = useState([]); const previousRef = useRef({ key: activeKey, node: children }); const childrenRef = useRef(children); childrenRef.current = children; const timersRef = useRef[]>([]); useEffect( () => () => { timersRef.current.forEach(clearTimeout); }, [], ); useEffect(() => { const previous = previousRef.current; if (previous.key === activeKey) { return; } previousRef.current = { key: activeKey, node: childrenRef.current }; if (reducedMotion) { return; } setExiting((list) => [...list, previous]); const timer = setTimeout(() => { setExiting((list) => list.filter((item) => item !== previous)); timersRef.current = timersRef.current.filter((t) => t !== timer); }, exitDurationMs); timersRef.current.push(timer); }, [activeKey, exitDurationMs, reducedMotion]); return reducedMotion ? [] : exiting; } interface PresenceItem { key: Key; node: ReactNode; } function toItems(children: ReactNode): PresenceItem[] { return Children.toArray(children) .filter(isValidElement) .map((child) => ({ key: child.key as Key, node: child })); } /** * Order-preserving merge of the previously rendered key order with the current * live keys (the react-transition-group algorithm). Removed keys stay in their * old positions, so an exiting item animates out in place instead of jumping to * the end of the list. */ function mergeKeys(previous: Key[], next: Key[]): Key[] { const nextSet = new Set(next); const pendingByNext = new Map(); let pending: Key[] = []; for (const key of previous) { if (nextSet.has(key)) { if (pending.length > 0) { pendingByNext.set(key, pending); pending = []; } } else { pending.push(key); } } const result: Key[] = []; for (const key of next) { const before = pendingByNext.get(key); if (before) { result.push(...before); } result.push(key); } result.push(...pending); return result; } interface RenderedItem extends PresenceItem { exiting: boolean; } /** * Diffs a list of keyed children across renders. New keys are returned with * `exiting: false` (mounted fresh, so their enter animation plays); removed keys * are kept with `exiting: true` for `exitDurationMs` so they can animate out, * then dropped. Order is preserved via {@link mergeKeys}. Under reduced motion a * removed key is dropped at once. */ function usePresenceList( children: ReactNode, exitDurationMs: number, reducedMotion: boolean, ): RenderedItem[] { const items = toItems(children); const liveKeys = items.map((item) => item.key); const signature = liveKeys.join(""); const nodesRef = useRef>(new Map()); for (const item of items) { nodesRef.current.set(item.key, item.node); } const liveKeysRef = useRef(liveKeys); liveKeysRef.current = liveKeys; const [order, setOrder] = useState(liveKeys); const orderRef = useRef(order); orderRef.current = order; const timersRef = useRef>>(new Map()); useEffect( () => () => { timersRef.current.forEach(clearTimeout); }, [], ); useEffect(() => { const live = new Set(liveKeysRef.current); if (reducedMotion) { timersRef.current.forEach(clearTimeout); timersRef.current.clear(); for (const key of nodesRef.current.keys()) { if (!live.has(key)) nodesRef.current.delete(key); } setOrder(liveKeysRef.current); return; } const newOrder = mergeKeys(orderRef.current, liveKeysRef.current); // A key that came back before its timer fired cancels its pending exit. for (const key of liveKeysRef.current) { const timer = timersRef.current.get(key); if (timer) { clearTimeout(timer); timersRef.current.delete(key); } } // A key that's gone animates out, then is dropped once the timer fires. for (const key of newOrder) { if (!live.has(key) && !timersRef.current.has(key)) { const timer = setTimeout(() => { timersRef.current.delete(key); nodesRef.current.delete(key); setOrder((current) => current.filter((k) => k !== key)); }, exitDurationMs); timersRef.current.set(key, timer); } } setOrder(newOrder); }, [signature, exitDurationMs, reducedMotion]); if (reducedMotion) { return items.map((item) => ({ ...item, exiting: false })); } const live = new Set(liveKeys); return order.map((key) => ({ key, node: nodesRef.current.get(key), exiting: !live.has(key), })); } export interface PresenceListProps { /** How long to keep a removed child mounted — match the exit animation. */ exitDurationMs: number; /** Animation class applied to entering children. */ enterClassName?: string; /** Animation class applied to exiting children. */ exitClassName?: string; /** Class applied to every item's wrapper ``. */ className?: string; /** * A list of keyed elements — each child must have a stable `key`. Adding a key * animates that item in while the others stay put; removing a key animates only * that item out. */ children: ReactNode; } /** * Keyed-list presence (AnimatePresence-style). Renders a list of keyed children, * each wrapped in its own ``, and animates individual add/remove: added * keys mount with `enterClassName`, removed keys stay mounted with `exitClassName` * for `exitDurationMs` before unmounting. For swapping a single child, use * {@link PresenceOne}. Under reduced motion the animation classes are dropped * and a removed child unmounts at once. */ export function PresenceList({ exitDurationMs, enterClassName, exitClassName, className, children, }: PresenceListProps): ReactNode { const reducedMotion = useReducedMotion(); const items = usePresenceList(children, exitDurationMs, reducedMotion); // No item is ever exiting under reduced motion, so only the enter class needs dropping. const enterAnimationClassName = reducedMotion ? undefined : enterClassName; return ( <> {items.map((item) => ( {item.node} ))} ); } type StyledElement = ReactElement<{ className?: string }>; export interface PresenceOneProps extends PresenceBaseProps { /** * A single element that accepts and forwards `className` to its root view. * The `className` + enter/exit animation classes are merged onto it directly, * so no extra wrapper `` is added to the tree. */ children: StyledElement; } /** * Keyed-swap presence that merges the animation classes onto the child element * itself via `cloneElement` — no wrapper ``. Requires a single element * that forwards `className`; for anything else use {@link PresenceList}. Under * reduced motion the animation classes are dropped and the swap is immediate. */ export function PresenceOne({ activeKey, exitDurationMs, enterClassName, exitClassName, className, children, }: PresenceOneProps): ReactNode { const reducedMotion = useReducedMotion(); const exiting = usePresence( activeKey, exitDurationMs, children, reducedMotion, ); return ( <> {exiting.map((item) => { const node = item.node as StyledElement; return cloneElement(node, { key: item.key, className: joinClasses( node.props.className, className, exitClassName, ), }); })} {cloneElement(children, { key: activeKey, className: joinClasses( children.props.className, className, reducedMotion ? undefined : enterClassName, ), })} ); }