diff --git a/packages/react/src/drawer/drawer.tsx b/packages/react/src/drawer/drawer.tsx
index 52c88c5b..a57acbc5 100755
--- a/packages/react/src/drawer/drawer.tsx
+++ b/packages/react/src/drawer/drawer.tsx
@@ -1,6 +1,6 @@
import React, { useContext, useEffect, useId, useRef, useState } from 'react';
import classNames from 'classnames';
-import { CSSTransition } from 'react-transition-group';
+import Transition from '../transition';
import Overlay from '../overlay';
import { ConfigContext } from '../config-provider/config-context';
import { getPrefixCls } from '../_utils/general';
@@ -91,11 +91,12 @@ const Drawer = React.forwardRef
((props, ref) => {
}}
style={maskStyle}>
-
((props, ref) => {
{children}
{footer &&
{footer}
}
-
+
);
diff --git a/packages/react/src/message/__tests__/__snapshots__/message.test.tsx.snap b/packages/react/src/message/__tests__/__snapshots__/message.test.tsx.snap
index ae3da2a5..3bbcad69 100644
--- a/packages/react/src/message/__tests__/__snapshots__/message.test.tsx.snap
+++ b/packages/react/src/message/__tests__/__snapshots__/message.test.tsx.snap
@@ -3,7 +3,7 @@
exports[` should match the snapshot 1`] = `
diff --git a/packages/react/src/overlay/overlay.tsx b/packages/react/src/overlay/overlay.tsx
index 9913b9f8..15a29277 100755
--- a/packages/react/src/overlay/overlay.tsx
+++ b/packages/react/src/overlay/overlay.tsx
@@ -1,7 +1,7 @@
import { useContext, useEffect, useRef } from 'react';
import classNames from 'classnames';
import Portal from '../portal';
-import { CSSTransition } from 'react-transition-group';
+import Transition from '../transition';
import { ConfigContext } from '../config-provider/config-context';
import { getPrefixCls } from '../_utils/general';
import { OverlayProps } from './types';
@@ -47,7 +47,7 @@ const Overlay = (props: OverlayProps): JSX.Element => {
return (
- {
{children}
-
+
);
};
diff --git a/packages/react/src/transition/__tests__/__snapshots__/transition.test.tsx.snap b/packages/react/src/transition/__tests__/__snapshots__/transition.test.tsx.snap
index cb1ad7d0..fbed1eb9 100644
--- a/packages/react/src/transition/__tests__/__snapshots__/transition.test.tsx.snap
+++ b/packages/react/src/transition/__tests__/__snapshots__/transition.test.tsx.snap
@@ -3,7 +3,7 @@
exports[` should match the snapshot 1`] = `
Content
diff --git a/packages/react/src/transition/index.tsx b/packages/react/src/transition/index.tsx
index fc7dd42c..81a2031f 100755
--- a/packages/react/src/transition/index.tsx
+++ b/packages/react/src/transition/index.tsx
@@ -1,4 +1,6 @@
import Transition from './transition';
export type { AnimationName, TransitionProps } from './transition';
+export { default as useTransition } from './use-transition';
+export type { TransitionState, UseTransitionOptions, UseTransitionResult } from './use-transition';
export default Transition;
diff --git a/packages/react/src/transition/transition.tsx b/packages/react/src/transition/transition.tsx
index 87e42335..65a7044f 100755
--- a/packages/react/src/transition/transition.tsx
+++ b/packages/react/src/transition/transition.tsx
@@ -1,6 +1,5 @@
import React from 'react';
-import { CSSTransition } from 'react-transition-group';
-import { CSSTransitionProps } from 'react-transition-group/CSSTransition';
+import useTransition, { TransitionState } from './use-transition';
export type AnimationName =
| 'zoom-center-top'
@@ -23,42 +22,112 @@ export type AnimationName =
| 'slide-down';
export type TransitionProps = {
+ in?: boolean;
+ timeout?: number | { enter: number; exit: number };
+ appear?: boolean;
+ unmountOnExit?: boolean;
+ mountOnEnter?: boolean;
+
/** Animation prefix */
prefix?: string;
/** Preset animation name */
animation?: AnimationName;
+ /** Custom class name base (overrides prefix + animation) */
+ classNames?: string;
+
/** Prevent the transition conflict with the inner component */
wrapper?: boolean;
+
+ nodeRef?: React.RefObject;
+
+ onEnter?: () => void;
+ onEntering?: () => void;
+ onEntered?: () => void;
+ onExit?: () => void;
+ onExiting?: () => void;
+ onExited?: () => void;
+
children?: React.ReactNode;
-} & Partial>;
+};
+
+function getTransitionClasses(base: string, state: TransitionState): string {
+ switch (state) {
+ case 'enter':
+ return `${base}-enter`;
+ case 'entering':
+ return `${base}-enter ${base}-enter-active`;
+ case 'entered':
+ return `${base}-enter-done`;
+ case 'exit':
+ return `${base}-exit`;
+ case 'exiting':
+ return `${base}-exit ${base}-exit-active`;
+ case 'exited':
+ return `${base}-exit-done`;
+ default:
+ return '';
+ }
+}
-const Transition = (props: TransitionProps): React.ReactElement => {
+const Transition = (props: TransitionProps): React.ReactElement | null => {
const {
+ in: inProp = false,
timeout = 300,
unmountOnExit = true,
+ mountOnEnter,
appear = true,
prefix = 'ty',
animation,
- classNames,
+ classNames: classNamesProp,
nodeRef,
children,
wrapper,
- ...otherProps
+ onEnter,
+ onEntering,
+ onEntered,
+ onExit,
+ onExiting,
+ onExited,
} = props;
- return (
- )}
- timeout={timeout}
- appear={appear}
- unmountOnExit={unmountOnExit}
- nodeRef={nodeRef}
- classNames={classNames ? classNames : `${prefix}-${animation}`}>
- {wrapper ? {children}
: (children as React.ReactElement)}
-
- );
+ const { state, shouldMount } = useTransition({
+ in: inProp,
+ timeout,
+ appear,
+ unmountOnExit,
+ mountOnEnter,
+ onEnter,
+ onEntering,
+ onEntered,
+ onExit,
+ onExiting,
+ onExited,
+ nodeRef,
+ });
+
+ if (!shouldMount) {
+ return null;
+ }
+
+ const base = classNamesProp ? classNamesProp : `${prefix}-${animation}`;
+ const transitionClasses = getTransitionClasses(base, state);
+
+ const child = wrapper ? {children}
: children;
+
+ if (React.isValidElement(child)) {
+ const existingClassName = (child.props as { className?: string }).className || '';
+ const mergedClassName = existingClassName
+ ? `${existingClassName} ${transitionClasses}`.trim()
+ : transitionClasses;
+
+ return React.cloneElement(child as React.ReactElement<{ className?: string }>, {
+ className: mergedClassName || undefined,
+ });
+ }
+
+ return child as React.ReactElement;
};
Transition.displayName = 'Transition';
diff --git a/packages/react/src/transition/use-transition.ts b/packages/react/src/transition/use-transition.ts
new file mode 100644
index 00000000..c7f1bc95
--- /dev/null
+++ b/packages/react/src/transition/use-transition.ts
@@ -0,0 +1,217 @@
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
+
+export type TransitionState =
+ | 'unmounted'
+ | 'enter'
+ | 'entering'
+ | 'entered'
+ | 'exit'
+ | 'exiting'
+ | 'exited';
+
+export interface UseTransitionOptions {
+ in: boolean;
+ timeout?: number | { enter: number; exit: number };
+ appear?: boolean;
+ unmountOnExit?: boolean;
+ mountOnEnter?: boolean;
+ onEnter?: () => void;
+ onEntering?: () => void;
+ onEntered?: () => void;
+ onExit?: () => void;
+ onExiting?: () => void;
+ onExited?: () => void;
+ nodeRef?: React.RefObject;
+}
+
+export interface UseTransitionResult {
+ state: TransitionState;
+ shouldMount: boolean;
+}
+
+function normalizeTimeout(
+ timeout: number | { enter: number; exit: number } | undefined
+): { enter: number; exit: number } {
+ if (timeout == null) return { enter: 300, exit: 300 };
+ if (typeof timeout === 'number') return { enter: timeout, exit: timeout };
+ return timeout;
+}
+
+function useTransition(options: UseTransitionOptions): UseTransitionResult {
+ const {
+ in: inProp,
+ timeout,
+ appear = true,
+ unmountOnExit = true,
+ mountOnEnter = false,
+ onEnter,
+ onEntering,
+ onEntered,
+ onExit,
+ onExiting,
+ onExited,
+ nodeRef,
+ } = options;
+
+ const normalizedTimeout = normalizeTimeout(timeout);
+
+ // Determine initial state
+ const getInitialState = (): TransitionState => {
+ if (inProp) {
+ return appear ? 'enter' : 'entered';
+ }
+ if (unmountOnExit || mountOnEnter) {
+ return 'unmounted';
+ }
+ return 'exited';
+ };
+
+ const [state, setState] = useState(getInitialState);
+ const rafRef = useRef(0);
+ const timerRef = useRef(0);
+ const transitionEndRef = useRef<(() => void) | null>(null);
+ const initialMountRef = useRef(true);
+
+ // Store latest callbacks in refs to avoid re-triggering effects
+ const callbacksRef = useRef({
+ onEnter,
+ onEntering,
+ onEntered,
+ onExit,
+ onExiting,
+ onExited,
+ });
+ callbacksRef.current = {
+ onEnter,
+ onEntering,
+ onEntered,
+ onExit,
+ onExiting,
+ onExited,
+ };
+
+ const cleanup = useCallback(() => {
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = 0;
+ }
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ timerRef.current = 0;
+ }
+ if (transitionEndRef.current && nodeRef?.current) {
+ nodeRef.current.removeEventListener('transitionend', transitionEndRef.current);
+ transitionEndRef.current = null;
+ }
+ }, [nodeRef]);
+
+ const waitForTransition = useCallback(
+ (phase: 'enter' | 'exit', done: () => void) => {
+ const safetyDuration =
+ phase === 'enter' ? normalizedTimeout.enter : normalizedTimeout.exit;
+
+ // timeout=0 means "don't wait" — advance immediately.
+ // This matches react-transition-group's behavior where timeout=0
+ // skips straight to the done state, letting CSS handle the animation
+ // via the base element's transition property.
+ if (safetyDuration === 0) {
+ done();
+ return;
+ }
+
+ const finish = () => {
+ cleanup();
+ done();
+ };
+
+ // Try transitionend listener first
+ const node = nodeRef?.current;
+ if (node) {
+ const handler = (e: Event) => {
+ if ((e as TransitionEvent).target === node) {
+ finish();
+ }
+ };
+ transitionEndRef.current = handler as () => void;
+ node.addEventListener('transitionend', handler);
+ }
+
+ // Safety timeout fallback
+ timerRef.current = window.setTimeout(finish, safetyDuration + 50);
+ },
+ [cleanup, nodeRef, normalizedTimeout.enter, normalizedTimeout.exit]
+ );
+
+ // Continue the enter animation after DOM is committed (state === 'enter').
+ // useLayoutEffect fires synchronously after DOM mutations, guaranteeing
+ // the child is mounted and refs are populated before onEnter runs.
+ useLayoutEffect(() => {
+ if (state === 'enter') {
+ callbacksRef.current.onEnter?.();
+
+ // Single rAF: useLayoutEffect runs before paint, so one rAF is enough
+ // to ensure the browser has painted the -enter class before we add -enter-active.
+ rafRef.current = requestAnimationFrame(() => {
+ setState('entering');
+ callbacksRef.current.onEntering?.();
+
+ waitForTransition('enter', () => {
+ setState('entered');
+ callbacksRef.current.onEntered?.();
+ });
+ });
+ }
+ }, [state]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Continue the exit animation after DOM reflects exit state
+ useLayoutEffect(() => {
+ if (state === 'exit') {
+ callbacksRef.current.onExit?.();
+
+ rafRef.current = requestAnimationFrame(() => {
+ setState('exiting');
+ callbacksRef.current.onExiting?.();
+
+ waitForTransition('exit', () => {
+ setState((prev) => {
+ if (prev === 'exiting') {
+ callbacksRef.current.onExited?.();
+ return unmountOnExit ? 'unmounted' : 'exited';
+ }
+ return prev;
+ });
+ });
+ });
+ }
+ }, [state]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // React to `in` prop changes — only set the initial phase state.
+ // The useLayoutEffect above handles the rest after DOM commit.
+ useEffect(() => {
+ if (initialMountRef.current) {
+ initialMountRef.current = false;
+ // On initial mount with appear=true, state is already 'enter' from getInitialState,
+ // and the useLayoutEffect above will pick it up.
+ return;
+ }
+
+ if (inProp) {
+ cleanup();
+ setState('enter');
+ } else {
+ cleanup();
+ setState('exit');
+ }
+ }, [inProp]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return cleanup;
+ }, [cleanup]);
+
+ const shouldMount = state !== 'unmounted';
+
+ return { state, shouldMount };
+}
+
+export default useTransition;
diff --git a/packages/react/src/waterfall/__tests__/__snapshots__/waterfall.test.tsx.snap b/packages/react/src/waterfall/__tests__/__snapshots__/waterfall.test.tsx.snap
index 7544b768..4bbbf3b4 100644
--- a/packages/react/src/waterfall/__tests__/__snapshots__/waterfall.test.tsx.snap
+++ b/packages/react/src/waterfall/__tests__/__snapshots__/waterfall.test.tsx.snap
@@ -7,7 +7,7 @@ exports[` should match the snapshot 1`] = `
style="position: relative; height: 16px;"
>
should match the snapshot 1`] = `