useAlign.js: inline style.left='0' reset is ignored under prefers-reduced-motiontransition-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
// MREimport{Select}from'antd';// index.css// @media (prefers-reduced-motion: reduce) {// *, *::before, *::after { transition-duration: 0.01ms !important; }// }<Selectoptions={[{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:
constoffsetStyle={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';// ...varpopupRect=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 originaboveAuthor 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');voidpopupElement.offsetHeight;// force reflowgetComputedStyle(popupElement).left;// still '-10800px', not '1px'popupElement.style.getPropertyValue('left');// '1px' — inline write succeededpopupElement.style.getPropertyPriority('left');// 'important' — priority appliedpopupElement.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.