Skip to content

useAlign.js: inline style reset to 0 is silently overridden under prefers-reduced-motion CSS (CSSTransition interpolation) #618

@sunyifei83

Description

@sunyifei83

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:

  1. CSS Transitions Level 1 §3.1 places Animations origin above Author Important origin in the cascade
  2. At t=0 of a just-started CSSTransition, the used value equals the starting keyframe (the pre-change value)
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions