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 .claude/commands/fix.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@ git worktree add ../${REPO_NAME}-$ARGUMENTS -b $ARGUMENTS

Then tell me to close Claude and reopen with:
```bash
cd ../<repo>-$ARGUMENTS && claude
cd ../<repo>-$ARGUMENTS && claude --dangerously-skip-permissions
```
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ Motion adheres to [Semantic Versioning](http://semver.org/).

Undocumented APIs should be considered internal and may change without warning.

## [12.28.2] 2026-01-21

### Added

- Add default value type `px` for `fontSize`.

### Fixed

- Removed default value type from `radius`.
- Ensure `LazyMotion` animates initial state even when state has changed before Motion is loaded.

## [12.28.1] 2026-01-21

### Fixed

- Ensure `scale: "0%"` isn't treated as default value.

## [12.28.0] 2026-01-20

### Added
Expand Down
8 changes: 4 additions & 4 deletions dev/html/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "html-env",
"private": true,
"version": "12.28.0",
"version": "12.28.2",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -10,9 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
"framer-motion": "^12.28.0",
"motion": "^12.28.0",
"motion-dom": "^12.28.0",
"framer-motion": "^12.28.2",
"motion": "^12.28.2",
"motion-dom": "^12.28.2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
Expand Down
4 changes: 2 additions & 2 deletions dev/next/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "next-env",
"private": true,
"version": "12.28.0",
"version": "12.28.2",
"type": "module",
"scripts": {
"dev": "next dev",
Expand All @@ -10,7 +10,7 @@
"build": "next build"
},
"dependencies": {
"motion": "^12.28.0",
"motion": "^12.28.2",
"next": "15.4.10",
"react": "19.0.0",
"react-dom": "19.0.0"
Expand Down
4 changes: 2 additions & 2 deletions dev/react-19/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "react-19-env",
"private": true,
"version": "12.28.0",
"version": "12.28.2",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -11,7 +11,7 @@
"preview": "vite preview"
},
"dependencies": {
"motion": "^12.28.0",
"motion": "^12.28.2",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
Expand Down
4 changes: 2 additions & 2 deletions dev/react/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "react-env",
"private": true,
"version": "12.28.0",
"version": "12.28.2",
"type": "module",
"scripts": {
"dev": "yarn vite",
Expand All @@ -11,7 +11,7 @@
"preview": "yarn vite preview"
},
"dependencies": {
"framer-motion": "^12.28.0",
"framer-motion": "^12.28.2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
Expand Down
58 changes: 58 additions & 0 deletions dev/react/src/tests/lazy-motion-fast-state.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { m, LazyMotion, domAnimation } from "framer-motion"
import { useState, useEffect, useRef } from "react"

/**
* Test for GitHub issue #2759
* When LazyMotion features load asynchronously and state changes occur before
* features load, the enter animation should still fire (not snap to final value).
*/

const variants = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
}

export const App = () => {
const [isVisible, setIsVisible] = useState(false)
const boxRef = useRef<HTMLDivElement>(null)

// Simulate state change that occurs before features load
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(true)
}, 5) // State changes after 5ms
return () => clearTimeout(timer)
}, [])

return (
<LazyMotion
features={() =>
new Promise((resolve) => {
// Features load after 50ms (longer than the 5ms state change)
setTimeout(() => {
resolve(domAnimation)
}, 50)
})
}
>
<m.div
id="box"
ref={boxRef}
initial={false}
animate={isVisible ? "visible" : "hidden"}
variants={variants}
transition={{ duration: 0.1 }}
onAnimationComplete={() => {
if (boxRef.current) {
boxRef.current.dataset.animationComplete = "true"
}
}}
style={{
width: 100,
height: 100,
background: "red",
}}
/>
</LazyMotion>
)
}
2 changes: 1 addition & 1 deletion lerna.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "12.28.0",
"version": "12.28.2",
"packages": [
"packages/*",
"dev/*"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
describe("LazyMotion with fast state changes", () => {
it("animates when state changes before features load", () => {
cy.visit("?test=lazy-motion-fast-state")
.wait(300) // Wait for features to load and animation to complete
.get("#box")
.should(([$element]: any) => {
// Verify the animation completed
expect($element.dataset.animationComplete).to.equal("true")
// Verify the element is visible (opacity: 1)
expect(getComputedStyle($element).opacity).to.equal("1")
})
})
})
4 changes: 2 additions & 2 deletions packages/framer-motion/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "framer-motion",
"version": "12.28.0",
"version": "12.28.2",
"description": "A simple and powerful JavaScript animation library",
"main": "dist/cjs/index.js",
"module": "dist/es/index.mjs",
Expand Down Expand Up @@ -88,7 +88,7 @@
"measure": "rollup -c ./rollup.size.config.mjs"
},
"dependencies": {
"motion-dom": "^12.28.0",
"motion-dom": "^12.28.2",
"motion-utils": "^12.27.2",
"tslib": "^2.4.0"
},
Expand Down
22 changes: 22 additions & 0 deletions packages/framer-motion/src/motion/utils/use-visual-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ export function useVisualElement<
HTMLElement | SVGElement
> | null>(null)

/**
* Track whether the component has been through React's commit phase.
* Used to detect when LazyMotion features load after the component has mounted.
*/
const hasMountedOnce = useRef(false)

