diff --git a/packages/eui/changelogs/upcoming/9514.md b/packages/eui/changelogs/upcoming/9514.md
new file mode 100644
index 000000000000..f793c7fd53ba
--- /dev/null
+++ b/packages/eui/changelogs/upcoming/9514.md
@@ -0,0 +1,3 @@
+**Bug fixes**
+
+- Fixed `EuiFlyoutManager` animation flickering when switching between flyout sessions by removing intermediate transition stages (backgrounding, returning, closing) and limiting opening animations to the initial flyout and first child only
diff --git a/packages/eui/src/components/flyout/manager/activity_stage.test.tsx b/packages/eui/src/components/flyout/manager/activity_stage.test.tsx
index cfcf66979cde..5fb44914eac8 100644
--- a/packages/eui/src/components/flyout/manager/activity_stage.test.tsx
+++ b/packages/eui/src/components/flyout/manager/activity_stage.test.tsx
@@ -93,13 +93,16 @@ describe('useFlyoutActivityStage', () => {
const TestComponent = ({
flyoutId,
level,
+ shouldAnimate,
}: {
flyoutId: string;
level: 'main' | 'child';
+ shouldAnimate?: boolean;
}) => {
const { activityStage, onAnimationEnd } = useFlyoutActivityStage({
flyoutId,
level,
+ shouldAnimate,
});
return (
@@ -175,7 +178,7 @@ describe('useFlyoutActivityStage', () => {
});
describe('stage transitions based on activity', () => {
- it('transitions from ACTIVE to CLOSING when flyout becomes inactive', () => {
+ it('when shouldAnimate is false (default), transitions directly to final stage: ACTIVE to INACTIVE', () => {
let currentMockState = buildMockState({
layoutMode: LAYOUT_MODE_SIDE_BY_SIDE,
mainFlyoutId: 'main-1',
@@ -197,12 +200,10 @@ describe('useFlyoutActivityStage', () => {
);
- // Initially active
expect(screen.getByTestSubject('activity-stage')).toHaveTextContent(
STAGE_ACTIVE
);
- // Change to inactive - session no longer contains main-1
currentMockState = buildMockState({
layoutMode: LAYOUT_MODE_SIDE_BY_SIDE,
mainFlyoutId: 'other-main',
@@ -217,12 +218,55 @@ describe('useFlyoutActivityStage', () => {
});
rerender();
+ expect(mockDispatch).toHaveBeenCalledWith(
+ mockSetActivityStage('main-1', STAGE_INACTIVE)
+ );
+ });
+
+ it('when shouldAnimate is true, transitions to intermediate CLOSING when flyout becomes inactive', () => {
+ let currentMockState = buildMockState({
+ layoutMode: LAYOUT_MODE_SIDE_BY_SIDE,
+ mainFlyoutId: 'main-1',
+ childFlyoutId: null,
+ flyouts: [
+ {
+ flyoutId: 'main-1',
+ level: LEVEL_MAIN,
+ activityStage: STAGE_ACTIVE,
+ },
+ ],
+ });
+ mockUseFlyoutManager.mockImplementation(() => ({
+ state: currentMockState,
+ dispatch: mockDispatch,
+ }));
+
+ const { rerender } = render(
+
+ );
+
+ currentMockState = buildMockState({
+ layoutMode: LAYOUT_MODE_SIDE_BY_SIDE,
+ mainFlyoutId: 'other-main',
+ childFlyoutId: null,
+ flyouts: [
+ {
+ flyoutId: 'main-1',
+ level: LEVEL_MAIN,
+ activityStage: STAGE_ACTIVE,
+ },
+ ],
+ });
+ rerender(
+
+ );
+
expect(mockDispatch).toHaveBeenCalledWith(
mockSetActivityStage('main-1', STAGE_CLOSING)
);
});
- it('transitions from INACTIVE to RETURNING when flyout becomes active', () => {
+ it('when shouldAnimate is false (default), transitions directly: INACTIVE to ACTIVE', () => {
const stateWithInactive = buildMockState({
layoutMode: LAYOUT_MODE_SIDE_BY_SIDE,
mainFlyoutId: 'other-main',
@@ -245,12 +289,10 @@ describe('useFlyoutActivityStage', () => {
);
- // Initially inactive
expect(screen.getByTestSubject('activity-stage')).toHaveTextContent(
STAGE_INACTIVE
);
- // Change to active - session now contains main-1
currentMockState = buildMockState({
layoutMode: LAYOUT_MODE_SIDE_BY_SIDE,
mainFlyoutId: 'main-1',
@@ -265,6 +307,50 @@ describe('useFlyoutActivityStage', () => {
});
rerender();
+ expect(mockDispatch).toHaveBeenCalledWith(
+ mockSetActivityStage('main-1', STAGE_ACTIVE)
+ );
+ });
+
+ it('when shouldAnimate is true, transitions to intermediate RETURNING when flyout becomes active', () => {
+ const stateWithInactive = buildMockState({
+ layoutMode: LAYOUT_MODE_SIDE_BY_SIDE,
+ mainFlyoutId: 'other-main',
+ childFlyoutId: null,
+ flyouts: [
+ {
+ flyoutId: 'main-1',
+ level: LEVEL_MAIN,
+ activityStage: STAGE_INACTIVE,
+ },
+ ],
+ });
+ let currentMockState = stateWithInactive;
+ mockUseFlyoutManager.mockImplementation(() => ({
+ state: currentMockState,
+ dispatch: mockDispatch,
+ }));
+
+ const { rerender } = render(
+
+ );
+
+ currentMockState = buildMockState({
+ layoutMode: LAYOUT_MODE_SIDE_BY_SIDE,
+ mainFlyoutId: 'main-1',
+ childFlyoutId: null,
+ flyouts: [
+ {
+ flyoutId: 'main-1',
+ level: LEVEL_MAIN,
+ activityStage: STAGE_INACTIVE,
+ },
+ ],
+ });
+ rerender(
+
+ );
+
expect(mockDispatch).toHaveBeenCalledWith(
mockSetActivityStage('main-1', STAGE_RETURNING)
);
@@ -272,7 +358,7 @@ describe('useFlyoutActivityStage', () => {
});
describe('main flyout backgrounding logic', () => {
- it('transitions to BACKGROUNDING when main flyout is active, has child, and layout is stacked', () => {
+ it('when shouldAnimate is false (default), transitions directly to BACKGROUNDED when main has child and layout is stacked', () => {
const stateWithChild = buildMockState({
layoutMode: LAYOUT_MODE_STACKED,
mainFlyoutId: 'main-1',
@@ -286,6 +372,27 @@ describe('useFlyoutActivityStage', () => {
render();
+ expect(mockDispatch).toHaveBeenCalledWith(
+ mockSetActivityStage('main-1', STAGE_BACKGROUNDED)
+ );
+ });
+
+ it('when shouldAnimate is true, transitions to BACKGROUNDING when main has child and layout is stacked', () => {
+ const stateWithChild = buildMockState({
+ layoutMode: LAYOUT_MODE_STACKED,
+ mainFlyoutId: 'main-1',
+ childFlyoutId: 'child-1',
+ flyouts: defaultFlyouts,
+ });
+ mockUseFlyoutManager.mockReturnValue({
+ state: stateWithChild,
+ dispatch: mockDispatch,
+ });
+
+ render(
+
+ );
+
expect(mockDispatch).toHaveBeenCalledWith(
mockSetActivityStage('main-1', STAGE_BACKGROUNDING)
);
@@ -350,7 +457,7 @@ describe('useFlyoutActivityStage', () => {
});
describe('main flyout returning logic', () => {
- it('transitions from BACKGROUNDED to RETURNING when child is gone', () => {
+ it('when shouldAnimate is false (default), transitions directly from BACKGROUNDED to ACTIVE when child is gone', () => {
const stateWithBackgrounded = buildMockState({
layoutMode: LAYOUT_MODE_STACKED,
mainFlyoutId: 'main-1',
@@ -371,11 +478,11 @@ describe('useFlyoutActivityStage', () => {
render();
expect(mockDispatch).toHaveBeenCalledWith(
- mockSetActivityStage('main-1', STAGE_RETURNING)
+ mockSetActivityStage('main-1', STAGE_ACTIVE)
);
});
- it('transitions from BACKGROUNDING to RETURNING when child is gone', () => {
+ it('when shouldAnimate is false (default), transitions directly from BACKGROUNDING to ACTIVE when child is gone', () => {
const stateWithBackgrounding = buildMockState({
layoutMode: LAYOUT_MODE_STACKED,
mainFlyoutId: 'main-1',
@@ -395,12 +502,39 @@ describe('useFlyoutActivityStage', () => {
render();
+ expect(mockDispatch).toHaveBeenCalledWith(
+ mockSetActivityStage('main-1', STAGE_ACTIVE)
+ );
+ });
+
+ it('when shouldAnimate is true, transitions from BACKGROUNDED to RETURNING when child is gone', () => {
+ const stateWithBackgrounded = buildMockState({
+ layoutMode: LAYOUT_MODE_STACKED,
+ mainFlyoutId: 'main-1',
+ childFlyoutId: null,
+ flyouts: [
+ {
+ flyoutId: 'main-1',
+ level: LEVEL_MAIN,
+ activityStage: STAGE_BACKGROUNDED,
+ },
+ ],
+ });
+ mockUseFlyoutManager.mockReturnValue({
+ state: stateWithBackgrounded,
+ dispatch: mockDispatch,
+ });
+
+ render(
+
+ );
+
expect(mockDispatch).toHaveBeenCalledWith(
mockSetActivityStage('main-1', STAGE_RETURNING)
);
});
- it('transitions from BACKGROUNDED to RETURNING when layout changes to side-by-side', () => {
+ it('when shouldAnimate is false (default), transitions directly from BACKGROUNDED to ACTIVE when layout is side-by-side', () => {
const stateWithBackgrounded = buildMockState({
layoutMode: LAYOUT_MODE_SIDE_BY_SIDE,
mainFlyoutId: 'main-1',
@@ -421,7 +555,7 @@ describe('useFlyoutActivityStage', () => {
render();
expect(mockDispatch).toHaveBeenCalledWith(
- mockSetActivityStage('main-1', STAGE_RETURNING)
+ mockSetActivityStage('main-1', STAGE_ACTIVE)
);
});
diff --git a/packages/eui/src/components/flyout/manager/activity_stage.ts b/packages/eui/src/components/flyout/manager/activity_stage.ts
index 404916a14f7a..8407dc0482d2 100644
--- a/packages/eui/src/components/flyout/manager/activity_stage.ts
+++ b/packages/eui/src/components/flyout/manager/activity_stage.ts
@@ -26,13 +26,41 @@ import { useFlyoutManager } from './provider';
export interface UseFlyoutActivityStageParams {
flyoutId: string;
level: EuiFlyoutLevel;
+ /** When false, skip intermediate stages (CLOSING, RETURNING, BACKGROUNDING) and transition directly to final state. */
+ shouldAnimate?: boolean;
}
export interface UseFlyoutActivityStageReturn {
activityStage: EuiFlyoutActivityStage;
+ /**
+ * Pass to the flyout's `onAnimationEnd` prop to finalize transitional stages
+ * (e.g. CLOSING -> INACTIVE). When `shouldAnimate` is false, the intermediate
+ * CLOSING/RETURNING/BACKGROUNDING stages are skipped, but OPENING -> ACTIVE
+ * still relies on this handler since new flyouts always start in OPENING.
+ */
onAnimationEnd: () => void;
}
+/**
+ * Returns the final stage after an animation completes.
+ * OPENING/RETURNING -> ACTIVE; CLOSING -> INACTIVE; BACKGROUNDING -> BACKGROUNDED.
+ */
+const getNextStage = (
+ stage: EuiFlyoutActivityStage
+): EuiFlyoutActivityStage | null => {
+ switch (stage) {
+ case STAGE_OPENING:
+ case STAGE_RETURNING:
+ return STAGE_ACTIVE;
+ case STAGE_CLOSING:
+ return STAGE_INACTIVE;
+ case STAGE_BACKGROUNDING:
+ return STAGE_BACKGROUNDED;
+ default:
+ return null;
+ }
+};
+
/**
* Encapsulates all activity-stage transitions and animation-driven updates
* for managed flyouts.
@@ -44,6 +72,7 @@ export interface UseFlyoutActivityStageReturn {
export const useFlyoutActivityStage = ({
flyoutId,
level,
+ shouldAnimate = false,
}: UseFlyoutActivityStageParams) => {
const ctx = useFlyoutManager();
const state = ctx?.state;
@@ -74,83 +103,68 @@ export const useFlyoutActivityStage = ({
stageRef.current = stage;
}
+ const transitionTo = useCallback(
+ (nextStage: EuiFlyoutActivityStage) => {
+ ctx?.dispatch?.(setActivityStage(flyoutId, nextStage));
+ stageRef.current = nextStage;
+ },
+ [ctx, flyoutId]
+ );
+
/**
- * 1. ACTIVE -> CLOSING when no longer the active flyout.
- * 2. INACTIVE -> RETURNING when it becomes active again (e.g., reopened or brought forward).
- * 3. (Main flyout only) ACTIVE + stacked + has child -> BACKGROUNDING (begin background animation).
- * 4. (Main only) BACKGROUNDED/BACKGROUNDING + (child gone OR side-by-side) -> RETURNING (bring main to foreground).
- *
- * Any stages that depend on animation end (OPENING, RETURNING, CLOSING, BACKGROUNDING) are finalized in `onAnimationEnd`.
+ * 1. ACTIVE -> CLOSING (or INACTIVE when !shouldAnimate) when no longer the active flyout.
+ * 2. INACTIVE -> RETURNING (or ACTIVE when !shouldAnimate) when it becomes active again.
+ * 3. (Main only) ACTIVE + stacked + has child -> BACKGROUNDING (or BACKGROUNDED when !shouldAnimate).
+ * 4. (Main only) BACKGROUNDED/BACKGROUNDING + (child gone OR side-by-side) -> RETURNING (or ACTIVE when !shouldAnimate).
*/
useEffect(() => {
const s = stageRef.current;
let next: EuiFlyoutActivityStage | null = null;
- if (s === STAGE_ACTIVE && !isActive) next = STAGE_CLOSING;
- else if (s === STAGE_INACTIVE && isActive) {
- next = STAGE_RETURNING;
+ if (s === STAGE_ACTIVE && !isActive) {
+ next = shouldAnimate ? STAGE_CLOSING : STAGE_INACTIVE;
+ } else if (s === STAGE_INACTIVE && isActive) {
+ next = shouldAnimate ? STAGE_RETURNING : STAGE_ACTIVE;
} else if (
level === LEVEL_MAIN &&
isActive &&
s === STAGE_ACTIVE &&
hasChild &&
layoutMode === LAYOUT_MODE_STACKED
- )
- next = STAGE_BACKGROUNDING;
- else if (
+ ) {
+ next = shouldAnimate ? STAGE_BACKGROUNDING : STAGE_BACKGROUNDED;
+ } else if (
level === LEVEL_MAIN &&
(s === STAGE_BACKGROUNDED || s === STAGE_BACKGROUNDING) &&
(!hasChild || layoutMode === LAYOUT_MODE_SIDE_BY_SIDE)
- )
- next = STAGE_RETURNING;
-
- if (next && next !== s) {
- ctx?.dispatch?.(setActivityStage(flyoutId, next));
- stageRef.current = next;
+ ) {
+ next = shouldAnimate ? STAGE_RETURNING : STAGE_ACTIVE;
}
- }, [isActive, hasChild, layoutMode, level, ctx, flyoutId, stage]);
- /**
- * Get the stage to transition to for given current stage.
- * Returns `null` if stage should remain unchanged.
- *
- * Stage transitions:
- * - OPENING / RETURNING -> ACTIVE
- * - CLOSING -> INACTIVE
- * - BACKGROUNDING -> BACKGROUNDED
- */
- const getNextStage = (
- stage: EuiFlyoutActivityStage
- ): EuiFlyoutActivityStage | null => {
- switch (stage) {
- case STAGE_OPENING:
- case STAGE_RETURNING:
- return STAGE_ACTIVE;
-
- case STAGE_CLOSING:
- return STAGE_INACTIVE;
-
- case STAGE_BACKGROUNDING:
- return STAGE_BACKGROUNDED;
+ if (next && next !== s) {
+ transitionTo(next);
}
-
- return null;
- };
+ }, [
+ isActive,
+ hasChild,
+ layoutMode,
+ level,
+ shouldAnimate,
+ transitionTo,
+ stage,
+ ]);
/**
- * onAnimationEnd event handler that must be passed to EuiFlyout.
- * It handles transitions between stages and updates activity stage
- * in EuiFlyoutManagerContext.
+ * onAnimationEnd: browser signal when a CSS animation completes.
+ * Calls transitionTo to move to the final stage (e.g. CLOSING -> INACTIVE).
*/
const onAnimationEnd = useCallback(() => {
const currentStage = stageRef.current;
const nextStage = getNextStage(currentStage);
-
if (nextStage && nextStage !== currentStage) {
- ctx?.dispatch?.(setActivityStage(flyoutId, nextStage));
- stageRef.current = nextStage;
+ transitionTo(nextStage);
}
- }, [ctx, flyoutId]);
+ }, [transitionTo]);
return { activityStage: stage, onAnimationEnd };
};
diff --git a/packages/eui/src/components/flyout/manager/flyout_managed.styles.ts b/packages/eui/src/components/flyout/manager/flyout_managed.styles.ts
index a4199a294558..b74d0c54f4f8 100644
--- a/packages/eui/src/components/flyout/manager/flyout_managed.styles.ts
+++ b/packages/eui/src/components/flyout/manager/flyout_managed.styles.ts
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-import { css, keyframes } from '@emotion/react';
+import { css } from '@emotion/react';
import { euiCanAnimate, logicalCSS } from '../../../global_styling';
import { UseEuiTheme } from '../../../services';
import {
@@ -36,54 +36,6 @@ export const euiManagedFlyoutStyles = (euiThemeContext: UseEuiTheme) => {
side: _EuiFlyoutSide = DEFAULT_SIDE,
level: EuiFlyoutLevel
) => {
- // Animation for moving flyout backwards in 3D space (z-axis) when inactive
- const euiFlyoutSlideBack3D = keyframes`
- from {
- transform: translateZ(0) translateX(0) scale(1);
- filter: blur(0px);
- opacity: 1;
- }
- to {
- transform: translateZ(-1500px) translateX(${
- side === 'left' ? 'calc(-100vw - 100%)' : 'calc(100vw + 100%)'
- }) scale(0.5);
- filter: blur(3px);
- opacity: 0.6;
- }
- `;
-
- // Animation for bringing flyout forward from 3D space when transitioning to active
- const euiFlyoutSlideForward3D = keyframes`
- from {
- transform: translateZ(-500px) translateX(${
- side === 'left' ? 'calc(-100vw - 100%)' : 'calc(100vw + 100%)'
- }) scale(0.85);
- filter: blur(3px);
- opacity: 0.6;
- }
- to {
- transform: translateZ(0) translateX(0) scale(1);
- filter: blur(0px);
- opacity: 1;
- }
- `;
- // When flyout is becoming inactive, animate backwards in 3D space
- const inactiveTransition = css`
- ${euiCanAnimate} {
- animation: ${euiFlyoutSlideBack3D} ${euiTheme.animation.extraSlow}
- ${euiTheme.animation.resistance} forwards;
- pointer-events: none;
- }
- `;
-
- // When flyout is becoming active from a backgrounded state, animate forward in 3D space
- const returningTransition = css`
- ${euiCanAnimate} {
- animation: ${euiFlyoutSlideForward3D} ${euiTheme.animation.normal}
- ${euiTheme.animation.resistance} forwards;
- }
- `;
-
const noTransition = css`
${euiCanAnimate} {
animation: none;
@@ -115,19 +67,19 @@ export const euiManagedFlyoutStyles = (euiThemeContext: UseEuiTheme) => {
return [activeFlyout, noTransition];
case STAGE_BACKGROUNDING:
- return [inactiveTransition];
+ return [inactiveFlyout, noTransition];
case STAGE_BACKGROUNDED:
return [inactiveFlyout, noTransition];
case STAGE_RETURNING:
- return [activeFlyout, returningTransition];
+ return [activeFlyout, noTransition];
case STAGE_INACTIVE:
return [inactiveFlyout, noTransition];
case STAGE_CLOSING:
- return [inactiveTransition];
+ return [inactiveFlyout, noTransition];
}
},
managedFlyout: css`
diff --git a/packages/eui/src/components/flyout/manager/flyout_managed.tsx b/packages/eui/src/components/flyout/manager/flyout_managed.tsx
index 14d57126c34c..bc77c638c6c1 100644
--- a/packages/eui/src/components/flyout/manager/flyout_managed.tsx
+++ b/packages/eui/src/components/flyout/manager/flyout_managed.tsx
@@ -14,6 +14,7 @@ import React, {
useState,
forwardRef,
} from 'react';
+import { css } from '@emotion/react';
import { flushSync } from 'react-dom';
import { useCombinedRefs, useEuiMemoizedStyles } from '../../../services';
import { useEuiI18n } from '../../i18n';
@@ -131,6 +132,13 @@ export const EuiManagedFlyout = forwardRef(
?.size
: undefined;
+ // Animate opening only for the first main flyout (sole session) or first child (no prior child in session).
+ const shouldAnimateOpening =
+ level === LEVEL_MAIN
+ ? (managerSessions?.length ?? 0) <= 1 &&
+ currentSession?.mainFlyoutId === flyoutId
+ : (session?.childHistory?.length ?? 0) === 0;
+
const styles = useEuiMemoizedStyles(euiManagedFlyoutStyles);
// Set default size based on level: main defaults to 'm', child defaults to 's'
@@ -309,6 +317,7 @@ export const EuiManagedFlyout = forwardRef(
const { activityStage, onAnimationEnd } = useFlyoutActivityStage({
flyoutId,
level,
+ shouldAnimate: false,
});
// Note: history controls are only relevant for main flyouts
@@ -341,6 +350,11 @@ export const EuiManagedFlyout = forwardRef(
styles.managedFlyout,
customCss,
styles.stage(activityStage, props.side, level),
+ // Suppress EuiFlyout's built-in opening animation for non-initial flyouts.
+ !shouldAnimateOpening &&
+ css`
+ animation-duration: 0s !important;
+ `,
]}
{...{
...props,