Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
aa6e765
Add animateLayout API for vanilla JS layout animations
mattgperry Jan 13, 2026
b2fb0cb
Remove debug console.log statements from animation files
mattgperry Jan 13, 2026
bf12bee
Rename mutation prop to updateDom in LayoutAnimationBuilder
mattgperry Jan 13, 2026
74c1d6f
Use projection system snapshots instead of getBoundingClientRect
mattgperry Jan 13, 2026
d794fb8
v12.27.0-alpha.2
mattgperry Jan 14, 2026
8008c4d
Include scope element in layout animation if it has data-layout
mattgperry Jan 14, 2026
5e79c9f
Add test for scope element with data-layout attribute
mattgperry Jan 14, 2026
cd9e63f
Add failing test for repeated layout animations
mattgperry Jan 14, 2026
f6cfddb
Latest
mattgperry Jan 14, 2026
54a17ce
v12.27.0-alpha.3
mattgperry Jan 14, 2026
2652d3f
Latest
mattgperry Jan 14, 2026
dcea7a8
v12.27.0-alpha.4
mattgperry Jan 14, 2026
3310695
Latest
mattgperry Jan 14, 2026
a66fa95
v12.27.0-alpha.5
mattgperry Jan 14, 2026
f5ad37e
Latest
mattgperry Jan 16, 2026
1e3d459
v12.27.0-alpha.6
mattgperry Jan 16, 2026
cfa83eb
Latest
mattgperry Jan 16, 2026
b78fced
Latest
mattgperry Jan 18, 2026
43407f3
Latest
mattgperry Jan 18, 2026
df20720
Latest
mattgperry Jan 18, 2026
6a5c6f7
Latest
mattgperry Jan 18, 2026
0c39787
Simplify LayoutAnimationBuilder
mattgperry Jan 18, 2026
66cd2e6
Latest
mattgperry Jan 18, 2026
4f28202
Merge pull request #3475 from motiondivision/animate-layout
mattgperry Jan 18, 2026
e54c92e
Update changelog
mattgperry Jan 18, 2026
eab3b0e
v12.27.0
mattgperry Jan 18, 2026
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ Motion adheres to [Semantic Versioning](http://semver.org/).

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

## [12.27.0] 2026-01-18

### Fixed

- Adding new exports for internal use.

## [12.26.2] 2026-01-13

### Fixed
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.26.2",
"version": "12.27.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -10,9 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
"framer-motion": "^12.26.2",
"motion": "^12.26.2",
"motion-dom": "^12.26.2",
"framer-motion": "^12.27.0",
"motion": "^12.27.0",
"motion-dom": "^12.27.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
Expand Down
70 changes: 70 additions & 0 deletions dev/html/public/animate-layout/basic-position-change.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<html>
<head>
<style>
body {
padding: 0;
margin: 0;
}

#box {
width: 100px;
height: 100px;
background-color: #00cc88;
}

#box.moved {
position: absolute;
top: 100px;
left: 200px;
}

[data-layout-correct="false"] {
background: #dd1144 !important;
opacity: 0.5;
}
</style>
</head>
<body>
<div id="box" data-layout></div>

<script type="module" src="/src/imports/animate-layout.js"></script>
<script type="module" src="/src/imports/script-assert.js"></script>
<script type="module">
const { animateLayout, frame } = window.AnimateLayout
const { matchViewportBox, showError } = window.Assert
const box = document.getElementById("box")

const boxOrigin = box.getBoundingClientRect()

async function runTest() {
// Target position from .moved class
const targetTop = 100
const targetLeft = 200

const controls = await animateLayout(
() => {
box.classList.add("moved")
},
{ duration: 10, ease: "linear" }
)

// Pause at halfway
controls.pause()
controls.time = controls.duration * 0.5

// Wait one frame for the animation to apply
await new Promise((resolve) => frame.postRender(resolve))

// At halfway, the box should be at the midpoint between origin and target
const expectedHalfway = {
top: 50,
left: 100,
}

matchViewportBox(box, expectedHalfway)
}

runTest().catch(console.error)
</script>
</body>
</html>
115 changes: 115 additions & 0 deletions dev/html/public/animate-layout/interrupt-animation.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<html>
<head>
<style>
body {
padding: 0;
margin: 0;
}

#box {
width: 100px;
height: 100px;
background-color: #00cc88;
}

#box.moved {
position: absolute;
left: 200px;
}

#box.moved-far {
position: absolute;
left: 400px;
}

[data-layout-correct="false"] {
background: #dd1144 !important;
opacity: 0.5;
}
</style>
</head>
<body>
<div id="box" data-layout></div>

<script type="module" src="/src/imports/animate-layout.js"></script>
<script type="module" src="/src/imports/script-assert.js"></script>
<script type="module">
const { animateLayout, frame } = window.AnimateLayout
const { matchViewportBox, showError } = window.Assert
const box = document.getElementById("box")

