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
17 changes: 16 additions & 1 deletion packages/react-core/src/components/Modal/examples/Modal.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ ouia: true
import { Fragment, useRef, useState } from 'react';
import RhUiWarningIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-warning-icon';
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';
import formStyles from '@patternfly/react-styles/css/components/Form/form';

## Examples
Expand Down Expand Up @@ -154,3 +153,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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
.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 +14 to +22

@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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Opening animation does not fade in — opacity missing from transition in .pf-v6-c-modal-animated-open.

The open class overrides --pf-v6-c-modal-animated--Transition with only transform and visibility, omitting opacity. When the browser applies the open class, it uses the new transition value for all property changes. Since opacity is not listed, it jumps instantly from 0 to 1 — no fade-in effect. The closing animation works correctly because the base class transition (which includes opacity) applies when the closed class is used. This asymmetry contradicts the PR objective: "Fade and shift the modal slightly downward when opening."

🐛 Proposed fix: add opacity to the open 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] 18-18: 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-styles/src/css/components/ModalAnimations/modal-animations.css`
around lines 14 - 22, Update the transition declaration in
.pf-v6-c-modal-animated-open to include opacity alongside transform and
visibility, preserving the existing timing and easing so opening the modal fades
it in as well as moving it.

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

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 — deferring this to the PF team office-hours discussion makes sense given it touches the broader animation design intent. Happy to revisit once you have their input; feel free to ping if the direction changes.


.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;
}
}
6 changes: 3 additions & 3 deletions packages/react-tokens/scripts/generateTokens.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ const getDeclarations = (cssAst) =>
.reduce((acc, val) => acc.concat(val), []); // flatten

const formatFilePathToName = (filePath) => {
// const filePathArr = filePath.split('/');
const normalizedPath = filePath.replace(/\\/g, '/');
let prefix = '';
if (filePath.includes('components/')) {
if (normalizedPath.includes('components/')) {
prefix = 'c_';
} else if (filePath.includes('layouts/')) {
} else if (normalizedPath.includes('layouts/')) {
prefix = 'l_';
}
return `${prefix}${basename(filePath, '.css').replace(/-+/g, '_')}`;
Expand Down
1 change: 1 addition & 0 deletions scripts/build-single-packages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const components = {
};

async function createPackage(component) {
component = component.replaceAll('\\', '/');
const cmds = [];
let destFile = component.replace(/[^/]+\.js$/g, 'package.json').replace('/dist/esm/', '/dist/dynamic/');

Expand Down