/**
* If we haven't preloaded a renderer, check to see if we have one lazy-loaded
*/
Expand All @@ -65,6 +71,16 @@ export function useVisualElement<
reducedMotionConfig,
isSVG,
})

/**
* If the component has already mounted before features loaded (e.g. via
* LazyMotion with async feature loading), we need to force the initial
* animation to run. Otherwise state changes that occurred before features
* loaded will be lost and the element will snap to its final state.
*/
if (hasMountedOnce.current && visualElementRef.current) {
visualElementRef.current.manuallyAnimateOnMount = true
}
}

const visualElement = visualElementRef.current
Expand Down Expand Up @@ -113,6 +129,12 @@ export function useVisualElement<
)

useIsomorphicLayoutEffect(() => {
/**
* Track that this component has mounted. This is used to detect when
* LazyMotion features load after the component has already committed.
*/
hasMountedOnce.current = true

if (!visualElement) return

isMounted.current = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ describe("buildHTMLStyles", () => {
expect(style).toEqual({ width: "100px" })
})

test("Builds fontSize with px unit", () => {
const latest = { fontSize: 16 }
const style = {}
build(latest, { style })

expect(style).toEqual({ fontSize: "16px" })
})

test("Builds vars", () => {
const latest = { "--width": 100 }
const vars = {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,11 @@ describe("buildTransform", () => {
"translateX(1px) translateY(10px) scale(2) rotate(90deg) rotateZ(190deg)"
)
})

it("Correctly handles string scale values of zero", () => {
// scale: "0" should produce scale(0), not be treated as default
expect(buildTransform({ scale: "0" }, {})).toBe("scale(0)")
expect(buildTransform({ scaleX: "0" }, {})).toBe("scaleX(0)")
expect(buildTransform({ scaleY: "0" }, {})).toBe("scaleY(0)")
})
})
2 changes: 1 addition & 1 deletion packages/motion-dom/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "motion-dom",
"version": "12.28.0",
"version": "12.28.2",
"author": "Matt Perry",
"license": "MIT",
"repository": "https://github.com/motiondivision/motion",
Expand Down
3 changes: 2 additions & 1 deletion packages/motion-dom/src/animation/waapi/utils/px-values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ export const pxValues = new Set([
"borderBottomWidth",
"borderLeftWidth",
"borderRadius",
"radius",
"borderTopLeftRadius",
"borderTopRightRadius",
"borderBottomRightRadius",
Expand Down Expand Up @@ -50,6 +49,8 @@ export const pxValues = new Set([
"marginInline",
"marginInlineStart",
"marginInlineEnd",
// Typography
"fontSize",
// Misc
"backgroundPositionX",
"backgroundPositionY",
Expand Down
31 changes: 31 additions & 0 deletions packages/motion-dom/src/effects/__tests__/style-effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,4 +477,35 @@ describe("styleEffect", () => {
// Verify mixed values are handled correctly
expect(element.style.transformOrigin).toBe("50% 100% 0px")
})

it("correctly handles string scale values of zero", async () => {
const element = document.createElement("div")

// Create motion values with string scale of zero
const scale = motionValue("0")
const scaleX = motionValue("0")
const scaleY = motionValue("0")

// Apply style effect
styleEffect(element, {
scale,
scaleX,
scaleY,
})

await nextFrame()

// scale: "0" should produce scale(0), not be treated as default
expect(element.style.transform).toBe("scale(0) scaleX(0) scaleY(0)")

// Change to non-zero values
scale.set("1")
scaleX.set("1")
scaleY.set("1")

await nextFrame()

// All values are now default (1), so transform should be "none"
expect(element.style.transform).toBe("none")
})
})
21 changes: 21 additions & 0 deletions packages/motion-dom/src/effects/__tests__/svg-effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,27 @@ async function nextFrame() {
}

describe("svgEffect", () => {
it("sets feMorphology radius as unitless number (issue #2779)", async () => {
const element = document.createElementNS(
"http://www.w3.org/2000/svg",
"feMorphology"
)

// Create motion value for radius
const radius = motionValue(4)

// Apply svg effect
svgEffect(element, {
radius,
})

await nextFrame()

// Verify radius is set as unitless number, not with "px" suffix
// This was the bug - radius was being set as "4px" instead of "4"
expect(element.getAttribute("radius")).toBe("4")
})

it("sets SVG attributes and styles after svgEffect is applied", async () => {
const element = document.createElementNS(
"http://www.w3.org/2000/svg",
Expand Down
3 changes: 2 additions & 1 deletion packages/motion-dom/src/effects/style/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ export function buildTransform(state: MotionValueState) {
if (typeof value === "number") {
valueIsDefault = value === (key.startsWith("scale") ? 1 : 0)
} else {
valueIsDefault = parseFloat(value) === 0
const parsed = parseFloat(value)
valueIsDefault = key.startsWith("scale") ? parsed === 1 : parsed === 0
}

if (!valueIsDefault) {
Expand Down
3 changes: 2 additions & 1 deletion packages/motion-dom/src/render/html/utils/build-transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ export function buildTransform(
if (typeof value === "number") {
valueIsDefault = value === (key.startsWith("scale") ? 1 : 0)
} else {
valueIsDefault = parseFloat(value) === 0
const parsed = parseFloat(value)
valueIsDefault = key.startsWith("scale") ? parsed === 1 : parsed === 0
}

if (!valueIsDefault || transformTemplate) {
Expand Down
Loading
Loading