Skip to content

Fix useScroll target not accounting for CSS translate#3655

Open
mattgperry wants to merge 1 commit intomainfrom
worktree-fix-issue-2914
Open

Fix useScroll target not accounting for CSS translate#3655
mattgperry wants to merge 1 commit intomainfrom
worktree-fix-issue-2914

Conversation

@mattgperry
Copy link
Collaborator

Summary

  • useScroll with a target element now correctly accounts for CSS translate and transform translations when calculating scroll progress
  • Previously, calcInset() used only offsetLeft/offsetTop which ignore CSS transforms, causing incorrect scroll progress when the target has a CSS translation
  • Added addTranslateOffset() helper that reads the element's computed translate and transform styles, extracting translation offsets from the matrix

How it works

For each element in the offsetParent chain, we now also read:

  1. The CSS translate property (e.g., translate: 100px 200px)
  2. The CSS transform property's resolved matrix (e.g., matrix(1, 0, 0, 1, 0, 500) from transform: translateY(500px))

The translation components are added to the layout-based offsetTop/offsetLeft values, giving the correct visual position.

Test plan

  • New Cypress E2E test (scroll-target-translate) verifies scroll progress accounts for transform: translateY(500px) on target
  • Existing scroll-target-transform test still passes
  • All 776 unit tests pass
  • Cypress tests pass on both React 18 and React 19

Fixes #2914

🤖 Generated with Claude Code

calcInset() used offsetLeft/offsetTop to calculate the target element's
position relative to the scroll container. These properties reflect layout
position only and ignore CSS transforms (translate, transform: translateY,
etc.), causing useScroll to report incorrect scroll progress when the
target has a CSS translation.

Added addTranslateOffset() which reads the element's computed translate
and transform styles, extracting the translation component from each.
This offset is added alongside offsetLeft/offsetTop for every element
in the offsetParent chain.

Fixes #2914

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@greptile-apps
Copy link

greptile-apps bot commented Mar 17, 2026

Greptile Summary

This PR fixes useScroll incorrectly computing scroll progress when a target element has CSS translate or transform translations applied, by introducing an addTranslateOffset() helper that reads the element's translate and transform computed styles and adds their translation components to the inset on each step of the offsetParent walk.

Changes:

  • inset.ts: Adds addTranslateOffset() called for every HTMLElement in the calcInset loop; handles 2D matrix(...) transforms and the standalone translate CSS property.
  • scroll-target-translate.tsx + scroll-target-translate.ts: New regression test component and Cypress E2E spec covering transform: translateY(500px) on a useScroll target.

Issues found:

  • 3D transforms (translate3d(), translateZ(), perspective()) produce a matrix3d(...) computed transform value; the current regex only matches matrix(...), so the X/Y translation components are silently ignored and scroll progress remains incorrect for those cases — effectively the same bug in 3D form.
  • The standalone CSS translate property can hold percentage values (e.g. translate: 50% 0). getComputedStyle returns the computed (unresolved) percentage string, and parseFloat("50%") yields 50, treating it as 50 px rather than 50% of the element's size.

Confidence Score: 3/5

  • Safe to merge for the 2D translate use-case, but introduces a silent regression path for 3D transforms.
  • The fix correctly solves the reported 2D issue and is well-tested for that case. However, matrix3d transforms are not handled and will silently produce wrong results — the same class of bug as the one being fixed. The percentage-value edge case in the translate property is lower priority but still incorrect. A score of 3 reflects that the fix is a clear improvement but leaves a meaningful gap for 3D transform users.
  • packages/framer-motion/src/render/dom/scroll/offsets/inset.ts — matrix3d branch missing; percentage translate values not resolved to pixels.

Important Files Changed

