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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions