diff --git a/CHANGELOG.md b/CHANGELOG.md
index 269f11a8c0..b03018ba15 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,12 @@ Motion adheres to [Semantic Versioning](http://semver.org/).
Undocumented APIs should be considered internal and may change without warning.
+## [12.29.3] 2026-02-02
+
+### Fixed
+
+- `Reorder`: Fixed viewport autoscroll.
+
## [12.29.2] 2026-01-26
### Fixed
diff --git a/dev/html/package.json b/dev/html/package.json
index 0373cedb5a..4ce792b5d4 100644
--- a/dev/html/package.json
+++ b/dev/html/package.json
@@ -1,7 +1,7 @@
{
"name": "html-env",
"private": true,
- "version": "12.29.2",
+ "version": "12.29.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -10,8 +10,8 @@
"preview": "vite preview"
},
"dependencies": {
- "framer-motion": "^12.29.2",
- "motion": "^12.29.2",
+ "framer-motion": "^12.29.3",
+ "motion": "^12.29.3",
"motion-dom": "^12.29.2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
diff --git a/dev/next/package.json b/dev/next/package.json
index 5325cb2d96..d54351379c 100644
--- a/dev/next/package.json
+++ b/dev/next/package.json
@@ -1,7 +1,7 @@
{
"name": "next-env",
"private": true,
- "version": "12.29.2",
+ "version": "12.29.3",
"type": "module",
"scripts": {
"dev": "next dev",
@@ -10,7 +10,7 @@
"build": "next build"
},
"dependencies": {
- "motion": "^12.29.2",
+ "motion": "^12.29.3",
"next": "15.4.10",
"react": "19.0.0",
"react-dom": "19.0.0"
diff --git a/dev/react-19/package.json b/dev/react-19/package.json
index 00b238eaed..d45f0e04f9 100644
--- a/dev/react-19/package.json
+++ b/dev/react-19/package.json
@@ -1,7 +1,7 @@
{
"name": "react-19-env",
"private": true,
- "version": "12.29.2",
+ "version": "12.29.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -11,7 +11,7 @@
"preview": "vite preview"
},
"dependencies": {
- "motion": "^12.29.2",
+ "motion": "^12.29.3",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
diff --git a/dev/react/package.json b/dev/react/package.json
index 56ce8f783e..91461b767f 100644
--- a/dev/react/package.json
+++ b/dev/react/package.json
@@ -1,7 +1,7 @@
{
"name": "react-env",
"private": true,
- "version": "12.29.2",
+ "version": "12.29.3",
"type": "module",
"scripts": {
"dev": "yarn vite",
@@ -11,7 +11,7 @@
"preview": "yarn vite preview"
},
"dependencies": {
- "framer-motion": "^12.29.2",
+ "framer-motion": "^12.29.3",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
diff --git a/dev/react/src/tests/reorder-auto-scroll-container.tsx b/dev/react/src/tests/reorder-auto-scroll-container.tsx
new file mode 100644
index 0000000000..4fe46e4a77
--- /dev/null
+++ b/dev/react/src/tests/reorder-auto-scroll-container.tsx
@@ -0,0 +1,104 @@
+import * as React from "react"
+import { useState } from "react"
+import { Reorder, useMotionValue } from "framer-motion"
+
+const initialItems = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
+
+interface ItemProps {
+ item: number
+}
+
+const Item = ({ item }: ItemProps) => {
+ const y = useMotionValue(0)
+ const hue = item * 30
+
+ return (
+
+ )
+}
+
+/**
+ * Test case for auto-scroll when the scrollable container is inside a scrollable page.
+ *
+ * This tests the bug where autoscroll wouldn't work because the gesture system
+ * uses pageX/pageY coordinates but getBoundingClientRect() returns viewport
+ * coordinates. When the page is scrolled, these coordinate systems don't match.
+ */
+export const App = () => {
+ const [items, setItems] = useState(initialItems)
+
+ return (
+ <>
+ {/* Spacer to make page scrollable */}
+
+
+ Scroll down to see the reorder list. The container and the page are both scrollable.
+
+
+
+
+ {items.map((item) => (
+
+ ))}
+
+
+ {/* More spacer to ensure page is scrollable */}
+
+
+ >
+ )
+}
+
+const styles = `
+html, body {
+ width: 100%;
+ min-height: 100%;
+ background: #333;
+ padding: 0;
+ margin: 0;
+}
+
+ul,
+li {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+ul {
+ position: relative;
+ width: 100%;
+}
+
+li {
+ border-radius: 10px;
+ margin-bottom: 10px;
+ width: 100%;
+ height: 60px;
+ position: relative;
+ border-radius: 5px;
+ flex-shrink: 0;
+ cursor: grab;
+}
+`
diff --git a/dev/react/src/tests/reorder-auto-scroll-page.tsx b/dev/react/src/tests/reorder-auto-scroll-page.tsx
index 0d305300b3..7c7f9a288d 100644
--- a/dev/react/src/tests/reorder-auto-scroll-page.tsx
+++ b/dev/react/src/tests/reorder-auto-scroll-page.tsx
@@ -46,8 +46,6 @@ export const App = () => {
{
describe("with scrollable container inside scrollable page", () => {
it("Auto-scrolls container after page has been scrolled", () => {
- cy.visit("?test=reorder-auto-scroll-page")
+ cy.visit("?test=reorder-auto-scroll-container")
.wait(200)
// Scroll the page down so the container is in view
.window()
@@ -117,4 +117,40 @@ describe("Reorder auto-scroll", () => {
})
})
})
+
+ describe("without scrollable container inside scrollable page", () => {
+ it("Auto-scrolls page after dragging near the edges", () => {
+ cy.visit("?test=reorder-auto-scroll-page")
+ .wait(200)
+ // Check window position, then scroll
+ .window()
+ .then((win) => {
+ expect(win.scrollY).to.equal(0)
+ })
+ .wait(100)
+ .get("[data-testid='0']")
+ .then(() => {
+ cy.window().then((win) => {
+ const nearBottom =
+ win.innerHeight - win.screenTop - 20
+
+ cy.get("[data-testid='0']")
+ .trigger("pointerdown", 50, 25)
+ .wait(50)
+ .trigger("pointermove", 50, 30, { force: true })
+ .wait(50)
+ .trigger("pointermove", 50, nearBottom, {
+ force: true,
+ })
+ .wait(300)
+ .window()
+ .then((win) => {
+ expect(win.scrollY).to.greaterThan(0)
+ })
+ .get("[data-testid='0']")
+ .trigger("pointerup", { force: true })
+ })
+ })
+ })
+ })
})
diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json
index 9d89260011..4b1d17b012 100644
--- a/packages/framer-motion/package.json
+++ b/packages/framer-motion/package.json
@@ -1,6 +1,6 @@
{
"name": "framer-motion",
- "version": "12.29.2",
+ "version": "12.29.3",
"description": "A simple and powerful JavaScript animation library",
"main": "dist/cjs/index.js",
"module": "dist/es/index.mjs",
diff --git a/packages/framer-motion/src/components/Reorder/utils/auto-scroll.ts b/packages/framer-motion/src/components/Reorder/utils/auto-scroll.ts
index 154ddba85c..30e5e12a50 100644
--- a/packages/framer-motion/src/components/Reorder/utils/auto-scroll.ts
+++ b/packages/framer-motion/src/components/Reorder/utils/auto-scroll.ts
@@ -39,7 +39,12 @@ export function resetAutoScrollState(): void {
function isScrollableElement(element: Element, axis: "x" | "y"): boolean {
const style = getComputedStyle(element)
const overflow = axis === "x" ? style.overflowX : style.overflowY
- return overflowStyles.has(overflow)
+
+ const isDocumentScroll =
+ element === document.body ||
+ element === document.documentElement
+
+ return overflowStyles.has(overflow) || isDocumentScroll
}
function findScrollableAncestor(
@@ -63,8 +68,8 @@ function getScrollAmount(
): { amount: number; edge: ActiveEdge } {
const rect = scrollElement.getBoundingClientRect()
- const start = axis === "x" ? rect.left : rect.top
- const end = axis === "x" ? rect.right : rect.bottom
+ const start = axis === "x" ? Math.max(0, rect.left) : Math.max(0, rect.top)
+ const end = axis === "x" ? Math.min(window.innerWidth, rect.right) : Math.min(window.innerHeight, rect.bottom)
const distanceFromStart = pointerPosition - start
const distanceFromEnd = end - pointerPosition
@@ -115,6 +120,10 @@ export function autoScrollIfNeeded(
const currentActiveEdge = activeScrollEdge.get(scrollableAncestor)
+ const isDocumentScroll =
+ scrollableAncestor === document.body ||
+ scrollableAncestor === document.documentElement
+
// If not currently scrolling this edge, check velocity to see if we should start
if (currentActiveEdge !== edge) {
// Only start scrolling if velocity is towards the edge
@@ -129,10 +138,9 @@ export function autoScrollIfNeeded(
// Record initial scroll limit (prevents infinite scroll)
const maxScroll =
axis === "x"
- ? scrollableAncestor.scrollWidth -
- scrollableAncestor.clientWidth
- : scrollableAncestor.scrollHeight -
- scrollableAncestor.clientHeight
+ ? scrollableAncestor.scrollWidth - (isDocumentScroll ? window.innerWidth : scrollableAncestor.clientWidth)
+ : scrollableAncestor.scrollHeight - (isDocumentScroll ? window.innerHeight : scrollableAncestor.clientHeight)
+
initialScrollLimits.set(scrollableAncestor, maxScroll)
}
@@ -141,15 +149,23 @@ export function autoScrollIfNeeded(
const initialLimit = initialScrollLimits.get(scrollableAncestor)!
const currentScroll =
axis === "x"
- ? scrollableAncestor.scrollLeft
- : scrollableAncestor.scrollTop
+ ? (isDocumentScroll ? window.scrollX : scrollableAncestor.scrollLeft)
+ : (isDocumentScroll ? window.scrollY : scrollableAncestor.scrollTop)
if (currentScroll >= initialLimit) return
}
// Apply scroll
if (axis === "x") {
- scrollableAncestor.scrollLeft += scrollAmount
+ if (isDocumentScroll) {
+ window.scrollBy({ left: scrollAmount })
+ } else {
+ scrollableAncestor.scrollLeft += scrollAmount
+ }
} else {
- scrollableAncestor.scrollTop += scrollAmount
+ if (isDocumentScroll) {
+ window.scrollBy({ top: scrollAmount })
+ } else {
+ scrollableAncestor.scrollTop += scrollAmount
+ }
}
}
diff --git a/packages/motion/package.json b/packages/motion/package.json
index 36f8919573..fda38ed78c 100644
--- a/packages/motion/package.json
+++ b/packages/motion/package.json
@@ -1,6 +1,6 @@
{
"name": "motion",
- "version": "12.29.2",
+ "version": "12.29.3",
"description": "An animation library for JavaScript and React.",
"main": "dist/cjs/index.js",
"module": "dist/es/index.mjs",
@@ -76,7 +76,7 @@
"postpublish": "git push --tags"
},
"dependencies": {
- "framer-motion": "^12.29.2",
+ "framer-motion": "^12.29.3",
"tslib": "^2.4.0"
},
"peerDependencies": {
diff --git a/yarn.lock b/yarn.lock
index f5c8ca9fa0..e08f109223 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7420,7 +7420,7 @@ __metadata:
languageName: node
linkType: hard
-"framer-motion@^12.29.2, framer-motion@workspace:packages/framer-motion":
+"framer-motion@^12.29.3, framer-motion@workspace:packages/framer-motion":
version: 0.0.0-use.local
resolution: "framer-motion@workspace:packages/framer-motion"
dependencies:
@@ -8192,8 +8192,8 @@ __metadata:
version: 0.0.0-use.local
resolution: "html-env@workspace:dev/html"
dependencies:
- framer-motion: ^12.29.2
- motion: ^12.29.2
+ framer-motion: ^12.29.3
+ motion: ^12.29.3
motion-dom: ^12.29.2
react: ^18.3.1
react-dom: ^18.3.1
@@ -11013,11 +11013,11 @@ __metadata:
languageName: unknown
linkType: soft
-"motion@^12.29.2, motion@workspace:packages/motion":
+"motion@^12.29.3, motion@workspace:packages/motion":
version: 0.0.0-use.local
resolution: "motion@workspace:packages/motion"
dependencies:
- framer-motion: ^12.29.2
+ framer-motion: ^12.29.3
tslib: ^2.4.0
peerDependencies:
"@emotion/is-prop-valid": "*"
@@ -11134,7 +11134,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "next-env@workspace:dev/next"
dependencies:
- motion: ^12.29.2
+ motion: ^12.29.3
next: 15.4.10
react: 19.0.0
react-dom: 19.0.0
@@ -12599,7 +12599,7 @@ __metadata:
"@typescript-eslint/parser": ^7.2.0
"@vitejs/plugin-react-swc": ^3.5.0
eslint-plugin-react-refresh: ^0.4.6
- motion: ^12.29.2
+ motion: ^12.29.3
react: ^19.0.0
react-dom: ^19.0.0
vite: ^5.2.0
@@ -12683,7 +12683,7 @@ __metadata:
"@typescript-eslint/parser": ^7.2.0
"@vitejs/plugin-react-swc": ^3.5.0
eslint-plugin-react-refresh: ^0.4.6
- framer-motion: ^12.29.2
+ framer-motion: ^12.29.3
react: ^18.3.1
react-dom: ^18.3.1
vite: ^5.2.0