Filename Overview
packages/framer-motion/src/render/dom/scroll/offsets/inset.ts Adds addTranslateOffset() to account for CSS translate and transform translations in calcInset(). Correctly handles 2D cases, but silently ignores 3D transforms (matrix3d) and may misinterpret percentage-based translate values.
dev/react/src/tests/scroll-target-translate.tsx New regression test component for #2914; sets up a scroll target with transform: translateY(500px) so Cypress can verify correct scroll progress via the opacity indicator. Looks correct.
packages/framer-motion/cypress/integration/scroll-target-translate.ts Cypress E2E test that scrolls to a known position and asserts opacity > 0.5 to confirm translate-aware scroll progress. Test logic is sound but the assertion is loosely bounded — it would pass even with moderately incorrect behaviour as long as the value is above 0.5.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["calcInset(element, container)"] --> B["Walk offsetParent chain"]
    B --> C{"isHTMLElement?"}
    C -- yes --> D["inset.x += offsetLeft\ninset.y += offsetTop"]
    D --> E["addTranslateOffset(inset, current)"]
    E --> F{"CSS translate\n!= 'none'?"}
    F -- yes --> G["Parse 'translate' parts\nAdd x/y (px only, no %resolve)"]
    F -- no --> H{"CSS transform\n!= 'none'?"}
    G --> H
    H -- yes --> I["Regex: match matrix(...)"]
    I --> J{"matrix() matched?"}
    J -- yes --> K["values[4] → inset.x\nvalues[5] → inset.y"]
    J -- no --> L["⚠️ matrix3d(...) NOT handled\nTranslation silently ignored"]
    K --> M["current = offsetParent"]
    L --> M
    H -- no --> M
    M --> B
    C -- SVG --> N["getBoundingClientRect diff"]
    N --> M
    C -- break --> O["Return inset"]
    M --> O
Loading

Last reviewed commit: 70a1b17

Comment on lines +16 to +23
if (transform && transform !== "none") {
const match = transform.match(/matrix\(([^)]+)\)/)
if (match) {
const values = match[1].split(",")
inset.x += parseFloat(values[4])
inset.y += parseFloat(values[5])
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 matrix3d transforms not handled

The regex only matches the 2D matrix(a, b, c, d, tx, ty) form. Whenever an element has a 3D CSS transform — e.g. translate3d(100px, 200px, 0), translateZ(10px), or perspective(...) — the browser resolves getComputedStyle(el).transform to matrix3d(...), not matrix(...). The current regex won't match, so the X/Y translation components are silently dropped and the scroll progress will still be wrong for those cases.

For matrix3d, the tx/ty values are at index 12 and 13 (0-indexed):
matrix3d(1,0,0,0, 0,1,0,0, 0,0,1,0, tx,ty,tz,1)

Suggested fix:

if (transform && transform !== "none") {
    const matrix3dMatch = transform.match(/matrix3d\(([^)]+)\)/)
    if (matrix3dMatch) {
        const values = matrix3dMatch[1].split(",")
        inset.x += parseFloat(values[12])
        inset.y += parseFloat(values[13])
    } else {
        const match = transform.match(/matrix\(([^)]+)\)/)
        if (match) {
            const values = match[1].split(",")
            inset.x += parseFloat(values[4])
            inset.y += parseFloat(values[5])
        }
    }
}

Comment on lines +10 to +14
if (translate && translate !== "none") {
const parts = translate.split(" ")
inset.x += parseFloat(parts[0]) || 0
inset.y += parseFloat(parts[1] || "0") || 0
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Percentage values in translate property not resolved

getComputedStyle(el).translate returns the computed value, which preserves <percentage> values (e.g. "50% 100px" is returned as-is, not resolved to pixels). parseFloat("50%") returns 50, treating it as 50 px regardless of the element's actual size.

This only affects the standalone CSS translate property (not transform: translateX(...), which does resolve to a pixel matrix). If users write translate: 50% 0, the inset will be wrong.

Consider resolving the percentage against the element's bounding box, or documenting the limitation:

function resolveTranslateValue(value: string, elementSize: number): number {
    if (value.endsWith("%")) {
        return (parseFloat(value) / 100) * elementSize
    }
    return parseFloat(value) || 0
}

// then in addTranslateOffset:
if (translate && translate !== "none") {
    const parts = translate.split(" ")
    const { offsetWidth, offsetHeight } = element
    inset.x += resolveTranslateValue(parts[0], offsetWidth)
    inset.y += resolveTranslateValue(parts[1] ?? "0", offsetHeight)
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] useScroll target does not account for CSS translate

1 participant