Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions packages/react-core/src/components/Backdrop/Backdrop.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,41 @@
import { css } from '@patternfly/react-styles';
import styles from '@patternfly/react-styles/css/components/Backdrop/backdrop';
import stylesAnimated from '@patternfly/react-styles/css/components/BackdropAnimations/backdrop-animations';
import { useHasAnimations } from '../../helpers';

export interface BackdropProps extends React.HTMLProps<HTMLDivElement> {
/** Content rendered inside the backdrop */
children?: React.ReactNode;
/** Additional classes added to the backdrop */
className?: string;
/** Flag indicating whether animations are enabled. */
hasAnimations?: boolean;
/** Flag to show the backdrop. Used in conjunction with `hasAnimations`. */
isVisible?: boolean;
}

export const Backdrop: React.FunctionComponent<BackdropProps> = ({
children = null,
className = '',
hasAnimations: hasAnimationsProp,
isVisible,
...props
}: BackdropProps) => (
<div {...props} className={css(styles.backdrop, className)}>
{children}
</div>
);
}: BackdropProps) => {
const hasAnimations = useHasAnimations(hasAnimationsProp);

return (
<div
{...props}
className={css(
styles.backdrop,
hasAnimations && stylesAnimated.backdropAnimated,
hasAnimations && isVisible === true && stylesAnimated.backdropAnimatedVisible,
hasAnimations && isVisible !== true && stylesAnimated.backdropAnimatedHidden,
className
)}
>
{children}
</div>
);
};
Backdrop.displayName = 'Backdrop';
2 changes: 2 additions & 0 deletions packages/react-core/src/components/Modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export interface ModalProps extends React.HTMLProps<HTMLDivElement>, OUIAProps {
ouiaId?: number | string;
/** Set the value of data-ouia-safe. Only set to true when the component is in a static state, i.e. no animations are occurring. At all other times, this value must be false. */
ouiaSafe?: boolean;
/** Flag indicating whether animations are enabled. */
hasAnimations?: boolean;
}

