diff --git a/README.md b/README.md
index 0a3ead2..2d8b2ea 100644
--- a/README.md
+++ b/README.md
@@ -104,6 +104,8 @@ function FadeCard({ visible, children }) {
`EaseView` works like a regular `View` — it accepts children, styles, and all standard view props. When values in `animate` change, it smoothly transitions to the new values using native platform animations.
+For unmount/exit animations, wrap keyed children in `EasePresence` and provide `exit` on `EaseView`.
+
## Why
### Goals
@@ -423,6 +425,30 @@ Use `initialAnimate` to set starting values. On mount, the view starts at `initi
Without `initialAnimate`, the view renders at the `animate` values immediately with no animation on mount.
+### Exit Animations
+
+Use `EasePresence` + `exit` to animate before unmount. `EasePresence` keeps removed keyed children mounted until `onTransitionEnd({ finished: true })`.
+
+```tsx
+import { EasePresence, EaseView } from 'react-native-ease';
+
+
+ {visible ? (
+
+ ) : null}
+
+```
+
+Notes:
+
+- Child elements must have stable keys.
+- Avoid `transition.loop` on exiting views (`repeat`/`reverse` never complete).
+
### Delay
Use `delay` to postpone the start of an animation. This is useful for staggering enter animations across multiple elements.
diff --git a/docs/docs/api-reference.mdx b/docs/docs/api-reference.mdx
index 9711d6f..e383a9b 100644
--- a/docs/docs/api-reference.mdx
+++ b/docs/docs/api-reference.mdx
@@ -11,6 +11,7 @@ A `View` that animates property changes using native platform APIs.
| ------------------ | ---------------------------- | ----------- |
| `animate` | `AnimateProps` | Target values for animated properties |
| `initialAnimate` | `AnimateProps` | Starting values for enter animations |
+| `exit` | `AnimateProps` | Target values for unmount animations (used by `EasePresence`) |
| `transition` | `Transition` | Single config or per-property map |
| `onTransitionEnd` | `(event) => void` | Called when all animations complete with `{ finished: boolean }` |
| `transformOrigin` | `{ x?: number; y?: number }` | Pivot point for scale/rotation as 0–1 fractions |
@@ -21,6 +22,14 @@ A `View` that animates property changes using native platform APIs.
| `children` | `ReactNode` | Child elements |
| `...rest` | `ViewProps` | All other standard View props |
+## ``
+
+Wrap keyed children to run `exit` animations before unmount.
+
+| Prop | Type | Description |
+| ---------- | ----------- | ----------- |
+| `children` | `ReactNode` | Keyed `EaseView` children to retain while their exit animations run |
+
## `AnimateProps`
| Property | Type | Default | Description |
diff --git a/docs/docs/usage.mdx b/docs/docs/usage.mdx
index 1d18fbe..db8aeee 100644
--- a/docs/docs/usage.mdx
+++ b/docs/docs/usage.mdx
@@ -225,6 +225,26 @@ Loop requires `initialAnimate` to define the starting value. Spring animations d
/>
```
+## Exit animations
+
+Use `EasePresence` + `exit` to animate before unmount.
+
+```tsx
+
+ {visible ? (
+
+ ) : null}
+
+```
+
+- Child elements must have stable keys.
+- Avoid `transition.loop` (`repeat`/`reverse`) on exiting views.
+
## Delay
```tsx
diff --git a/example/src/demos/ExitDemo.tsx b/example/src/demos/ExitDemo.tsx
index 1cb19c1..e94552b 100644
--- a/example/src/demos/ExitDemo.tsx
+++ b/example/src/demos/ExitDemo.tsx
@@ -1,46 +1,39 @@
import { useState } from 'react';
import { View, StyleSheet } from 'react-native';
-import { EaseView, type TransitionEndEvent } from 'react-native-ease';
+import { EasePresence, EaseView } from 'react-native-ease';
import { Section } from '../components/Section';
import { Button } from '../components/Button';
export function ExitDemo() {
const [show, setShow] = useState(true);
- const [exiting, setExiting] = useState(false);
-
- const handleTransitionEnd = ({ finished }: TransitionEndEvent) => {
- if (finished) {
- setShow(false);
- setExiting(false);
- }
- };
return (
- {show && (
-
- )}
+
+ {show ? (
+
+ ) : null}
+
);
diff --git a/skills/react-native-ease-refactor/SKILL.md b/skills/react-native-ease-refactor/SKILL.md
index 7fc3170..129ee87 100644
--- a/skills/react-native-ease-refactor/SKILL.md
+++ b/skills/react-native-ease-refactor/SKILL.md
@@ -87,7 +87,7 @@ Use this table to convert Reanimated/Animated patterns to EaseView:
| `entering={SlideInLeft}` / `SlideInRight` | `initialAnimate={{ translateX: ±value }}` + `animate={{ translateX: 0 }}` |
| `entering={SlideInUp}` / `SlideInDown` | `initialAnimate={{ translateY: ±value }}` + `animate={{ translateY: 0 }}` |
| `entering={ZoomIn}` | `initialAnimate={{ scale: 0 }}` + `animate={{ scale: 1 }}` |
-| `exiting={FadeOut}` / other exit animations | State-driven exit: boolean state + `onTransitionEnd` to unmount (flag as "requires state changes" in report) |
+| `exiting={FadeOut}` / other exit animations | `EasePresence` + `exit={{ ... }}` on `EaseView` with a stable `key` |
| `withRepeat(withTiming(...), -1, false)` | `transition={{ type: 'timing', ..., loop: 'repeat' }}` + `initialAnimate` for start value |
| `withRepeat(withTiming(...), -1, true)` | `transition={{ type: 'timing', ..., loop: 'reverse' }}` + `initialAnimate` for start value |
| `Easing.linear` | `easing: 'linear'` |
@@ -219,7 +219,7 @@ Format:
**Current:** Brief description of what the animation does and which API it uses
**Proposed:** What the EaseView equivalent looks like (include exact transition values with mapped defaults)
**Changes:** What will be added/removed/modified
-**Note:** (only if applicable) "Requires state changes for exit animation" or other caveats
+**Note:** (only if applicable) "Needs `EasePresence` wrapper + stable keys for exit animation" or other caveats
### Not Migratable (will be skipped)
diff --git a/src/EasePresence.tsx b/src/EasePresence.tsx
new file mode 100644
index 0000000..6fdb314
--- /dev/null
+++ b/src/EasePresence.tsx
@@ -0,0 +1,200 @@
+import {
+ Children,
+ cloneElement,
+ isValidElement,
+ type Key,
+ type ReactElement,
+ type ReactNode,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
+import type { AnimateProps, Transition, TransitionEndEvent } from './types';
+import type { EaseViewProps } from './EaseView';
+
+type PresenceItem = {
+ key: string;
+ element: ReactElement;
+ isExiting: boolean;
+ exitVersion: number;
+};
+
+export type EasePresenceProps = {
+ children: ReactNode;
+};
+
+function normalizeKey(key: Key | null): string {
+ return key == null ? '__ease_presence_no_key__' : String(key);
+}
+
+function hasLoopingTransition(transition?: Transition): boolean {
+ if (!transition) return false;
+
+ const isLoopingTiming = (value: Transition | undefined) =>
+ value != null &&
+ 'type' in value &&
+ value.type === 'timing' &&
+ (value.loop === 'repeat' || value.loop === 'reverse');
+
+ if (isLoopingTiming(transition)) {
+ return true;
+ }
+
+ if (!('type' in transition)) {
+ return (
+ isLoopingTiming(transition.default) ||
+ isLoopingTiming(transition.transform) ||
+ isLoopingTiming(transition.opacity) ||
+ isLoopingTiming(transition.borderRadius) ||
+ isLoopingTiming(transition.backgroundColor) ||
+ isLoopingTiming(transition.border) ||
+ isLoopingTiming(transition.shadow)
+ );
+ }
+
+ return false;
+}
+
+function toEaseElements(children: ReactNode): ReactElement[] {
+ return Children.toArray(children).filter(
+ (child: ReactNode): child is ReactElement =>
+ isValidElement(child),
+ );
+}
+
+function warnForMissingKeys(children: ReactNode): void {
+ if (!__DEV__) return;
+
+ Children.forEach(children, (child: ReactNode) => {
+ if (isValidElement(child) && child.key == null) {
+ console.warn(
+ 'react-native-ease: EasePresence requires stable keys on children to run exit animations.',
+ );
+ }
+ });
+}
+
+export function EasePresence({ children }: EasePresenceProps) {
+ const childElements = useMemo(() => toEaseElements(children), [children]);
+
+ const [items, setItems] = useState(() =>
+ childElements.map((element) => ({
+ key: normalizeKey(element.key),
+ element,
+ isExiting: false,
+ exitVersion: 0,
+ })),
+ );
+
+ useEffect(() => {
+ warnForMissingKeys(children);
+
+ setItems((prev: PresenceItem[]) => {
+ const incomingByKey = new Map>();
+ const incomingOrder: string[] = [];
+
+ for (const element of childElements) {
+ const key = normalizeKey(element.key);
+ incomingByKey.set(key, element);
+ incomingOrder.push(key);
+ }
+
+ const prevByKey = new Map(
+ prev.map((item: PresenceItem) => [item.key, item]),
+ );
+ const next: PresenceItem[] = [];
+
+ for (const key of incomingOrder) {
+ const nextElement = incomingByKey.get(key)!;
+ const existing = prevByKey.get(key);
+ if (existing) {
+ next.push({
+ key,
+ element: nextElement,
+ isExiting: false,
+ exitVersion: existing.exitVersion,
+ });
+ } else {
+ next.push({
+ key,
+ element: nextElement,
+ isExiting: false,
+ exitVersion: 0,
+ });
+ }
+ }
+
+ for (const oldItem of prev) {
+ if (incomingByKey.has(oldItem.key)) {
+ continue;
+ }
+
+ if (oldItem.isExiting) {
+ next.push(oldItem);
+ continue;
+ }
+
+ const exit = oldItem.element.props.exit;
+ if (!exit) {
+ continue;
+ }
+
+ if (hasLoopingTransition(oldItem.element.props.transition)) {
+ if (__DEV__) {
+ console.warn(
+ 'react-native-ease: EasePresence ignores exit animation when transition.loop is repeat/reverse. Remove loop on exiting views.',
+ );
+ }
+ continue;
+ }
+
+ next.push({
+ ...oldItem,
+ isExiting: true,
+ exitVersion: oldItem.exitVersion + 1,
+ });
+ }
+
+ return next;
+ });
+ }, [childElements, children]);
+
+ return (
+ <>
+ {items.map((item: PresenceItem) => {
+ if (!item.isExiting) {
+ return item.element;
+ }
+
+ const exitAnimate: AnimateProps | undefined = item.element.props.exit;
+ if (!exitAnimate) {
+ return item.element;
+ }
+
+ const originalOnTransitionEnd = item.element.props.onTransitionEnd;
+ const exitingKey = item.key;
+ const exitingVersion = item.exitVersion;
+
+ return cloneElement(item.element, {
+ animate: exitAnimate,
+ onTransitionEnd: (event: TransitionEndEvent) => {
+ originalOnTransitionEnd?.(event);
+ if (!event.finished) {
+ return;
+ }
+ setItems((current: PresenceItem[]) =>
+ current.filter(
+ (candidate: PresenceItem) =>
+ !(
+ candidate.key === exitingKey &&
+ candidate.isExiting &&
+ candidate.exitVersion === exitingVersion
+ ),
+ ),
+ );
+ },
+ });
+ })}
+ >
+ );
+}
diff --git a/src/EaseView.tsx b/src/EaseView.tsx
index aadc03f..4d49f12 100644
--- a/src/EaseView.tsx
+++ b/src/EaseView.tsx
@@ -199,6 +199,8 @@ export type EaseViewProps = ViewProps & {
animate?: AnimateProps;
/** Starting values for enter animations. Animates to `animate` on mount. */
initialAnimate?: AnimateProps;
+ /** Target values for exit animations. Used by `EasePresence` before unmount. */
+ exit?: AnimateProps;
/** Animation configuration (timing or spring). */
transition?: Transition;
/** Called when all animations complete. Reports whether they finished naturally or were interrupted. */
@@ -241,6 +243,7 @@ export type EaseViewProps = ViewProps & {
export function EaseView({
animate,
initialAnimate,
+ exit: _exit,
transition,
onTransitionEnd,
useHardwareLayer = false,
diff --git a/src/EaseView.web.tsx b/src/EaseView.web.tsx
index 20963df..9617e96 100644
--- a/src/EaseView.web.tsx
+++ b/src/EaseView.web.tsx
@@ -118,6 +118,8 @@ const SPRING_FALLBACK_EASING = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)';
export type EaseViewProps = {
animate?: AnimateProps;
initialAnimate?: AnimateProps;
+ /** Target values for exit animations. Used by `EasePresence` before unmount. */
+ exit?: AnimateProps;
transition?: Transition;
onTransitionEnd?: (event: TransitionEndEvent) => void;
/** No-op on web. */
diff --git a/src/__tests__/EasePresence.test.tsx b/src/__tests__/EasePresence.test.tsx
new file mode 100644
index 0000000..a857e9d
--- /dev/null
+++ b/src/__tests__/EasePresence.test.tsx
@@ -0,0 +1,105 @@
+import { fireEvent, render, screen } from '@testing-library/react-native';
+import { EasePresence } from '../EasePresence';
+import { EaseView } from '../EaseView';
+
+describe('EasePresence', () => {
+ it('keeps removed child mounted while exit runs, then removes on finish', () => {
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ rerender({null});
+
+ const exiting = screen.getByTestId('card');
+ expect(exiting).toBeTruthy();
+ expect(exiting.props.animateOpacity).toBe(0);
+
+ fireEvent(exiting, 'onTransitionEnd', { nativeEvent: { finished: true } });
+ expect(screen.queryByTestId('card')).toBeNull();
+ });
+
+ it('removes immediately when child has no exit prop', () => {
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ rerender({null});
+ expect(screen.queryByTestId('card')).toBeNull();
+ });
+
+ it('forwards onTransitionEnd during exit', () => {
+ const onTransitionEnd = jest.fn();
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ rerender({null});
+
+ fireEvent(screen.getByTestId('card'), 'onTransitionEnd', {
+ nativeEvent: { finished: true },
+ });
+
+ expect(onTransitionEnd).toHaveBeenCalledWith({ finished: true });
+ });
+
+ it('does not remove exiting item when transition end is interrupted', () => {
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ rerender({null});
+
+ fireEvent(screen.getByTestId('card'), 'onTransitionEnd', {
+ nativeEvent: { finished: false },
+ });
+
+ expect(screen.queryByTestId('card')).toBeTruthy();
+ });
+
+ it('warns for missing keys', () => {
+ const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
+
+ render(
+
+
+ ,
+ );
+
+ expect(spy).toHaveBeenCalledWith(
+ expect.stringContaining('EasePresence requires stable keys on children'),
+ );
+
+ spy.mockRestore();
+ });
+});
diff --git a/src/__tests__/EaseView.test.tsx b/src/__tests__/EaseView.test.tsx
index 073ec68..613c141 100644
--- a/src/__tests__/EaseView.test.tsx
+++ b/src/__tests__/EaseView.test.tsx
@@ -124,6 +124,19 @@ describe('EaseView', () => {
expect(props.initialAnimateScaleX).toBe(1);
expect(props.initialAnimateScaleY).toBe(1);
});
+
+ it('does not forward exit prop to native component', () => {
+ render(
+ ,
+ );
+ const props = getNativeProps();
+ expect(props.exit).toBeUndefined();
+ expect(props.animateOpacity).toBe(1);
+ });
});
describe('transition defaults', () => {
diff --git a/src/index.tsx b/src/index.tsx
index 612c5da..80802b0 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,5 +1,7 @@
export { EaseView } from './EaseView';
export type { EaseViewProps } from './EaseView';
+export { EasePresence } from './EasePresence';
+export type { EasePresenceProps } from './EasePresence';
export type {
AnimateProps,
CubicBezier,