useAlign.js: inline style.left='0' reset is ignored under prefers-reduced-motion transition-duration: 0.01ms rule
Target upstream: react-component/trigger / @rc-component/trigger@2.3.1
Summary
useAlign.js resets popupElement.style.left = '0' and popupElement.style.top = '0' before reading the popup's getBoundingClientRect() (to measure the popup at the origin of its offsetParent). This pattern silently breaks when the host page has the widely-recommended accessibility rule:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
Under prefers-reduced-motion: reduce, Chrome creates a CSSTransition on left (and top) the moment style.left changes from the initial -1000vw to 0. useAlign then synchronously reads getBoundingClientRect() within the same event-loop turn — but at t=0 of a CSSTransition, the used value equals the starting keyframe (-1000vw), not the newly-assigned 0. The popup's reported x/y stay at (-10800, -17800) instead of the expected (0, 0).
nextOffsetX = triggerRect.left - popupRect.x then becomes 548 - (-10800) = 11348px, which is written back to popupElement.style.left. The popup renders thousands of pixels off-screen. From the user's perspective, clicking the Select "does nothing".
Environment
|
|
@rc-component/trigger |
2.3.1 |
antd |
5.29.3 (also reproduces on 5.27.6) |
react |
19.2.0 |
| Browser |
Chromium, devicePixelRatio=1 |
| Viewport |
1080 × 1780 |
| OS reduced-motion |
enabled (or Chromium headless / DevTools / VS Code Webview — all default to reduced-motion) |
Reproduction
// MRE
import { Select } from 'antd';
// index.css
// @media (prefers-reduced-motion: reduce) {
// *, *::before, *::after { transition-duration: 0.01ms !important; }
// }
<Select options={[{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }]} />
Open in Chromium with "Emulate CSS media feature prefers-reduced-motion: reduce" in DevTools Rendering panel, click the Select. Popup renders at style.left: 11348px (or similar large value) instead of below the trigger.
Root-cause walkthrough
@rc-component/trigger/es/Popup/index.js:76-77 initializes popup inline style with:
const offsetStyle = { left: '-1000vw', top: '-1000vh', right: AUTO, bottom: AUTO };
useAlign.js:147-148 resets the popup to origin before reading rect:
popupElement.style.left = '0';
popupElement.style.top = '0';
// ...
var popupRect = popupElement.getBoundingClientRect(); // line 189
The semantic of lines 147-148 is: "pretend the popup sits at (0,0) of its containing block and read where that would land in the viewport". This semantic is violated when any CSS rule applies a non-zero transition-duration to left/top on the popup, because:
- CSS Transitions Level 1 §3.1 places Animations origin above Author Important origin in the cascade
- At
t=0 of a just-started CSSTransition, the used value equals the starting keyframe (the pre-change value)
getBoundingClientRect() reflects the used value — so it returns the popup's previous position, not the just-written 0
This is demonstrable by probing with inline !important:
popupElement.style.setProperty('left', '1px', 'important');
void popupElement.offsetHeight; // force reflow
getComputedStyle(popupElement).left; // still '-10800px', not '1px'
popupElement.style.getPropertyValue('left'); // '1px' — inline write succeeded
popupElement.style.getPropertyPriority('left'); // 'important' — priority applied
popupElement.getAnimations() returns 2 CSSTransition objects (one on left, one on top), both in playState: 'running' at currentTime: 0, duration: 0.00001s.
Observed DIAG data (instrumented useAlign.js)
popupClass: ant-select-dropdown ant-slide-up-appear ant-slide-up-appear-prepare ant-slide-up css-h52jd
cssTransitionProperty=all duration=1e-05s animationName=none
animations[2]:
CSSTransition/left/running/t=0/d=0.01/kf=offset,easing,composite,left,computedOffset|offset,easing,composite,left,computedOffset
CSSTransition/top /running/t=0/d=0.01/kf=offset,easing,composite,top ,computedOffset|offset,easing,composite,top ,computedOffset
beforeReset: styleL=-1000vw styleT=-1000vh rectX=-10800 rectY=-19135 csL=-10800px csT=-17800px
afterReset: styleL=0px styleT=0px rectX=-10800 rectY=-19135 csL=-10800px csT=-17800px
↑ rect stays at pre-reset position, reset is ineffective
!imp probe: setProperty('left','1px','important') → csL=-10800px ← inline !important is still overridden
!imp readback: styleL=1px[important] styleT=2px[important] ← confirms the write took effect
matchedRules enumeration (stylesheets walk, popupElement.matches()):
[1] :where(.css-h52jdb).ant-select-dropdown { top: -9999px } ← only antd rule, NOT !important, NOT left
Why existing workarounds mislead
Users hitting this will typically try:
- Setting
getPopupContainer to a different parent → doesn't help, popup still goes through useAlign
- Forcing CSS
position: absolute !important; left: 0 !important; top: 100% !important on .ant-select-dropdown → loses the portal model, gets clipped by modal overflow, disables auto-flip
The problem must be fixed inside useAlign.js because the issue is about synchronous DOM read-after-write during a CSSTransition, which any CSS author-layer workaround cannot override.
Proposed fix — useAlign.js
Disable transitions on the popup element for the duration of the measurement:
// ========================= Align =========================
var onAlign = useEvent(function () {
if (popupEle && target && open) {
var popupElement = popupEle;
// ... existing code ...
+ // Save transition so we can restore it after measuring.
+ var originTransition = popupElement.style.transition;
+ // Temporarily disable all transitions so the subsequent style writes
+ // take effect immediately (CSSTransitions at t=0 otherwise report the
+ // previous used value via getBoundingClientRect / getComputedStyle,
+ // which breaks the alignment math under host-page CSS like
+ // `@media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important } }`.
+ popupElement.style.transition = 'none';
// Reset first
popupElement.style.left = '0';
popupElement.style.top = '0';
popupElement.style.right = 'auto';
popupElement.style.bottom = 'auto';
popupElement.style.overflow = 'hidden';
// ... existing rect read ...
// Reset back
popupElement.style.left = originLeft;
popupElement.style.top = originTop;
popupElement.style.right = originRight;
popupElement.style.bottom = originBottom;
popupElement.style.overflow = originOverflow;
+ popupElement.style.transition = originTransition;
// ... existing code ...
}
});
This is minimally invasive, does not change alignment math, and works regardless of what host-page CSS rules apply.
Alternative — derive popupRect from offsetParent
A more robust (but larger) fix: since the reset's semantic is "popup at (0,0) of offsetParent", compute popupRect directly from popupElement.offsetParent.getBoundingClientRect(), bypassing the need to ever write to style.left/top for measurement. The downstream patch in our project took this approach successfully (verified working), but it's a larger change that maintainers may want to review more carefully.
Downstream patch (for reference)
Our project is running this patch in production on @rc-component/trigger@2.3.1 via patch-package:
--- a/node_modules/@rc-component/trigger/es/hooks/useAlign.js
+++ b/node_modules/@rc-component/trigger/es/hooks/useAlign.js
@@ -189,6 +189,16 @@
var popupRect = popupElement.getBoundingClientRect();
// ... existing code ...
popupRect.y = (_popupRect$y = popupRect.y) !== null && _popupRect$y !== void 0 ? _popupRect$y : popupRect.top;
+ // PATCH: when the inline `left:0; top:0` reset above is effectively
+ // ignored by an active CSSTransition (Animations origin > author !important,
+ // see CSS Transitions Level 1 §3.1), popupRect still reflects the popup's
+ // previous position. Compute popupRect from offsetParent directly — this
+ // is mathematically equivalent to "popup at (0,0) of offsetParent".
+ if (popupElement.offsetParent) {
+ var _parentRect = popupElement.offsetParent.getBoundingClientRect();
+ popupRect.x = _parentRect.left;
+ popupRect.y = _parentRect.top;
+ popupRect.width = popupElement.offsetWidth;
+ popupRect.height = popupElement.offsetHeight;
+ }
Filed from ClusterDelivery frontend, full root-cause documented with runtime DIAG data.
useAlign.js: inline
style.left='0'reset is ignored underprefers-reduced-motiontransition-duration: 0.01msruleTarget upstream: react-component/trigger /
@rc-component/trigger@2.3.1Summary
useAlign.jsresetspopupElement.style.left = '0'andpopupElement.style.top = '0'before reading the popup'sgetBoundingClientRect()(to measure the popup at the origin of its offsetParent). This pattern silently breaks when the host page has the widely-recommended accessibility rule:Under
prefers-reduced-motion: reduce, Chrome creates aCSSTransitiononleft(andtop) the momentstyle.leftchanges from the initial-1000vwto0.useAlignthen synchronously readsgetBoundingClientRect()within the same event-loop turn — but att=0of a CSSTransition, the used value equals the starting keyframe (-1000vw), not the newly-assigned0. The popup's reported x/y stay at(-10800, -17800)instead of the expected(0, 0).nextOffsetX = triggerRect.left - popupRect.xthen becomes548 - (-10800) = 11348px, which is written back topopupElement.style.left. The popup renders thousands of pixels off-screen. From the user's perspective, clicking the Select "does nothing".Environment
@rc-component/trigger2.3.1antd5.29.3(also reproduces on5.27.6)react19.2.0devicePixelRatio=11080 × 1780Reproduction
Open in Chromium with "Emulate CSS media feature prefers-reduced-motion: reduce" in DevTools Rendering panel, click the Select. Popup renders at
style.left: 11348px(or similar large value) instead of below the trigger.Root-cause walkthrough
@rc-component/trigger/es/Popup/index.js:76-77initializes popup inline style with:useAlign.js:147-148resets the popup to origin before reading rect:The semantic of lines 147-148 is: "pretend the popup sits at (0,0) of its containing block and read where that would land in the viewport". This semantic is violated when any CSS rule applies a non-zero
transition-durationtoleft/topon the popup, because:t=0of a just-started CSSTransition, the used value equals the starting keyframe (the pre-change value)getBoundingClientRect()reflects the used value — so it returns the popup's previous position, not the just-written0This is demonstrable by probing with inline
!important:popupElement.getAnimations()returns 2CSSTransitionobjects (one onleft, one ontop), both inplayState: 'running'atcurrentTime: 0,duration: 0.00001s.Observed DIAG data (instrumented
useAlign.js)Why existing workarounds mislead
Users hitting this will typically try:
getPopupContainerto a different parent → doesn't help, popup still goes through useAlignposition: absolute !important; left: 0 !important; top: 100% !importanton.ant-select-dropdown→ loses the portal model, gets clipped by modal overflow, disables auto-flipThe problem must be fixed inside
useAlign.jsbecause the issue is about synchronous DOM read-after-write during a CSSTransition, which any CSS author-layer workaround cannot override.Proposed fix —
useAlign.jsDisable transitions on the popup element for the duration of the measurement:
// ========================= Align ========================= var onAlign = useEvent(function () { if (popupEle && target && open) { var popupElement = popupEle; // ... existing code ... + // Save transition so we can restore it after measuring. + var originTransition = popupElement.style.transition; + // Temporarily disable all transitions so the subsequent style writes + // take effect immediately (CSSTransitions at t=0 otherwise report the + // previous used value via getBoundingClientRect / getComputedStyle, + // which breaks the alignment math under host-page CSS like + // `@media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important } }`. + popupElement.style.transition = 'none'; // Reset first popupElement.style.left = '0'; popupElement.style.top = '0'; popupElement.style.right = 'auto'; popupElement.style.bottom = 'auto'; popupElement.style.overflow = 'hidden'; // ... existing rect read ... // Reset back popupElement.style.left = originLeft; popupElement.style.top = originTop; popupElement.style.right = originRight; popupElement.style.bottom = originBottom; popupElement.style.overflow = originOverflow; + popupElement.style.transition = originTransition; // ... existing code ... } });This is minimally invasive, does not change alignment math, and works regardless of what host-page CSS rules apply.
Alternative — derive popupRect from offsetParent
A more robust (but larger) fix: since the reset's semantic is "popup at (0,0) of offsetParent", compute
popupRectdirectly frompopupElement.offsetParent.getBoundingClientRect(), bypassing the need to ever write tostyle.left/topfor measurement. The downstream patch in our project took this approach successfully (verified working), but it's a larger change that maintainers may want to review more carefully.Downstream patch (for reference)
Our project is running this patch in production on
@rc-component/trigger@2.3.1viapatch-package:Filed from ClusterDelivery frontend, full root-cause documented with runtime DIAG data.