export enum ModalVariant {
Expand Down
10 changes: 10 additions & 0 deletions packages/react-core/src/components/Modal/ModalBox.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { css } from '@patternfly/react-styles';
import styles from '@patternfly/react-styles/css/components/ModalBox/modal-box';
import stylesAnimated from '@patternfly/react-styles/css/components/ModalAnimations/modal-animations';
import topSpacer from '@patternfly/react-tokens/dist/esm/c_modal_box_m_align_top_spacer';

export interface ModalBoxProps extends React.HTMLProps<HTMLDivElement> {
Expand All @@ -19,6 +20,10 @@ export interface ModalBoxProps extends React.HTMLProps<HTMLDivElement> {
positionOffset?: string;
/** Variant of the modal. */
variant?: 'small' | 'medium' | 'large' | 'default';
/** Flag indicating whether animations are enabled. */
hasAnimations?: boolean;
/** Flag to show the modal. */
isOpen?: boolean;
}

export const ModalBox: React.FunctionComponent<ModalBoxProps> = ({
Expand All @@ -31,6 +36,8 @@ export const ModalBox: React.FunctionComponent<ModalBoxProps> = ({
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedby,
style,
isOpen,
hasAnimations,
...props
}: ModalBoxProps) => {
if (positionOffset) {
Expand All @@ -46,6 +53,9 @@ export const ModalBox: React.FunctionComponent<ModalBoxProps> = ({
aria-modal="true"
className={css(
styles.modalBox,
hasAnimations && stylesAnimated.modalAnimated,
hasAnimations && isOpen === true && stylesAnimated.modalAnimatedOpen,
hasAnimations && isOpen !== true && stylesAnimated.modalAnimatedClosed,
className,
position === 'top' && styles.modifiers.alignTop,
variant === 'large' && styles.modifiers.lg,
Expand Down
20 changes: 16 additions & 4 deletions packages/react-core/src/components/Modal/ModalContent.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { FocusTrap } from '../../helpers';
import bullsEyeStyles from '@patternfly/react-styles/css/layouts/Bullseye/bullseye';
import { css } from '@patternfly/react-styles';
import { getOUIAProps, OUIAProps } from '../../helpers';
import { getOUIAProps, OUIAProps, useHasAnimations } from '../../helpers';
import { Backdrop } from '../Backdrop';
import { ModalBoxCloseButton } from './ModalBoxCloseButton';
import { ModalBox } from './ModalBox';
Expand Down Expand Up @@ -49,6 +49,8 @@ export interface ModalContentProps extends OUIAProps {
ouiaId?: number | string;
/** Set the value of data-ouia-safe. Only set to true when the component is in a static state, i.e. no animations are occurring. At all other times, this value must be false. */
ouiaSafe?: boolean;
/** Flag indicating whether animations are enabled. */
hasAnimations?: boolean;
}

export const ModalContent: React.FunctionComponent<ModalContentProps> = ({
Expand All @@ -72,9 +74,12 @@ export const ModalContent: React.FunctionComponent<ModalContentProps> = ({
ouiaSafe = true,
elementToFocus,
focusTrapId,
hasAnimations: hasAnimationsProp,
...props
}: ModalContentProps) => {
if (!isOpen) {
const hasAnimations = useHasAnimations(hasAnimationsProp);

if (!isOpen && !hasAnimations) {
return null;
}
Comment on lines +77 to 84

@coderabbitai coderabbitai Bot Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files 'packages/react-core/src/components/Modal/*' | sed 's#^`#FILE` #' | sort
echo '--- ModalContent.tsx ---'
cat -n packages/react-core/src/components/Modal/ModalContent.tsx
echo '--- search for transition/end handlers and unmount flags ---'
rg -n "onTransitionEnd|onAnimationEnd|shouldRender|setShouldRender|hasAnimations|useHasAnimations|ModalContent" packages/react-core/src/components/Modal -S

Repository: patternfly/patternfly-react

Length of output: 14629


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- Modal.tsx ---'
cat -n packages/react-core/src/components/Modal/Modal.tsx
echo '--- ModalBox.tsx ---'
cat -n packages/react-core/src/components/Modal/ModalBox.tsx
echo '--- ModalContent.test.tsx relevant assertions ---'
sed -n '1,220p' packages/react-core/src/components/Modal/__tests__/ModalContent.test.tsx | cat -n
echo '--- search docs/examples for closing animation / unmount ---'
rg -n "closing animation|remove the modal|hasAnimations|isOpen|unmount|transition end|animation end" packages/react-core/src/components/Modal -S

Repository: patternfly/patternfly-react

Length of output: 22854


Animated modals still never unmount on close packages/react-core/src/components/Modal/ModalContent.tsx:82-84

With hasAnimations, ModalContent keeps rendering after isOpen becomes false, and there’s no transition-end or timeout path to remove it after the close animation. The modal stays mounted until the parent unmounts it, so the “remove the modal after the closing animation completes” behavior is still missing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-core/src/components/Modal/ModalContent.tsx` around lines 77 -
84, Animated modal content remains mounted after closing because ModalContent
only skips rendering when animations are disabled. Add close-animation
completion handling around useHasAnimations and the isOpen state so the
component unmounts after the exit transition finishes, using a transition-end
callback or timeout fallback, while preserving immediate null rendering for
non-animated closes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring this to discuss with PF team during office-hours


Expand All @@ -91,6 +96,8 @@ export const ModalContent: React.FunctionComponent<ModalContentProps> = ({
const modalBox = (
<ModalBox
className={css(className)}
isOpen={isOpen}
hasAnimations={hasAnimations}
variant={variant}
position={position}
positionOffset={positionOffset}
Expand All @@ -113,10 +120,15 @@ export const ModalContent: React.FunctionComponent<ModalContentProps> = ({
{children}
</ModalBox>
);
let focusTrapActive = !disableFocusTrap;
if (hasAnimations) {
focusTrapActive = !disableFocusTrap && isOpen;
}

return (
<Backdrop className={css(backdropClassName)} id={backdropId}>
<Backdrop className={css(backdropClassName)} id={backdropId} hasAnimations={hasAnimations} isVisible={isOpen}>
<FocusTrap
active={!disableFocusTrap}
active={focusTrapActive}
focusTrapOptions={{
clickOutsideDeactivates: true,
tabbableOptions: { displayCheck: 'none' },
Expand Down
81 changes: 81 additions & 0 deletions packages/react-core/src/components/Modal/examples/Modal.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Modal.css
*
* To fix an issue with the PR preview deployment,
* this file contains the contents of the new modal-animations.css + backdrop-animations.css
*/


/* --- modal-animations.css ------ */

.pf-v6-c-modal-animated {
--pf-v6-c-modal-animated--Transition:
opacity 240ms cubic-bezier(0.4, 0.14, 1, 1),
transform 240ms cubic-bezier(0.4, 0.14, 1, 1),
visibility 0ms linear 240ms;
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translate3d(0, -24px, 0);
transform-origin: top center;
transition: var(--pf-v6-c-modal-animated--Transition);
}

.pf-v6-c-modal-animated-open {
--pf-v6-c-modal-animated--Transition:
transform 240ms cubic-bezier(0, 0, 0.2, 1),
visibility 0ms linear 0ms;
opacity: 1;
visibility: visible;
pointer-events: unset;
transform: translate3d(0, 0, 0);
}
Comment on lines +24 to +32

@coderabbitai coderabbitai Bot Jul 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Missing opacity transition in the open state — modal won't fade in.

The .pf-v6-c-modal-animated-open class overrides --pf-v6-c-modal-animated--Transition with only transform and visibility, omitting opacity. Since the base class sets transition: var(--pf-v6-c-modal-animated--Transition), the resolved transition when opening excludes opacity, causing it to snap from 0→1 instantly instead of fading in. This directly contradicts the PR objective of "fade and shift the modal slightly downward when opening."

The closing path works correctly because .pf-v6-c-modal-animated-closed doesn't override the custom property, so the base transition (which includes opacity 240ms) applies.

The same fix should be applied to the production file packages/react-styles/src/css/components/ModalAnimations/modal-animations.css.

✨ Proposed fix: add opacity to the open-state transition
 .pf-v6-c-modal-animated-open {
     --pf-v6-c-modal-animated--Transition:
+        opacity 240ms cubic-bezier(0, 0, 0.2, 1),
         transform 240ms cubic-bezier(0, 0, 0.2, 1),
         visibility 0ms linear 0ms;
     opacity: 1;
     visibility: visible;
     pointer-events: unset;
     transform: translate3d(0, 0, 0);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.pf-v6-c-modal-animated-open {
--pf-v6-c-modal-animated--Transition:
transform 240ms cubic-bezier(0, 0, 0.2, 1),
visibility 0ms linear 0ms;
opacity: 1;
visibility: visible;
pointer-events: unset;
transform: translate3d(0, 0, 0);
}
.pf-v6-c-modal-animated-open {
--pf-v6-c-modal-animated--Transition:
opacity 240ms cubic-bezier(0, 0, 0.2, 1),
transform 240ms cubic-bezier(0, 0, 0.2, 1),
visibility 0ms linear 0ms;
opacity: 1;
visibility: visible;
pointer-events: unset;
transform: translate3d(0, 0, 0);
}
🧰 Tools
🪛 Stylelint (17.14.0)

[error] 28-28: Expected empty line before declaration (declaration-empty-line-before)

(declaration-empty-line-before)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-core/src/components/Modal/examples/Modal.css` around lines 24
- 32, Update the transition custom property in `.pf-v6-c-modal-animated-open`
and its production counterpart so it includes the existing 240ms opacity
transition along with transform and visibility, preserving the modal’s
fade-and-shift opening animation.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring this to discuss with PF team during office-hours

See this comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GAUNSD, understood — thanks for flagging that this will be discussed with the PF team during office-hours. I'll leave this open for now and won't follow up further until there's a decision from that discussion.


.pf-v6-c-modal-animated-closed {
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translate3d(0, -24px, 0);
}

@media screen and (prefers-reduced-motion: reduce) {
.pf-v6-c-modal-animated,
.pf-v6-c-modal-animated-open,
.pf-v6-c-modal-animated-closed {
transition: none;
}
}

/* --- backdrop-animations.css --- */

.pf-v6-c-backdrop-animated {
background-color: transparent;
visibility: hidden;
pointer-events: none;
transition:
background-color 240ms cubic-bezier(0.4, 0.14, 1, 1), /* Carbon equivalent: duration-moderate-02 + motion(exit, expressive) */
visibility 0ms linear 240ms; /* Carbon equivalent: duration-moderate-02 */
}

.pf-v6-c-backdrop-animated-visible {
background-color: var(--pf-v6-c-backdrop--BackgroundColor);
visibility: visible;
pointer-events: unset;
transition:
background-color 240ms cubic-bezier(0, 0, 0.2, 1),
visibility 0ms linear 0ms;
}

.pf-v6-c-backdrop-animated-hidden {
background-color: transparent;
visibility: hidden;
pointer-events: none;
}

@media screen and (prefers-reduced-motion: reduce) {
.pf-v6-c-backdrop-animated,
.pf-v6-c-backdrop-animated-visible,
.pf-v6-c-backdrop-animated-hidden {
transition: none;
}
}
17 changes: 17 additions & 0 deletions packages/react-core/src/components/Modal/examples/Modal.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import RhUiWarningIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-warnin
import RhUiAttentionBellFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-attention-bell-fill-icon';
import RhUiQuestionMarkCircleIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-question-mark-circle-icon';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line will need to be added back. The icon is being used in one of the Modal examples, and we need to include those same import lines in these MD files.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad! Not sure why these imports got modified on my end - could've been a weird Windows build issue. Adding them back now!

import formStyles from '@patternfly/react-styles/css/components/Form/form';
import './Modal.css';

## Examples

Expand Down Expand Up @@ -154,3 +155,19 @@ To customize which element inside the modal receives focus when initially opened
```ts file="./ModalCustomFocus.tsx"

```

### Animated modal (hasAnimations)

To allow modals to animate as they open and close, set the `hasAnimations` property on the modal.

```ts file="./ModalAnimated.tsx"

```

### Animated modal (AnimationsProvider)

To enable animations globally, wrap your application with `AnimationsProvider`. All modals within the provider will animate without needing individual `hasAnimations` props.

```ts file="./ModalAnimatedProvider.tsx"

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Fragment, useState } from 'react';
import { Button, Modal, ModalHeader, ModalBody, ModalFooter, ModalVariant } from '@patternfly/react-core';

export const ModalAnimated: React.FunctionComponent = () => {
const [isModalOpen, setIsModalOpen] = useState(false);

const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => {
setIsModalOpen((prevIsModalOpen) => !prevIsModalOpen);
};

return (
<Fragment>
<Button variant="primary" onClick={handleModalToggle}>
Show animated modal
</Button>
<Modal
hasAnimations
variant={ModalVariant.large}
isOpen={isModalOpen}
onClose={handleModalToggle}
aria-labelledby="modal-animated-label"
aria-describedby="modal-animated-description"
elementToFocus="#modal-animated-confirm-button"
>
<ModalHeader title="Animated Modal Header" labelId="modal-animated-label" />
<ModalBody id="modal-animated-description">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</ModalBody>
<ModalFooter>
<Button id="modal-animated-confirm-button" key="confirm" variant="primary" onClick={handleModalToggle}>
Confirm
</Button>
<Button key="cancel" variant="link" onClick={handleModalToggle}>
Cancel
</Button>
</ModalFooter>
</Modal>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</Fragment>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Fragment, useState } from 'react';
import {
AnimationsProvider,
Button,
Modal,
ModalHeader,
ModalBody,
ModalFooter,
ModalVariant
} from '@patternfly/react-core';

export const ModalAnimatedProvider: React.FunctionComponent = () => {
const [isModalOpen, setIsModalOpen] = useState(false);

const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => {
setIsModalOpen((prevIsModalOpen) => !prevIsModalOpen);
};

return (
<AnimationsProvider config={{ hasAnimations: true }}>
<Fragment>
<Button variant="primary" onClick={handleModalToggle}>
Show animated modal (via AnimationsProvider)
</Button>
<Modal
variant={ModalVariant.large}
isOpen={isModalOpen}
onClose={handleModalToggle}
aria-labelledby="modal-animated-provider-label"
aria-describedby="modal-animated-provider-description"
elementToFocus="#modal-animated-provider-confirm-button"
>
<ModalHeader title="Animated Modal Header" labelId="modal-animated-provider-label" />
<ModalBody id="modal-animated-provider-description">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex
ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat
nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
anim id est laborum.
</ModalBody>
<ModalFooter>
<Button
id="modal-animated-provider-confirm-button"
key="confirm"
variant="primary"
onClick={handleModalToggle}
>
Confirm
</Button>
<Button key="cancel" variant="link" onClick={handleModalToggle}>
Cancel
</Button>
</ModalFooter>
</Modal>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</Fragment>
</AnimationsProvider>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
.pf-v6-c-backdrop-animated {
background-color: transparent;
visibility: hidden;
pointer-events: none;
transition:
background-color 240ms cubic-bezier(0.4, 0.14, 1, 1), /* Carbon equivalent: duration-moderate-02 + motion(exit, expressive) */
visibility 0ms linear 240ms; /* Carbon equivalent: duration-moderate-02 */
}

.pf-v6-c-backdrop-animated-visible {
background-color: var(--pf-v6-c-backdrop--BackgroundColor);
visibility: visible;
pointer-events: unset;
transition:
background-color 240ms cubic-bezier(0, 0, 0.2, 1),
visibility 0ms linear 0ms;
}

.pf-v6-c-backdrop-animated-hidden {
background-color: transparent;
visibility: hidden;
pointer-events: none;
}

@media screen and (prefers-reduced-motion: reduce) {
.pf-v6-c-backdrop-animated,
.pf-v6-c-backdrop-animated-visible,
.pf-v6-c-backdrop-animated-hidden {
transition: none;
}
}
Loading