Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ test-e2e: test-nextjs test-html test-react test-react-19
yarn test-playwright

test-single: build test-mkdir
yarn start-server-and-test "yarn dev-server" http://localhost:9991 "cd packages/framer-motion && cypress run --config-file=cypress.react-19.json --headed --spec cypress/integration/layout-group.ts"
yarn start-server-and-test "yarn dev-server" http://localhost:9991 "cd packages/framer-motion && cypress run --config-file=cypress.react-19.json --headed --spec cypress/integration/unit-conversion.ts"

lint: bootstrap
yarn lint
Expand Down
24 changes: 16 additions & 8 deletions dev/react/src/tests/unit-conversion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { motion, useCycle, useMotionValue } from "framer-motion"
export const App = () => {
const params = new URLSearchParams(window.location.search)
const isExternalMotionValue = params.get("use-motion-value") || false
// When roundtrip param is present, use a fast animation that completes (for testing calc -> 0 -> calc)
const isRoundTrip = params.get("roundtrip") !== null
const [x, cycleX] = useCycle<number | string>(0, "calc(3 * var(--width))")
const xMotionValue = useMotionValue(x)
const value = isExternalMotionValue ? xMotionValue : undefined
Expand All @@ -15,14 +17,20 @@ export const App = () => {
<motion.div
initial={false}
animate={{ x }}
transition={{ duration: 5, ease: () => 0.5 }}
style={{
x: value,
width: 100,
height: 100,
background: "#ffaa00",
"--width": "100px",
}}
transition={
isRoundTrip
? { duration: 0.1 }
: { duration: 5, ease: () => 0.5 }
}
style={
{
x: value,
width: 100,
height: 100,
background: "#ffaa00",
"--width": "100px",
} as React.CSSProperties
}
onClick={() => cycleX()}
id="box"
/>
Expand Down
44 changes: 44 additions & 0 deletions packages/framer-motion/cypress/integration/unit-conversion.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,48 @@
describe("Unit conversion", () => {
/**
* Test for GitHub issue #3410
* When animating from a calc() with CSS variables to a simple value (and back),
* the animation should complete correctly without getting stuck at intermediate values.
*/
it("Animate x roundtrip: 0 -> calc -> 0", () => {
// Helper to extract translateX value from computed transform matrix
const getTranslateX = (element: HTMLElement): number => {
const style = window.getComputedStyle(element)
const matrix = new DOMMatrix(style.transform)
return matrix.m41 // translateX is in m41
}

cy.visit("?test=unit-conversion&roundtrip=true")
.wait(200)
.get("#box")
.should(([$box]: any) => {
// Initial position should be 0
expect(getTranslateX($box)).to.equal(0)
// Initial style should be none or translateX(0px)
expect($box.style.transform).to.match(/^(none|translateX\(0px\))$/)
})
// First click: 0 -> calc(3 * var(--width)) = 300px
.trigger("click")
.wait(300)
.should(([$box]: any) => {
// Computed position should be 300px
expect(getTranslateX($box)).to.equal(300)
// Style should preserve the calc expression
expect($box.style.transform).to.equal(
"translateX(calc(3 * var(--width)))"
)
})
// Second click: calc(300px) -> 0
.trigger("click")
.wait(300)
.should(([$box]: any) => {
// Computed position should be back to 0
expect(getTranslateX($box)).to.equal(0)
// Style should be none (transform cleared when returning to default)
expect($box.style.transform).to.equal("none")
})
})

it("Animate x from 0 to calc", () => {
cy.visit("?test=unit-conversion")
.wait(100)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { MotionValue } from "../../value"
import { findDimensionValueType } from "../../value/types/dimensions"
import { AnyResolvedKeyframe } from "../types"
import { getVariableValue } from "../utils/css-variables-conversion"
import { isCSSVariableToken } from "../utils/is-css-variable"
import {
containsCSSVariable,
isCSSVariableToken,
} from "../utils/is-css-variable"
import {
KeyframeResolver,
OnKeyframesResolved,
Expand Down Expand Up @@ -84,6 +87,18 @@ export class DOMKeyframesResolver<
const originType = findDimensionValueType(origin)
const targetType = findDimensionValueType(target)

/**
* If one keyframe contains embedded CSS variables (e.g. in calc()) and the other
* doesn't, we need to measure to convert to pixels. This handles GitHub issue #3410.
*/
const originHasVar = containsCSSVariable(origin)
const targetHasVar = containsCSSVariable(target)

if (originHasVar !== targetHasVar && positionalValues[name]) {
this.needsMeasurement = true
return
}

/**
* Either we don't recognise these value types or we can animate between them.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isCSSVariableToken } from "../is-css-variable"
import { containsCSSVariable, isCSSVariableToken } from "../is-css-variable"

describe("isCSSVariableToken", () => {
test("returns true for a CSS variable", () => {
Expand All @@ -12,3 +12,33 @@ describe("isCSSVariableToken", () => {
).toBe(true)
})
})

describe("containsCSSVariable", () => {
test("returns false for non-strings", () => {
expect(containsCSSVariable(0)).toBe(false)
expect(containsCSSVariable(100)).toBe(false)
expect(containsCSSVariable(null)).toBe(false)
expect(containsCSSVariable(undefined)).toBe(false)
})

test("returns false for strings without CSS variables", () => {
expect(containsCSSVariable("100px")).toBe(false)
expect(containsCSSVariable("calc(100px + 50px)")).toBe(false)
expect(containsCSSVariable("0")).toBe(false)
})

test("returns true for standalone CSS variable tokens", () => {
expect(containsCSSVariable("var(--foo)")).toBe(true)
expect(containsCSSVariable("var(--offset)")).toBe(true)
})

test("returns true for calc expressions containing CSS variables", () => {
expect(containsCSSVariable("calc(100px + var(--offset))")).toBe(true)
expect(containsCSSVariable("calc(-100% - var(--myVar))")).toBe(true)
expect(containsCSSVariable("calc(var(--a) + var(--b))")).toBe(true)
})

test("ignores CSS variables in comments", () => {
expect(containsCSSVariable("100px /* var(--foo) */")).toBe(false)
})
})
13 changes: 13 additions & 0 deletions packages/motion-dom/src/animation/utils/is-css-variable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,16 @@ export const isCSSVariableToken = (

const singleCssVariableRegex =
/var\(--(?:[\w-]+\s*|[\w-]+\s*,(?:\s*[^)(\s]|\s*\((?:[^)(]|\([^)(]*\))*\))+\s*)\)$/iu

/**
* Check if a value contains a CSS variable anywhere (e.g. inside calc()).
* Unlike isCSSVariableToken which checks if the value IS a var() token,
* this checks if the value CONTAINS var() somewhere in the string.
*/
export function containsCSSVariable(
value?: AnyResolvedKeyframe | null
): boolean {
if (typeof value !== "string") return false
// Strip comments to avoid false positives
return value.split("/*")[0].includes("var(--")
}