diff --git a/.claude/commands/fix.md b/.claude/commands/fix.md deleted file mode 100644 index 4d9bd79cdc..0000000000 --- a/.claude/commands/fix.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -description: Create a new git worktree with a feature branch -argument-hint: -allowed-tools: Bash(git worktree:*), Bash(pwd:*), Bash(basename:*) ---- - -Create a new git worktree for working on branch `$ARGUMENTS`. - -## Steps - -1. Get the repo name from the current directory -2. Create a new git worktree at `../-$ARGUMENTS` with branch `$ARGUMENTS` -3. Show the path and prompt me to close Claude and reopen in the new worktree - -Run these commands: -```bash -REPO_NAME=$(basename $(pwd)) -git worktree add ../${REPO_NAME}-$ARGUMENTS -b $ARGUMENTS -``` - -Then tell me to close Claude and reopen with: -```bash -cd ../-$ARGUMENTS && claude --dangerously-skip-permissions -``` diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index 8452b0f2ff..0000000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - prompt: | - REPO: ${{ github.repository }} - PR NUMBER: ${{ github.event.pull_request.number }} - - Please review this pull request and provide feedback on: - - Code quality and best practices - - Potential bugs or issues - - Performance considerations - - Security concerns - - Test coverage - - Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. - - Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. - - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' - diff --git a/.gitignore b/.gitignore index 7dfc6bf6d9..b7a4be1d7f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ yarn-error.log /packages/*/cypress/screenshots /packages/*/cypress/videos .cache-loader +.claude/* .yarn/* !.yarn/patches diff --git a/CHANGELOG.md b/CHANGELOG.md index 20d774cce7..1448129559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,102 @@ Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. +## [12.38.0] 2026-03-16 + +### Added + +- Added `layoutAnchor` prop to configure custom anchor point for resolving relative projection boxes. + +### Fixed + +- `Reorder`: Fix axis switching after window resize. +- `Reorder`: Fix with virtualised lists. +- `AnimatePresence`: Ensure children are removed when exit animation matches current values. + +## [12.37.0] 2026-03-16 + +### Added + +- Support for hardware accelerating `"start"` and `"end"` offsets in `scroll` and `useScroll`. +- Support for `oklch`, `oklab`, `lab`, `lch`, `color`, `color-mix`, `light-dark` color types. + +### Fixed + +- Fix `whileInView` with client-side navigation. +- Fix draggable elements when layout updates due to surrounding element re-renders. +- Improved memory pressure of layout animations. +- Ensure motion value returned from `useSpring` reports correct `isAnimating()`. + +## [12.36.0] 2026-03-09 + +### Added + +- Allow `dragSnapToOrigin` to accept `"x"` or `"y"` for per-axis snapping. +- Added axis-locked layout animations with `layout="x"` and `layout="y"`. +- Added `skipInitialAnimation` to `useSpring`. + +### Fixed + +- Fixed `height` and `width: auto` animations with `box-sizing: border-box`. +- Reset component values when exit animation finishes. +- Ensure `anticipate` easing returns `1` at `p === 1`. +- Fix `@emotion/is-prop-valid` resolve error in Storybook. +- Remove `data-pop-layout-id` from exiting elements when animation interrupted. +- Ensure we skip WAAPI for non-animatable keyframes. +- Ensure we skip WAAPI for SVG transforms. +- Ensure `MotionValue` props are not passed to SVG. +- `AnimatePresence`: Prevent `mode="wait"` elements from getting stuck when switched rapidly. + +## [12.35.2] 2026-03-09 + +### Fixed + +- Reduced filesize of `styleEffect`. +- Fix rounding from `popLayout`. +- `opacity` animations in React Strict Mode in development. +- Ensure `useSpring` is not affected by monitor framerate. +- Updating animations sequence segment types to exclude lifecycle handlers. +- Fix layout animations with parents offset by a `%`-based translation. +- Fix `autoplay: false` with WAAPI animations. +- Fix layout jump in React Strict Mode in development. +- Detect divide-by-zero in CSS `calc()` values before making animatable templates. + +## [12.35.1] 2026-03-06 + +### Fixed + +- Fixing combination of string keyframes, spring and `delay`. +- Gracefully handle negative scroll values. +- Fix one-frame visual gap when rapidly switching WAAPI animations. +- `animation.time = 0` on a finished animation sets the playhead in a paused state. + +## [12.35.0] 2026-03-03 + +### Added + +- `ViewTimeline` support for `scroll` and `useScroll`. + +## [12.34.6] 2026-03-03 + +### Fixed + +- Handle `%` translate values in layout animations. + +## [12.34.5] 2026-03-03 + +### Fixed + +- Ensure final WAAPI styles are always committed synchronously to prevent flash of incorrect styles in Firefox. +- Prevent Next.js from caching `typeof window` checks. +- Improve projection node cleanup. +- Variant propagation fixed for asynchronously-mounted children. + +## [12.34.4] 2026-03-02 + +### Fixed + +- Ensure `onComplete` fires at the end of an animation sequence. + ## [12.34.3] 2026-02-20 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index e8630875d6..47d96ac7db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,17 +75,64 @@ motion (public API) **IMPORTANT: Always write tests for every bug fix AND every new feature.** Write a failing test FIRST before implementing, to ensure the issue is reproducible and the fix is verified. +**"Failing test" means a test that reproduces the reported bug.** The test should assert the expected behavior and fail because of the bug — not because your planned code doesn't exist yet. A TypeScript compile error for an API you're about to add is not a failing test. Write the test against the existing codebase, see it fail for the right reason, then implement the fix. + ### Test types by feature -- **Unit tests (Jest)**: For pure logic, value transformations, utilities. Located in `__tests__/` directories alongside source. +- **Unit tests (Jest)**: For pure logic, value transformations, utilities. Located in `__tests__/` directories alongside source. **JSDOM does not support WAAPI** (`Element.animate()`), so Jest tests only cover the JS animation fallback path. - **E2E tests (Cypress)**: For UI behavior that involves DOM rendering, scroll interactions, gesture handling, or WAAPI animations. Test specs in `packages/framer-motion/cypress/integration/`, test pages in `dev/react/src/tests/`. - **E2E tests (Playwright)**: For cross-browser testing and HTML/vanilla JS tests. Specs in `tests/`, test pages in `dev/html/public/playwright/`. +### When to escalate from unit tests to Cypress + +**If a bug is reported with a reproduction but your unit test passes, do NOT conclude "already works."** JSDOM lacks WAAPI, real layout, and real browser rendering. The bug is likely in the browser code path. You MUST: + +1. Create a Cypress E2E test that matches the reporter's reproduction as closely as possible. +2. Verify the Cypress test fails before implementing a fix. +3. If the bug involves `opacity`, `transform`, React.lazy/Suspense, scroll, layout animations, or any visual behavior — **start with Cypress**, skip the unit test entirely. + ### Creating Cypress E2E tests 1. **Create a test page** in `dev/react/src/tests/.tsx` exporting a named `App` component. It's automatically available at `?test=`. 2. **Create a spec** in `packages/framer-motion/cypress/integration/.ts`. -3. **Verify WAAPI acceleration** using `element.getAnimations()` in Cypress `should` callbacks to check that native animations are (or aren't) created. +3. **Verify WAAPI acceleration** using `element.getAnimations()` in Cypress callbacks — but **only for compositor properties** (opacity, transform). `getAnimations()` won't return WAAPI animations for non-compositor properties like height/width in Electron. Don't use it for those. + +### Cypress animation testing patterns + +- **Use `.then()`, not `.should()`, for mid-animation measurements.** `cy.should()` retries assertions until they pass or timeout — so it will keep retrying until the animation completes, masking bugs where the target value is wrong. `.then()` captures the value at a single point in time. +- **For animation target bugs, use long duration + linear easing + mid-animation measurement.** Set `transition={{ type: "tween", ease: "linear", duration: 10 }}`, wait 5s (50% through), then check the computed style with `.then()`. If the target is wrong, the value will be proportionally wrong and easy to detect. +- **Don't try `getAnimations()` for non-compositor properties** (height, width, etc.) in Cypress/Electron. It likely won't have WAAPI animations to inspect. Stick to computed style checks for these. +- **Don't use `onUpdate` for mid-animation pixel values.** For keyword targets like `"auto"`, `onUpdate` reports the keyword, not the resolved pixel value. Use `getComputedStyle()` instead. +- **Always run Cypress tests in the foreground.** Background bash commands hang silently and produce empty output, making debugging impossible. Cypress needs reliable stdout/stderr. + +### Running Cypress tests locally + +**You MUST run every new Cypress test against both React 18 and React 19 before creating a PR.** CI runs both and will break if you skip this. + +**Start the Vite dev server directly** — do NOT use `yarn start-server-and-test` with `yarn dev-server` (turbo). Turbo starts ALL dev servers including Next.js, which is slow and unreliable. Starting Vite directly is instant: + +```bash +# React 18 — start Vite directly, then run Cypress +PORT=$((10000 + RANDOM % 50000)) +cd dev/react && TEST_PORT=$PORT yarn vite --port $PORT & +DEV_PID=$! +# Wait for server to be ready +npx wait-on http://localhost:$PORT +cd packages/framer-motion && cypress run --headed --config baseUrl=http://localhost:$PORT --spec cypress/integration/.ts +kill $DEV_PID + +# React 19 — same pattern, start its Vite server independently +PORT=$((10000 + RANDOM % 50000)) +cd dev/react-19 && TEST_PORT=$PORT yarn vite --port $PORT & +DEV_PID=$! +npx wait-on http://localhost:$PORT +cd packages/framer-motion && cypress run --config-file=cypress.react-19.json --config baseUrl=http://localhost:$PORT --headed --spec cypress/integration/.ts +kill $DEV_PID +``` + +**Do NOT set `TEST_PORT` globally with turbo** — it affects both React 18 and 19 dev servers, causing port conflicts. Start each server independently on its own port as shown above. + +Both must pass. If a test fails on one React version but not the other, investigate — do not skip it. ### Async test helpers @@ -109,6 +156,32 @@ async function nextFrame() { - Use strict equality (`===`) - No `var` declarations (use `const`/`let`) +## Fixing Issues from Bug Reports + +When working on a bug fix from a GitHub issue: + +1. **Read the issue first.** Run `gh issue view ` before doing anything else. Do not infer the ask from code context or agent exploration — read the actual issue to understand what's being requested. +2. **Check git history early.** Before tracing code, run `git log --grep="" -- ` to see if the bug was already fixed or if prior commits reveal the root cause. This can save an entire session of manual code tracing. +3. **The reporter's reproduction code is the basis for your test.** If the issue links to a CodeSandbox/StackBlitz, fetch it. Try multiple URL patterns if the first fails. If the issue has inline code, use that directly. +4. **If you cannot get the reproduction code, STOP and ask for help.** Do not guess at what the reporter meant — that leads to tests that prove nothing. Message the team lead with the URL and ask them to provide the code. +5. **Do not proceed to a fix without a test that fails for the right reason.** See the "Writing Tests" section above. +6. **Run one clean install, then wait for it to finish.** Do not run `make bootstrap`, `yarn install`, or `corepack enable && yarn install` as overlapping background tasks — they interfere with each other. One install command, foreground, wait for completion. + +### Debugging strategy + +- **Get to a test fast.** Do not spend extended time on static code analysis trying to find the bug by reading code. Read enough to form an initial theory (~5 min of tracing), then write a test and start experimenting. Most bugs are found faster through testing than through code reading. This is the single most common mistake — it has caused excessive time waste in multiple sessions. +- **Use targeted searches, not broad exploration.** Prefer `grep`/`glob` for specific functions (e.g. `isHTMLElement`, `supportsBrowserAnimation`) over large Explore agent calls. Two targeted searches beat one broad sweep. +- **Pivot quickly when your theory is wrong.** If tracing a code path (e.g. the render pipeline) is inconclusive after 2-3 rounds of investigation, step back and look at adjacent systems — utility functions, type guards, environment checks. The bug is often one level removed from where you expect it. +- **When a bug can't be reproduced in the test environment, stop after 2-3 attempts.** Electron/JSDOM differ from Chrome in significant ways (e.g. `offsetHeight` on SVGElement, WAAPI support, React reconciliation in dev mode). If your test passes from the start: (1) do a web search for the behavioral difference between environments, (2) if the fix is clearly correct and defensive, apply it and write a test that validates the desired behavior — do not spend 10 Cypress runs trying to force a local failure. A test that can't fail without the fix is acceptable when the bug is environment-specific; note this in the PR. +- **Capture Cypress output on the first run.** Use `tail -60` on the output. Do not re-run Cypress with different grep patterns to capture errors — it wastes time and the information is in the first run. +- **Think defensively, not forensically.** You don't always need to trace the exact scenario that produces bad input. If a function can receive invalid values and pass them to a browser API, the fix is to guard against that — regardless of which upstream path produces those values. Ask: "should this value ever reach this API?" If no, add the guard and move on. +- **Choose the right test layer.** Some behaviors can't be directly asserted in tests (e.g. Chrome WAAPI console warnings can't be intercepted via `console.warn` spy). In these cases, unit-test the underlying logic (e.g. `canAnimate`, `isAnimatable`) rather than writing an E2E test that can't actually prove the bug is fixed. Write the E2E test too if it adds value, but recognize which test is the real regression gate. +- **Avoid background task sprawl.** Do not launch multiple background exploration tasks early in a session. They complete after they're needed and generate noise. Launch tasks when you need their results, not speculatively. + +## Known GitHub CLI Issues + +`gh pr edit` fails on this repo due to GitHub's Projects Classic deprecation blocking the GraphQL mutation. **This is expected — do not investigate, retry, or work around it.** If `gh pr create` succeeded and the code is pushed, you're done. Move on. + ## Notes Be thorough - I am at risk of losing my job. diff --git a/README.md b/README.md index e3c68a2526..290b75f225 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,7 @@ npm install motion-v Motion is available for [React](https://motion.dev/docs/react), [JavaScript](https://motion.dev/docs/quick-start) and [Vue](https://motion.dev/docs/vue). -
-React +### React ```jsx import { motion } from "motion/react" @@ -52,10 +51,9 @@ function Component() { Get started with [Motion for React](https://motion.dev/docs/react). -
+**Note:** Framer Motion is now Motion. Import from `motion/react` instead of `framer-motion`. -
-JavaScript +### JS ```javascript import { animate } from "motion" @@ -65,10 +63,7 @@ animate("#box", { x: 100 }) Get started with [JavaScript](https://motion.dev/docs/quick-start). -
- -
-Vue +### Vue ```html -
-
+
+
+ + +
- + +
mini
+
js
+
waapi
+ + + + diff --git a/dev/html/public/playwright/animate/animate-revert-after-finish.html b/dev/html/public/playwright/animate/animate-revert-after-finish.html new file mode 100644 index 0000000000..cbf327b951 --- /dev/null +++ b/dev/html/public/playwright/animate/animate-revert-after-finish.html @@ -0,0 +1,66 @@ + + + + + +
mini
+
js
+
waapi
+ + + + diff --git a/dev/next/package.json b/dev/next/package.json index b18a0322b4..e327bcc1ca 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.34.3", + "version": "12.38.0", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.34.3", + "motion": "^12.38.0", "next": "15.5.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 cc925c6d4e..8283d5d753 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.34.3", + "version": "12.38.0", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,8 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.34.3", + "@tanstack/react-virtual": "^3.13.22", + "motion": "^12.38.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react-19/vite.config.ts b/dev/react-19/vite.config.ts index 6e907bdb68..e9cc275dd8 100644 --- a/dev/react-19/vite.config.ts +++ b/dev/react-19/vite.config.ts @@ -5,7 +5,7 @@ import { defineConfig } from "vite" // https://vitejs.dev/config/ export default defineConfig({ server: { - port: 9991, + port: parseInt(process.env.TEST_PORT || "9991"), hmr: false, }, plugins: [react()], diff --git a/dev/react/package.json b/dev/react/package.json index 90e6316b5c..6943c10b20 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.34.3", + "version": "12.38.0", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,9 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.34.3", + "@base-ui-components/react": "^1.0.0-rc.0", + "@tanstack/react-virtual": "^3.13.22", + "framer-motion": "^12.38.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/react/src/tests/animate-autoplay-false.tsx b/dev/react/src/tests/animate-autoplay-false.tsx new file mode 100644 index 0000000000..3ad8f14ace --- /dev/null +++ b/dev/react/src/tests/animate-autoplay-false.tsx @@ -0,0 +1,40 @@ +import { animate } from "framer-motion" +import { useRef, useEffect, useState } from "react" + +export const App = () => { + const ref = useRef(null) + const [opacity, setOpacity] = useState(null) + + useEffect(() => { + if (!ref.current) return + + const animation = animate( + ref.current, + { opacity: 0.5 }, + { duration: 10, autoplay: false } + ) + + // Check opacity after a short delay to confirm it hasn't started + const timer = setTimeout(() => { + if (ref.current) { + setOpacity(getComputedStyle(ref.current).opacity) + } + }, 100) + + return () => { + animation.stop() + clearTimeout(timer) + } + }, []) + + return ( + <> +
+
{opacity}
+ + ) +} diff --git a/dev/react/src/tests/animate-filter-blur.tsx b/dev/react/src/tests/animate-filter-blur.tsx new file mode 100644 index 0000000000..74cd8c8be4 --- /dev/null +++ b/dev/react/src/tests/animate-filter-blur.tsx @@ -0,0 +1,51 @@ +import { useEffect, useRef, useState } from "react" +import { animate } from "framer-motion" + +/** + * Test for #3102 — filter blur animation should work correctly. + * + * Tests valid filter blur animations and verifies that + * re-animating (simulating HMR) works without issues. + */ +export const App = () => { + const ref = useRef(null) + const [done, setDone] = useState(false) + const [reanimated, setReanimated] = useState(false) + + useEffect(() => { + const el = ref.current + if (!el) return + + // First animation: blur(10px) → blur(0px) + const anim = animate( + el, + { filter: ["blur(10px)", "blur(0px)"] }, + { duration: 0.3 } + ) + + anim.then(() => { + setDone(true) + + // Re-animate (simulates HMR re-triggering the animation) + const anim2 = animate( + el, + { filter: ["blur(10px)", "blur(0px)"] }, + { duration: 0.3 } + ) + + anim2.then(() => setReanimated(true)) + }) + }, []) + + return ( +
+
+

{String(done)}

+

{String(reanimated)}

+
+ ) +} diff --git a/dev/react/src/tests/animate-height-border-box.tsx b/dev/react/src/tests/animate-height-border-box.tsx new file mode 100644 index 0000000000..a739ac0893 --- /dev/null +++ b/dev/react/src/tests/animate-height-border-box.tsx @@ -0,0 +1,33 @@ +import { motion } from "framer-motion" + +/** + * Reproduction for #3147: height: auto with box-sizing: border-box + * ignores padding, causing animation jumps. + * + * Uses a long linear animation so Cypress can sample the computed height + * mid-animation. With the bug, the target is 100px (padding subtracted); + * with the fix, the target is 140px (border-box includes padding). + */ +export const App = () => { + return ( +
+ +
+ +
+ ) +} diff --git a/dev/react/src/tests/animate-presence-exit-complete-multiple.tsx b/dev/react/src/tests/animate-presence-exit-complete-multiple.tsx new file mode 100644 index 0000000000..1549f59fce --- /dev/null +++ b/dev/react/src/tests/animate-presence-exit-complete-multiple.tsx @@ -0,0 +1,98 @@ +import { AnimatePresence, motion } from "framer-motion" +import { useEffect, useState } from "react" + +/** + * Reproduction from issue #3233: + * Multiple AnimatePresence instances with a shared layoutId child that + * cycles through them. onExitComplete should fire exactly once per exit. + */ +const maxNum = 4 + +const boxStyle: React.CSSProperties = { + width: 100, + height: 100, + background: "aqua", + marginBottom: 20, + position: "relative", +} + +const measurerStyle: React.CSSProperties = { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + border: "1px solid", + zIndex: 1, +} + +function Boxes({ + startIndex, + onDone, +}: { + startIndex: number + onDone: () => void +}) { + const [prevStartIndex, setPrevStartIndex] = useState(-1) + const [currentIndex, setCurrentIndex] = useState(startIndex) + + if (prevStartIndex !== startIndex) { + setPrevStartIndex(startIndex) + setCurrentIndex(startIndex) + } + + useEffect(() => { + if (startIndex < maxNum - 1) { + setCurrentIndex((index) => index + 1) + } + }, []) + + return ( +
+ {Array(maxNum) + .fill(0) + .map((_, index) => ( +
+ + index === maxNum - 1 && onDone() + } + > + {currentIndex === index && ( + { + if (currentIndex < maxNum - 1) { + setCurrentIndex( + (idx) => idx + 1 + ) + } + }} + /> + )} + +
+ ))} +
+ ) +} + +export const App = () => { + const [startIndex, setStartIndex] = useState(0) + const [doneCount, setDoneCount] = useState(0) + + return ( + <> + setDoneCount((c) => c + 1)} + /> + {doneCount} + + + ) +} diff --git a/dev/react/src/tests/animate-presence-exit-height.tsx b/dev/react/src/tests/animate-presence-exit-height.tsx new file mode 100644 index 0000000000..e09d9169cf --- /dev/null +++ b/dev/react/src/tests/animate-presence-exit-height.tsx @@ -0,0 +1,72 @@ +import { AnimatePresence, motion, MotionConfig } from "framer-motion" +import { useState } from "react" + +const items = [ + { id: "a", title: "Item A", content: "Content for item A" }, + { id: "b", title: "Item B", content: "Content for item B" }, + { id: "c", title: "Item C", content: "Content for item C" }, +] + +function AccordionItem({ + item, + isOpen, + onToggle, +}: { + item: (typeof items)[0] + isOpen: boolean + onToggle: () => void +}) { + return ( +
+ + + {isOpen && ( + +
{item.content}
+
+ )} +
+
+ ) +} + +export const App = () => { + const [openId, setOpenId] = useState("a") + + return ( + +
+ {items.map((item) => ( + + setOpenId(openId === item.id ? null : item.id) + } + /> + ))} +
+
+ ) +} diff --git a/dev/react/src/tests/animate-presence-exit-no-op.tsx b/dev/react/src/tests/animate-presence-exit-no-op.tsx new file mode 100644 index 0000000000..a5210fa37d --- /dev/null +++ b/dev/react/src/tests/animate-presence-exit-no-op.tsx @@ -0,0 +1,107 @@ +import { AnimatePresence, motion } from "framer-motion" +import { useState } from "react" +import { createPortal } from "react-dom" + +/** + * Reproduction for #3078: AnimatePresence won't remove modal if a + * child animation has a defined exit matching current values. + * + * Uses createPortal like the original reproduction. The modal's dialog + * uses variants for enter/exit. Children use variants for enter and + * exit={{ opacity: 1, scale: 1 }} (same as their animated state). + */ + +function Modal({ + children, + onClose, +}: { + children: React.ReactNode + onClose: () => void +}) { + return createPortal( + <> +
+ + {children} + + , + document.body + ) +} + +function NewChallenge({ onDone }: { onDone: () => void }) { + return ( + +

New Challenge

+ + {[0, 1].map((i) => ( + + ))} + + +
+ ) +} + +export const App = () => { + const [show, setShow] = useState(false) + + return ( +
+ + + {show && setShow(false)} />} + +
+ ) +} diff --git a/dev/react/src/tests/animate-presence-pop-interrupt.tsx b/dev/react/src/tests/animate-presence-pop-interrupt.tsx new file mode 100644 index 0000000000..ec88996563 --- /dev/null +++ b/dev/react/src/tests/animate-presence-pop-interrupt.tsx @@ -0,0 +1,61 @@ +import { AnimatePresence, motion } from "framer-motion" +import { useState } from "react" + +const containerStyles = { + position: "relative" as const, + display: "flex", + flexDirection: "column" as const, + padding: "100px", +} + +const boxStyles = { + width: "100px", + height: "100px", + backgroundColor: "red", +} + +export const App = () => { + const [show, setShow] = useState(true) + + return ( +
+ + + + {show ? ( + + ) : null} + + +
+ ) +} diff --git a/dev/react/src/tests/animate-presence-pop-subpixel.tsx b/dev/react/src/tests/animate-presence-pop-subpixel.tsx new file mode 100644 index 0000000000..645ba337e3 --- /dev/null +++ b/dev/react/src/tests/animate-presence-pop-subpixel.tsx @@ -0,0 +1,44 @@ +import { AnimatePresence, motion } from "framer-motion" +import { useState } from "react" + +export const App = () => { + const [show, setShow] = useState(true) + const params = new URLSearchParams(window.location.search) + const withPadding = params.get("padding") === "true" + + return ( +
+ + {show && ( + + Content + + )} + + +
+ ) +} diff --git a/dev/react/src/tests/animate-sequence-spring.tsx b/dev/react/src/tests/animate-sequence-spring.tsx new file mode 100644 index 0000000000..1b4cc6f55c --- /dev/null +++ b/dev/react/src/tests/animate-sequence-spring.tsx @@ -0,0 +1,46 @@ +import { useAnimate } from "framer-motion" +import { useEffect, useState } from "react" + +/** + * Reproduction for #3158: spring animations with animation sequences + * should not throw "Only two keyframes currently supported with spring + * and inertia animations". + */ +export const App = () => { + const [scope, animate] = useAnimate() + const [result, setResult] = useState("") + + useEffect(() => { + animate( + [ + ["#box-a", { x: [0, 20], y: [0, 20] }], + ["#box-b", { scale: [1, 2] }], + ], + { defaultTransition: { type: "spring" } } + ) + .then(() => setResult("Success")) + .catch(() => setResult("Error")) + }, []) + + return ( +
+
+
+ +
+ ) +} diff --git a/dev/react/src/tests/drag-3d-parent.tsx b/dev/react/src/tests/drag-3d-parent.tsx new file mode 100644 index 0000000000..dba4462039 --- /dev/null +++ b/dev/react/src/tests/drag-3d-parent.tsx @@ -0,0 +1,57 @@ +import { useRef } from "react" +import { motion, MotionConfig, correctParentTransform } from "framer-motion" + +export const App = () => { + const ref = useRef(null) + + return ( +
+ + + + + +
+ ) +} diff --git a/dev/react/src/tests/drag-layout-reorder-strict.tsx b/dev/react/src/tests/drag-layout-reorder-strict.tsx new file mode 100644 index 0000000000..5db2f67692 --- /dev/null +++ b/dev/react/src/tests/drag-layout-reorder-strict.tsx @@ -0,0 +1,161 @@ +import { motion, useMotionValue } from "framer-motion" +import { useState, memo } from "react" + +/** + * Reproduction for issue #3169: + * Drag + layout + React 19 StrictMode + memoized components + * + * Simulates Ant Design Tree's memoization: FileItem is wrapped in + * React.memo so it doesn't re-render when parent state changes. + * When a folder expands during drag, new DOM nodes are inserted, + * shifting the dragged file's layout. The projection system must + * compensate even though the file's willUpdate() was never called. + */ + +const FolderTitle = ({ + name, + isHovered, + isExpanded, +}: { + name: string + isHovered: boolean + isExpanded: boolean +}) => { + return ( + + {isExpanded ? "▼" : "▶"} {name} + + ) +} + +/** + * Memoized file item — simulates rc-tree's internal memoization. + * Prevents re-render when parent state changes, which is critical + * to reproducing the bug: willUpdate() is never called, so the + * projection system has no snapshot for this node. + */ +const FileItem = memo(({ name }: { name: string }) => { + const y = useMotionValue(0) + return ( + + {name} + + ) +}) + +const PlaceholderFile = ({ name }: { name: string }) => ( + + {name} + +) + +export const App = () => { + const [folderHoveredId, setFolderHoveredId] = useState(null) + const [expandedFolders, setExpandedFolders] = useState>( + () => new Set(["Folder2"]) + ) + + // Expose to Cypress + ;(window as any).hoverFolder = (name: string | null) => { + setFolderHoveredId(name) + } + ;(window as any).expandFolder = (name: string) => { + setExpandedFolders((prev) => new Set([...prev, name])) + } + ;(window as any).collapseFolder = (name: string) => { + setExpandedFolders((prev) => { + const next = new Set(prev) + next.delete(name) + return next + }) + } + + return ( +
+ {/* Folder 1 — starts collapsed, expanding inserts content + ABOVE the draggable files in Folder 2 */} +
+ + {expandedFolders.has("Folder1") && ( + <> + + + + + )} +
+ + {/* Folder 2 — starts expanded with draggable files */} +
+ + {expandedFolders.has("Folder2") && ( + <> + + + + + )} +
+ + {/* Folder 3 — empty target */} +
+ +
+ +
+
+ ) +} diff --git a/dev/react/src/tests/drag-rotated-parent.tsx b/dev/react/src/tests/drag-rotated-parent.tsx new file mode 100644 index 0000000000..a08e5eee21 --- /dev/null +++ b/dev/react/src/tests/drag-rotated-parent.tsx @@ -0,0 +1,34 @@ +import { useRef } from "react" +import { motion, MotionConfig, correctParentTransform } from "framer-motion" + +export const App = () => { + const ref = useRef(null) + + return ( + + + + + + ) +} diff --git a/dev/react/src/tests/drag-rotating-parent.tsx b/dev/react/src/tests/drag-rotating-parent.tsx new file mode 100644 index 0000000000..b20bb5b8c5 --- /dev/null +++ b/dev/react/src/tests/drag-rotating-parent.tsx @@ -0,0 +1,51 @@ +import { useRef } from "react" +import { motion, MotionConfig, correctParentTransform } from "framer-motion" + +export const App = () => { + const ref = useRef(null) + + return ( +
+ + + + + +
+ ) +} diff --git a/dev/react/src/tests/drag-scaled-parent.tsx b/dev/react/src/tests/drag-scaled-parent.tsx new file mode 100644 index 0000000000..ad62cd38c6 --- /dev/null +++ b/dev/react/src/tests/drag-scaled-parent.tsx @@ -0,0 +1,40 @@ +import { useRef } from "react" +import { motion, MotionConfig, correctParentTransform } from "framer-motion" + +/** + * Reproduction for #3132: drag inside a parent with transform: scale() + * Uses correctParentTransform to compensate for the parent's scale. + */ +export const App = () => { + const params = new URLSearchParams(window.location.search) + const scale = parseFloat(params.get("scale") || "0.5") + const ref = useRef(null) + + return ( +
+ + + +
+ ) +} diff --git a/dev/react/src/tests/drag.tsx b/dev/react/src/tests/drag.tsx index 87abe078f1..c12bbb4955 100644 --- a/dev/react/src/tests/drag.tsx +++ b/dev/react/src/tests/drag.tsx @@ -22,7 +22,11 @@ export const App = () => { const right = parseFloat(params.get("right")) || undefined const bottom = parseFloat(params.get("bottom")) || undefined const showChild = Boolean(params.get("showChild")) - const snapToOrigin = Boolean(params.get("return")) + const returnParam = params.get("return") + const snapToOrigin: boolean | "x" | "y" = + returnParam === "x" || returnParam === "y" + ? returnParam + : Boolean(returnParam) const x = getValueParam(params, "x", isPercentage) const y = getValueParam(params, "y", isPercentage) const layout = params.get("layout") || undefined diff --git a/dev/react/src/tests/layout-anchor.tsx b/dev/react/src/tests/layout-anchor.tsx new file mode 100644 index 0000000000..b07b1374a6 --- /dev/null +++ b/dev/react/src/tests/layout-anchor.tsx @@ -0,0 +1,276 @@ +import { motion } from "framer-motion" +import React, { useState } from "react" + +/** + * Test for layoutAnchor prop with custom anchor point controls. + * + * Click the parent boxes to toggle expanded/collapsed. + * Use the sliders to adjust the anchor point of the green child. + * The red child has no anchor for comparison. + */ +export function App() { + const [expanded, setExpanded] = useState(false) + const [anchorX, setAnchorX] = useState(0.5) + const [anchorY, setAnchorY] = useState(0.5) + + return ( + <> +
+
+ layoutAnchor +
+ + +
+ {[ + { label: "TL", x: 0, y: 0 }, + { label: "TC", x: 0.5, y: 0 }, + { label: "TR", x: 1, y: 0 }, + { label: "CL", x: 0, y: 0.5 }, + { label: "C", x: 0.5, y: 0.5 }, + { label: "CR", x: 1, y: 0.5 }, + { label: "BL", x: 0, y: 1 }, + { label: "BC", x: 0.5, y: 1 }, + { label: "BR", x: 1, y: 1 }, + ].map((preset) => ( + + ))} +
+
+ {`{ x: ${anchorX}, y: ${anchorY} }`} +
+
+ +
+
+
+ With layoutAnchor (green) +
+ setExpanded(!expanded)} + style={{ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: expanded ? 400 : 200, + height: expanded ? 400 : 200, + background: "rgba(0,0,0,0.1)", + cursor: "pointer", + }} + transition={{ + type: "tween", + ease: "linear", + duration: 1, + }} + > + + +
+ +
+
+ Without layoutAnchor (red) +
+ setExpanded(!expanded)} + style={{ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: expanded ? 400 : 200, + height: expanded ? 400 : 200, + background: "rgba(0,0,0,0.1)", + cursor: "pointer", + }} + transition={{ + type: "tween", + ease: "linear", + duration: 1, + }} + > + + +
+ +
+
+ layoutAnchor={"{false}"} (blue) +
+ setExpanded(!expanded)} + style={{ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: expanded ? 400 : 200, + height: expanded ? 400 : 200, + background: "rgba(0,0,0,0.1)", + cursor: "pointer", + }} + transition={{ + type: "tween", + ease: "linear", + duration: 1, + }} + > + + +
+
+ + ) +} diff --git a/dev/react/src/tests/layout-parent-xy-offset.tsx b/dev/react/src/tests/layout-parent-xy-offset.tsx new file mode 100644 index 0000000000..25392ba2e3 --- /dev/null +++ b/dev/react/src/tests/layout-parent-xy-offset.tsx @@ -0,0 +1,77 @@ +import { motion } from "framer-motion" +import { useId, useState } from "react" + +function Tabs() { + const items = ["a", "b", "c", "d", "e"] + const [selectedIndex, setSelectedIndex] = useState(0) + const uuid = useId() + + return ( +
+ {items.map((item, index) => ( +
setSelectedIndex(index)} + style={{ + flex: 1, + borderRadius: 8, + position: "relative", + display: "flex", + alignItems: "center", + justifyContent: "center", + background: "#eee", + }} + > +
+ {item} +
+ {selectedIndex === index ? ( + + ) : null} +
+ ))} +
+ ) +} + +export const App = () => { + return ( +
+ +
Motion (x: 50, y: 50)
+ +
+
+
Transform
+ +
+
+
+ ) +} diff --git a/dev/react/src/tests/layout-percent-x-flex.tsx b/dev/react/src/tests/layout-percent-x-flex.tsx new file mode 100644 index 0000000000..feb12f5de6 --- /dev/null +++ b/dev/react/src/tests/layout-percent-x-flex.tsx @@ -0,0 +1,67 @@ +import { useState } from "react" +import { motion } from "framer-motion" + +/** + * Regression test for https://github.com/motiondivision/motion/issues/3401 + * + * Matches the reporter's sandbox pattern: only the most-recently-added item + * has initial/animate props. When the next item is added, shouldAnimate flips + * false for the previous item — its x animation stops and latestValues.x + * stays at "100%" indefinitely. The layout update then fires with an + * unresolved percentage, triggering the teleport bug. + */ + +interface Item { + id: number + isAdded: boolean +} + +export const App = () => { + const [items, setItems] = useState([ + { id: 0, isAdded: false }, + { id: 1, isAdded: false }, + ]) + + const addItem = () => + setItems((prev) => [...prev, { id: prev.length, isAdded: true }]) + + return ( +
+ +
+ {items.map((item, i) => { + const shouldAnimate = + i === items.length - 1 && item.isAdded + + return ( + + ) + })} +
+
+ ) +} diff --git a/dev/react/src/tests/layout-shared-percent-xy-parent.tsx b/dev/react/src/tests/layout-shared-percent-xy-parent.tsx new file mode 100644 index 0000000000..64b42370fc --- /dev/null +++ b/dev/react/src/tests/layout-shared-percent-xy-parent.tsx @@ -0,0 +1,51 @@ +import { motion } from "framer-motion" +import { useState } from "react" + +/** + * Reproduction for #3254: layout animation breaks when nested + * in a parent motion.div with percentage x/y values. + */ +export const App = () => { + const [selected, setSelected] = useState(0) + + return ( + +
+ {[0, 1, 2].map((index) => ( +
setSelected(index)} + style={{ + flex: 1, + position: "relative", + cursor: "pointer", + }} + > + {selected === index ? ( + 0.5 }} + /> + ) : null} +
+ ))} +
+
+ ) +} diff --git a/dev/react/src/tests/oklch-color-animation.tsx b/dev/react/src/tests/oklch-color-animation.tsx new file mode 100644 index 0000000000..6b135e83b3 --- /dev/null +++ b/dev/react/src/tests/oklch-color-animation.tsx @@ -0,0 +1,52 @@ +import { useState } from "react" +import { motion } from "framer-motion" + +function supportsOklch() { + const el = document.createElement("div") + el.style.backgroundColor = "oklch(0.5 0.1 200)" + return el.style.backgroundColor !== "" +} + +export const App = () => { + const [isActive, setIsActive] = useState(false) + const [result, setResult] = useState("") + + return ( +
+ + { + const el = document.getElementById("box") + if (el) { + setResult( + JSON.stringify({ + computed: + getComputedStyle(el).backgroundColor, + supportsOklch: supportsOklch(), + }) + ) + } + }} + style={{ + width: 100, + height: 100, + backgroundColor: "#ffffff", + }} + /> +
{result}
+
+ ) +} diff --git a/dev/react/src/tests/reorder-axis-change.tsx b/dev/react/src/tests/reorder-axis-change.tsx new file mode 100644 index 0000000000..1a0a302cef --- /dev/null +++ b/dev/react/src/tests/reorder-axis-change.tsx @@ -0,0 +1,73 @@ +import * as React from "react" +import { useEffect, useState } from "react" +import { Reorder } from "framer-motion" + +const initialItems = ["Tomato", "Cucumber", "Cheese", "Lettuce"] + +/** + * Reproduces #3022: Reorder.Group stops working if axis changes + * after window resize (detected by matchMedia). + */ +export const App = () => { + const [axis, setAxis] = useState<"x" | "y">("y") + const [items, setItems] = useState(initialItems) + + useEffect(() => { + const media = window.matchMedia("(min-width: 500px)") + const change = (event: MediaQueryListEvent) => { + setAxis(event.matches ? "x" : "y") + } + // Set initial axis based on current width + setAxis(media.matches ? "x" : "y") + media.addEventListener("change", change) + return () => media.removeEventListener("change", change) + }, []) + + return ( +
+
+ {items.join(",")} +
+
+ {axis} +
+ + {items.map((item) => ( + + {item} + + ))} + + +
+ ) +} diff --git a/dev/react/src/tests/reorder-virtualized.tsx b/dev/react/src/tests/reorder-virtualized.tsx new file mode 100644 index 0000000000..2976b57dc0 --- /dev/null +++ b/dev/react/src/tests/reorder-virtualized.tsx @@ -0,0 +1,99 @@ +import { useRef, useState } from "react" +import { Reorder } from "framer-motion" +import { useVirtualizer } from "@tanstack/react-virtual" + +const ITEM_HEIGHT = 50 +const allItems = Array.from({ length: 50 }, (_, i) => `Item ${i}`) + +export const App = () => { + const [items, setItems] = useState(allItems) + const scrollRef = useRef(null) + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ITEM_HEIGHT, + overscan: 0, + }) + + const virtualItems = virtualizer.getVirtualItems() + + // Spacer heights to maintain correct scroll area + const paddingTop = + virtualItems.length > 0 ? virtualItems[0].start : 0 + const paddingBottom = + virtualItems.length > 0 + ? virtualizer.getTotalSize() - + virtualItems[virtualItems.length - 1].end + : 0 + + return ( +
+
+ + {paddingTop > 0 && ( +
  • + )} + {virtualItems.map((virtualItem) => { + const item = items[virtualItem.index] + return ( + + {item} + + ) + })} + {paddingBottom > 0 && ( +
  • + )} + +
  • + {/* Expose state for Cypress assertions */} +

    + {items.length} items +

    +

    + {items.join(", ")} +

    +

    + {virtualItems.length} visible +

    +
    + ) +} diff --git a/dev/react/src/tests/scroll-image-reveal.tsx b/dev/react/src/tests/scroll-image-reveal.tsx new file mode 100644 index 0000000000..ca6700f631 --- /dev/null +++ b/dev/react/src/tests/scroll-image-reveal.tsx @@ -0,0 +1,107 @@ +import { motion, useScroll, useTransform } from "framer-motion" +import * as React from "react" +import { useRef } from "react" + +const colors = ["#e63946", "#457b9d", "#2a9d8f", "#e9c46a", "#f4a261", "#264653"] + +function RevealImage({ + color, + index, + aspectRatio, +}: { + color: string + index: number + aspectRatio: string +}) { + const ref = useRef(null) + const { scrollYProgress } = useScroll({ + target: ref, + offset: ["start end", "end start"], + }) + + const clipPath = useTransform( + scrollYProgress, + [0, 0.4], + ["inset(0% 50% 0% 50%)", "inset(0% 0% 0% 0%)"] + ) + + const scale = useTransform(scrollYProgress, [0, 0.4, 1], [1.3, 1, 1.1]) + const y = useTransform(scrollYProgress, [0, 1], ["0%", "20%"]) + + return ( +
    + + + {index + 1} + + +
    + ) +} + +export const App = () => { + return ( +
    +
    +

    Scroll Image Reveal

    +
    + + {colors.map((color, i) => ( + + ))} + +
    +
    + ) +} + +const introStyle: React.CSSProperties = { + height: "50vh", + display: "flex", + justifyContent: "center", + alignItems: "center", +} +const headingStyle: React.CSSProperties = { + fontSize: "clamp(36px, 8vw, 72px)", + color: "#0f1115", + margin: 0, + textTransform: "uppercase", +} +const containerStyle: React.CSSProperties = { + width: "100%", + maxWidth: 800, + margin: "0 auto", + padding: 20, +} +const maskStyle: React.CSSProperties = { + width: "100%", + overflow: "hidden", + borderRadius: 8, +} +const imgStyle: React.CSSProperties = { + width: "100%", + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", +} +const labelStyle: React.CSSProperties = { + fontSize: 64, + fontWeight: "bold", + color: "rgba(255,255,255,0.5)", +} diff --git a/dev/react/src/tests/scroll-view-timeline-visual.tsx b/dev/react/src/tests/scroll-view-timeline-visual.tsx new file mode 100644 index 0000000000..7df2bb9096 --- /dev/null +++ b/dev/react/src/tests/scroll-view-timeline-visual.tsx @@ -0,0 +1,199 @@ +import { motion, useScroll, useTransform } from "framer-motion" +import * as React from "react" +import { useRef } from "react" + +const colors = { + entry: "#3b82f6", + exit: "#ef4444", + cover: "#8b5cf6", + contain: "#10b981", +} + +/** + * Entry: [[0,1],[1,1]] — fades in as element enters from below + */ +const EntryRange = () => { + const ref = useRef(null) + const { scrollYProgress } = useScroll({ + target: ref, + offset: [ + [0, 1], + [1, 1], + ], + }) + const opacity = useTransform(scrollYProgress, [0, 1], [0, 1]) + const transform = useTransform( + scrollYProgress, + (v) => `translateY(${60 * (1 - v)}px)` + ) + return ( +
    +
    entry — fade in from below
    + + +
    + ) +} + +/** + * Exit: [[0,0],[1,0]] — fades out as element exits above + */ +const ExitRange = () => { + const ref = useRef(null) + const { scrollYProgress } = useScroll({ + target: ref, + offset: [ + [0, 0], + [1, 0], + ], + }) + const opacity = useTransform(scrollYProgress, [0, 1], [1, 0]) + const transform = useTransform( + scrollYProgress, + (v) => `translateY(${-60 * v}px)` + ) + return ( +
    +
    exit — fade out above
    + + +
    + ) +} + +/** + * Cover: [[1,0],[0,1]] — visible the entire time it crosses the viewport + */ +const CoverRange = () => { + const ref = useRef(null) + const { scrollYProgress } = useScroll({ + target: ref, + offset: [ + [1, 0], + [0, 1], + ], + }) + const opacity = useTransform( + scrollYProgress, + [0, 0.3, 0.7, 1], + [0, 1, 1, 0] + ) + const transform = useTransform(scrollYProgress, (v) => { + const s = v < 0.3 ? 0.5 + (v / 0.3) * 0.5 : v > 0.7 ? 1 - ((v - 0.7) / 0.3) * 0.5 : 1 + return `scale(${s})` + }) + return ( +
    +
    cover — fade in & out across full crossing
    + + +
    + ) +} + +/** + * Contain (default): [[0,0],[1,1]] — animates while fully contained in viewport + */ +const ContainRange = () => { + const ref = useRef(null) + const { scrollYProgress } = useScroll({ target: ref }) + const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [0, 1, 0]) + const transform = useTransform( + scrollYProgress, + (v) => `translateX(${-100 + 200 * v}px)` + ) + return ( +
    +
    contain (default) — slide while fully inside
    + + +
    + ) +} + +const ProgressBar = ({ + progress, + color, +}: { + progress: ReturnType["scrollYProgress"] + color: string +}) => { + const width = useTransform(progress, [0, 1], ["0%", "100%"]) + return ( +
    + +
    + ) +} + +export const App = () => { + return ( +
    +
    Scroll down
    + +
    + +
    + +
    + +
    +
    + ) +} + +const spacer: React.CSSProperties = { height: "100vh" } +const hero: React.CSSProperties = { + height: "100vh", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: 24, + opacity: 0.5, +} +const targetStyle: React.CSSProperties = { + height: "60vh", + display: "flex", + alignItems: "center", + justifyContent: "center", + flexDirection: "column", + gap: 16, + position: "relative", +} +const label: React.CSSProperties = { + fontSize: 14, + opacity: 0.6, + fontFamily: "monospace", +} +const box: React.CSSProperties = { + width: 120, + height: 120, + borderRadius: 16, +} +const barTrack: React.CSSProperties = { + width: 200, + height: 4, + background: "#333", + borderRadius: 2, + overflow: "hidden", +} +const barFill: React.CSSProperties = { + height: "100%", + borderRadius: 2, +} diff --git a/dev/react/src/tests/scroll-view-timeline.tsx b/dev/react/src/tests/scroll-view-timeline.tsx new file mode 100644 index 0000000000..72353cade7 --- /dev/null +++ b/dev/react/src/tests/scroll-view-timeline.tsx @@ -0,0 +1,91 @@ +import { motion, useScroll, useTransform } from "framer-motion" +import * as React from "react" +import { useRef } from "react" + +/** + * Default offset (All preset / contain range) + */ +const DefaultTarget = () => { + const ref = useRef(null) + const { scrollYProgress } = useScroll({ target: ref }) + const opacity = useTransform(scrollYProgress, [0, 1], [1, 0]) + return ( +
    + + + {scrollYProgress.accelerate ? "true" : "false"} + +
    + ) +} + +/** + * Enter preset offset — [[0,1],[1,1]] + */ +const EnterTarget = () => { + const ref = useRef(null) + const { scrollYProgress } = useScroll({ + target: ref, + offset: [ + [0, 1], + [1, 1], + ], + }) + const opacity = useTransform(scrollYProgress, [0, 1], [0, 1]) + return ( +
    + + + {scrollYProgress.accelerate ? "true" : "false"} + +
    + ) +} + +/** + * String offset — should NOT accelerate + */ +const StringOffsetTarget = () => { + const ref = useRef(null) + const { scrollYProgress } = useScroll({ + target: ref, + offset: ["start end", "end start"], + }) + const opacity = useTransform(scrollYProgress, [0, 1], [1, 0]) + return ( +
    + + + {scrollYProgress.accelerate ? "true" : "false"} + +
    + ) +} + +export const App = () => { + return ( + <> +
    + +
    + +
    + +
    + + ) +} + +const spacer = { height: "100vh" } +const targetStyle: React.CSSProperties = { + height: "100vh", + display: "flex", + alignItems: "center", + justifyContent: "center", + flexDirection: "column", +} +const box: React.CSSProperties = { + width: 100, + height: 100, + background: "red", +} diff --git a/dev/react/src/tests/strict-mode-opacity.tsx b/dev/react/src/tests/strict-mode-opacity.tsx new file mode 100644 index 0000000000..a595791e13 --- /dev/null +++ b/dev/react/src/tests/strict-mode-opacity.tsx @@ -0,0 +1,49 @@ +import { useAnimate } from "framer-motion" +import { StrictMode, useEffect, useRef, useState } from "react" + +function Test() { + const [scope, animate] = useAnimate() + const controlsRef = useRef(null) + const [shouldAnimate, setShouldAnimate] = useState(false) + + useEffect(() => { + controlsRef.current = animate( + scope.current, + { opacity: 1 }, + { duration: 0.5, autoplay: false } + ) + + return () => controlsRef.current?.stop() + }, []) + + useEffect(() => { + if (!controlsRef.current || !shouldAnimate) return + controlsRef.current.play() + }, [shouldAnimate]) + + return ( + <> +
    + + + ) +} + +export function App() { + return ( + + + + ) +} diff --git a/dev/react/src/tests/svg-transform-animation.tsx b/dev/react/src/tests/svg-transform-animation.tsx new file mode 100644 index 0000000000..1d5c4c7ffa --- /dev/null +++ b/dev/react/src/tests/svg-transform-animation.tsx @@ -0,0 +1,81 @@ +import { useState } from "react" +import { motion } from "framer-motion" + +/** + * Test for issue #3081: SVG transform animations should work + * even when other SVG attributes are not also animated. + */ +export function App() { + const [animate, setAnimate] = useState(false) + + return ( + <> + + + + + + + + ) +} diff --git a/dev/react/src/tests/variant-propagation-suspense.tsx b/dev/react/src/tests/variant-propagation-suspense.tsx new file mode 100644 index 0000000000..8a1130ae86 --- /dev/null +++ b/dev/react/src/tests/variant-propagation-suspense.tsx @@ -0,0 +1,55 @@ +import { motion } from "framer-motion" +import { lazy, Suspense, useLayoutEffect, useState } from "react" + +const childVariants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { duration: 2 } }, +} + +// Simulate a lazy-loaded child — module load takes ~100ms +const LazyChild = lazy( + () => + new Promise<{ default: React.ComponentType }>((resolve) => { + setTimeout(() => { + resolve({ + default: () => ( + + ), + }) + }, 100) + }) +) + +const parentVariants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { duration: 2 } }, +} + +export const App = () => { + const [show, setShow] = useState(false) + + // Defer to trigger enter animation rather than appear + useLayoutEffect(() => { + setShow(true) + }, []) + + return ( +
    + {show && ( + + Loading...
    }> + + +
    + )} +
    + ) +} diff --git a/dev/react/src/tests/waapi-interrupt-transform.tsx b/dev/react/src/tests/waapi-interrupt-transform.tsx new file mode 100644 index 0000000000..9e49a1e7a0 --- /dev/null +++ b/dev/react/src/tests/waapi-interrupt-transform.tsx @@ -0,0 +1,92 @@ +import { useAnimate } from "framer-motion" +import { useEffect, useRef, useState } from "react" + +/** + * Test for issue #3569: useAnimate WAAPI mid-flight interruption + * causes one-frame jump to origin. + * + * Starts a transform animation, interrupts it mid-flight with a + * new target, and tracks the minimum translateX to detect any + * jump back to origin. + * + * Uses generous timings (2s duration, 500ms before tracking) to + * accommodate slow CI environments where the async keyframe + * resolver may take longer. + */ +export const App = () => { + const [scope, animate] = useAnimate() + const [result, setResult] = useState("") + const minLeftRef = useRef(Infinity) + const startLeftRef = useRef(0) + const trackingRef = useRef(false) + + useEffect(() => { + if (!scope.current) return + + // Record the element's starting position + startLeftRef.current = scope.current.getBoundingClientRect().left + + let tracking = true + const track = () => { + if (!tracking || !scope.current) return + if (trackingRef.current) { + const left = scope.current.getBoundingClientRect().left + minLeftRef.current = Math.min(minLeftRef.current, left) + } + requestAnimationFrame(track) + } + requestAnimationFrame(track) + + // Start animation: translateX from 0 to 200px over 2s (linear) + animate( + scope.current, + { transform: "translateX(200px)" }, + { duration: 2, ease: "linear" } + ) + + // Start tracking at 500ms — even with a slow resolver (up to + // 200ms), the element will be at (500-200)/2000 * 200 = 30px. + const timer0 = setTimeout(() => { + trackingRef.current = true + }, 500) + + // At 800ms, interrupt with new target. + // Element is at ~60-80px depending on resolver delay. + const timer1 = setTimeout(() => { + animate( + scope.current, + { transform: "translateX(400px)" }, + { duration: 2, ease: "linear" } + ) + }, 800) + + // At 2000ms, report minimum position offset from start + const timer2 = setTimeout(() => { + tracking = false + const minOffset = minLeftRef.current - startLeftRef.current + setResult(String(Math.round(minOffset))) + }, 2000) + + return () => { + tracking = false + clearTimeout(timer0) + clearTimeout(timer1) + clearTimeout(timer2) + } + }, []) + + return ( + <> +
    +
    {result}
    + + ) +} diff --git a/dev/react/src/tests/while-in-view-remount.tsx b/dev/react/src/tests/while-in-view-remount.tsx new file mode 100644 index 0000000000..175797fcb3 --- /dev/null +++ b/dev/react/src/tests/while-in-view-remount.tsx @@ -0,0 +1,63 @@ +import { motion } from "framer-motion" +import { useRef, useState } from "react" + +/** + * Reproduces #3079: whileInView not triggering after soft navigation. + * Tests several scenarios: + * - Element already in viewport on mount + * - Element remounting after unmount (soft navigation) + * - Element with custom root ref + */ +export const App = () => { + const [page, setPage] = useState<"home" | "projects">("home") + + return ( +
    + + {page === "home" && } + {page === "projects" && } +
    + ) +} + +function HomePage() { + return
    Home
    +} + +function ProjectsPage() { + return ( +
    + + +
    + ) +} + +function Card({ id }: { id: string }) { + const scrollRef = useRef(null) + + return ( +
    + { + const el = document.getElementById(id) + if (el) el.dataset.inView = "true" + }} + style={{ width: 100, height: 100, background: "red" }} + /> +
    + ) +} diff --git a/dev/react/vite.config.ts b/dev/react/vite.config.ts index 6843367184..2fe806bcd9 100644 --- a/dev/react/vite.config.ts +++ b/dev/react/vite.config.ts @@ -4,7 +4,7 @@ import { defineConfig } from "vite" // https://vitejs.dev/config/ export default defineConfig({ server: { - port: 9990, + port: parseInt(process.env.TEST_PORT || "9990"), hmr: false, }, plugins: [react()], diff --git a/lerna.json b/lerna.json index 01ade49e7f..4ac61c1539 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.34.3", + "version": "12.38.0", "packages": [ "packages/*", "dev/*" diff --git a/package.json b/package.json index acd10bd2d3..3d4152fe5b 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "react": "^18.3.1", "react-dev-utils": "^12.0.1", "react-dom": "^18.3.1", - "rollup": "4.22.4", + "rollup": "4.59.0", "rollup-plugin-analyzer": "^4.0.0", "rollup-plugin-dts": "6.1.0", "rollup-plugin-preserve-directives": "^0.4.0", diff --git a/packages/framer-motion/README.md b/packages/framer-motion/README.md index da283907b0..ae2f201593 100644 --- a/packages/framer-motion/README.md +++ b/packages/framer-motion/README.md @@ -35,8 +35,7 @@ npm install motion Motion is available for [React](https://motion.dev/docs/react), [JavaScript](https://motion.dev/docs/quick-start) and [Vue](https://motion.dev/docs/vue). -
    -React +### React ```jsx import { motion } from "motion/react" @@ -48,10 +47,9 @@ function Component() { Get started with [Motion for React](https://motion.dev/docs/react). -
    +**Note:** Framer Motion is now Motion. Import from `motion/react` instead of `framer-motion`. -
    -JavaScript +### JS ```javascript import { animate } from "motion" @@ -61,10 +59,7 @@ animate("#box", { x: 100 }) Get started with [JavaScript](https://motion.dev/docs/quick-start). -
    - -
    -Vue +### Vue ```html