async function runTest() {
console.log("Starting test...")

// First animation: move from 0 to 200
const controls1 = await animateLayout(
() => {
box.classList.add("moved")
},
{ duration: 0.5, ease: "linear" }
)

console.log("First animation started")

// Wait ~halfway through the animation (250ms)
await new Promise((resolve) => setTimeout(resolve, 250))

// Get the current visual position before interrupting
const midpointBounds = box.getBoundingClientRect()
const visualPositionBeforeInterrupt = midpointBounds.left

console.log(`Position before interrupt: ${visualPositionBeforeInterrupt}`)

// Should be somewhere between 0 and 200 (roughly 100)
if (visualPositionBeforeInterrupt < 20 || visualPositionBeforeInterrupt > 180) {
showError(
box,
`Box should be mid-animation (~100), but is at x=${visualPositionBeforeInterrupt}`
)
return
}

// Now interrupt with a second animation: move to 400
// The animation should start from where the box visually is, not from 200
console.log("Starting second animation...")

const controls2 = await animateLayout(
() => {
box.classList.remove("moved")
box.classList.add("moved-far")
},
{ duration: 0.5, ease: "linear" }
)

console.log("Second animation started")

// Wait one frame for the second animation to start
await new Promise((resolve) => frame.postRender(resolve))

// Get the position right after the second animation starts
const afterInterruptBounds = box.getBoundingClientRect()
const positionAfterInterrupt = afterInterruptBounds.left

console.log(`Position after interrupt: ${positionAfterInterrupt}`)

// The box should still be near where it was when interrupted (~100)
// NOT jumped to 200 (end of first animation) or 0 or 400
const jumpDistance = Math.abs(positionAfterInterrupt - visualPositionBeforeInterrupt)

console.log(`Jump distance: ${jumpDistance}px`)

// Allow some small movement since one frame of the new animation has run
// But it shouldn't have jumped more than ~50px
if (jumpDistance > 50) {
box.dataset.layoutCorrect = "false"
showError(
box,
`Animation jumped from ${visualPositionBeforeInterrupt} to ${positionAfterInterrupt} (${jumpDistance}px). Should start smoothly from interrupted position.`
)
}
}

runTest().catch(console.error)
</script>
</body>
</html>
77 changes: 77 additions & 0 deletions dev/html/public/animate-layout/repeat-animation.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<html>
<head>
<style>
body {
padding: 0;
margin: 0;
}

#box {
width: 100px;
height: 100px;
background-color: #00cc88;
}

#box.moved {
position: absolute;
top: 100px;
left: 200px;
}

[data-layout-correct="false"] {
background: #dd1144 !important;
opacity: 0.5;
}
</style>
</head>
<body>
<div id="box" data-layout></div>

<script type="module" src="/src/imports/animate-layout.js"></script>
<script type="module" src="/src/imports/script-assert.js"></script>
<script type="module">
const { animateLayout, frame } = window.AnimateLayout
const { matchViewportBox, showError } = window.Assert
const box = document.getElementById("box")

async function runTest() {
// First animation: move to position
const controls1 = await animateLayout(
() => {
box.classList.add("moved")
},
{ duration: 0.01, ease: "linear" }
)

// Wait for first animation to complete
await controls1.finished

// Second animation: move back to origin
// This should animate, not happen instantly
const controls2 = await animateLayout(
() => {
box.classList.remove("moved")
},
{ duration: 10, ease: "linear" }
)

// Pause at halfway
controls2.pause()
controls2.time = controls2.duration * 0.5

// Wait one frame for the animation to apply
await new Promise((resolve) => frame.postRender(resolve))

// At halfway, the box should be at the midpoint between moved (100, 200) and origin (0, 0)
const expectedHalfway = {
top: 50,
left: 100,
}

matchViewportBox(box, expectedHalfway)
}

runTest().catch(console.error)
</script>
</body>
</html>
98 changes: 98 additions & 0 deletions dev/html/public/animate-layout/scale-correction.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<html>
<head>
<style>
body {
padding: 0;
margin: 0;
}

#parent {
width: 200px;
height: 200px;
background-color: #ddd;
}

#parent.resized {
width: 400px;
height: 100px;
}

#child {
width: 50px;
height: 50px;
background-color: #00cc88;
}

[data-layout-correct="false"] {
background: #dd1144 !important;
opacity: 0.5;
}
</style>
</head>
<body>
<div id="parent" data-layout>
<div id="child" data-layout></div>
</div>

<script type="module" src="/src/imports/animate-layout.js"></script>
<script type="module" src="/src/imports/script-assert.js"></script>
<script type="module">
const { animateLayout, frame } = window.AnimateLayout
const parent = document.getElementById("parent")
const child = document.getElementById("child")

async function runTest() {
const controls = await animateLayout(
() => {
parent.classList.add("resized")
},
{ duration: 10, ease: "linear" }
)

// Pause at halfway
controls.pause()
controls.time = controls.duration * 0.5

// Wait one frame for the animation to apply
await new Promise((resolve) => frame.postRender(resolve))

// The child should remain square (50x50) throughout the animation
// even though the parent is being scaled from 200x200 to 400x100
const childBounds = child.getBoundingClientRect()
const aspectRatio = childBounds.width / childBounds.height
const threshold = 0.1

// Aspect ratio should be ~1 (square)
if (Math.abs(aspectRatio - 1) > threshold) {
window.showError(
child,
`Child should remain square during parent scale. Got aspect ratio: ${aspectRatio.toFixed(
2
)} (${childBounds.width.toFixed(
1
)}x${childBounds.height.toFixed(1)})`
)
}

// Child should still be approximately 50x50
const expectedSize = 50
const sizeThreshold = 5

if (
Math.abs(childBounds.width - expectedSize) >
sizeThreshold ||
Math.abs(childBounds.height - expectedSize) > sizeThreshold
) {
window.showError(
child,
`Child size should be ~${expectedSize}x${expectedSize}, got ${childBounds.width.toFixed(
1
)}x${childBounds.height.toFixed(1)}`
)
}
}

runTest().catch(console.error)
</script>
</body>
</html>
Loading
Loading