Skip to content

Prototype: vanilla drag() API shared with React drag - #3932

Draft
mattgperry wants to merge 5 commits into
mainfrom
claude/vanilla-drag-35pso8
Draft

mattgperry wants to merge 5 commits into
mainfrom
claude/vanilla-drag-35pso8

Conversation

@mattgperry

@mattgperry mattgperry commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Requested by Matt · project thread

Before: drag only works through React motion components. The drag logic (VisualElementDragControls, PanSession, constraint maths) lives in framer-motion.

After: import { drag } from "motion" makes any element draggable without React:

const stop = drag("#box", {
  axis: "x",
  constraints: document.getElementById("container"), // or { left: 0, right: 300 }
  elastic: 0.2,
  onStart, onMove, onEnd,
})

React drag props run through the same controller, which now lives in motion-dom.

How

  • Moved VisualElementDragControls, PanSession, drag/utils/constraints, addPointerEvent, event-info and distance from framer-motion into motion-dom (git renames, with import changes only). framer-motion's drag and pan features import them from motion-dom.
  • The controller takes an optional readProps getter. React keeps the default (visualElement.getProps()), and drag() passes its options mapped to prop names (axis → drag, constraints → dragConstraints, onMove → onDrag and so on).
  • drag() gets a projection node from the new acquireProjectionNode() in LayoutAnimationBuilder. It reuses a mounted node that a motion component or animateLayout() already owns, or creates one in the shared imperative tree and drops it on cleanup. It sets alwaysMeasureLayout, the same as React does for draggable elements.
  • drag() sets the same element styles as the React drag prop (touch-action, user-select, -webkit-touch-callout, draggable="false"), and puts back the previous values on cleanup.
  • dragConstraints now also takes an Element as well as a ref, for both APIs.
  • Removed @internal tags from PanSession and the controller's stop/cancel/endPanSession, because the d.ts build strips them and framer-motion now imports them across the package boundary.

Hand-off fix (handOffElementState)

Before, calling animate(el, { x }) before drag() made the element jump back to its origin on release. An instant animate() before drag() also never rendered. The cause was in the hand-off from the style effect to a VisualElement, which animateLayout() also uses. The values moved to the VisualElement, but two links to the emptied effect state stayed:

  • Each value's owner was still the effect subject, so transform animations started on it later read from the empty state.
  • The element's transform group still read through the effect state, and when it finished it committed transform: none.

Now the hand-off sets each value's owner to the VisualElement. It also moves any transform group to the main thread and detaches it, so later animations form a new group. A detached group no longer removes a newer group from transformGroups when it finishes.

Tests

Vanilla drag() is tested to the same level as motion.div drag:

  • Jest (motion-dom, 66 tests): every test in the React drag suite has a vanilla port, except React-only features. The ports cover callbacks, willChange, momentum, direction lock, nested propagation, all four constraint edges, transitions, pointer-down stopping momentum, cleanup during a gesture, keyboard-accessible elements, and interop with animate() and animateLayout().
  • Playwright (Chromium, 78 tests): ports the Cypress drag specs, covering momentum, element constraints (including resize and scroll cases), scrolling during a drag, release before a frame, nested draggables, SVG, child inputs, touch input, and interop with animate().
  • Hand-off regression tests: the two animate-before-drag tests in drag-basic.spec.ts failed before the hand-off fix and pass after it.
  • Not ported (React-only): whileDrag, dragControls/snapToCursor, dragListener, AnimatePresence, layout/layoutId, Reorder.
  • Marked test.fail: scaled or rotated parents, and SVG with a scaled viewBox. Vanilla has no transformPagePoint option yet.
  • Other suites: motion-dom Jest passes (897). The Playwright animate, animate-layout, gestures and effects suites pass (201), including the independent-transforms hand-off specs.
  • framer-motion client Jest: 920 passed. One press.test.tsx test failed once in the full run and passed in 3 runs on its own. This PR does not touch press.
  • I did not run Cypress, because its binary cannot install in the cloud container.

Size

  • motion-div and animate stay within budget.
  • New drag scenario: about 36 kB gz, because vanilla drag() brings in VisualElement, the projection tree and the animation stack. Budgets went up for vanilla-all and react-all, because both export drag.
  • For comparison, the same rollup setup measures gsap core + Draggable at 39.3 kB gz, and 41.9 kB gz with InertiaPlugin.
  • A lighter vanilla path is possible as a follow-up: measure with getBoundingClientRect, render through styleEffect, and skip projection.

Open questions

  • Should drag() return controls (start(event, { snapToCursor })) and not only a cleanup function? Should it take a transformPagePoint option?
  • whileDrag has no vanilla equivalent yet.

🤖 Generated with Claude Code

https://claude.ai/code/session_015exBWSHQZDwn3zvCFJV5Sh

Moves the drag controller, PanSession, constraint utils and pointer
event helpers from framer-motion into motion-dom. The React drag
feature now uses the shared controller from motion-dom.

Adds drag(elementOrSelector, options) to motion-dom. It reuses or
creates a VisualElement and projection node for the element, then
runs the same controller with options mapped from the vanilla names.
dragConstraints also accepts an element, as well as a ref.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015exBWSHQZDwn3zvCFJV5Sh
@mattgperry mattgperry self-assigned this Oct 15, 2026
Ports the React drag Jest suite and the Cypress drag specs to vanilla
drag(): 66 Jest tests in motion-dom and 78 Playwright tests. Behaviours
that only exist in React (whileDrag, dragControls, AnimatePresence,
layout props, Reorder) are left out.

Fixes found by the new tests:
- drag() now sets touch-action, user-select and draggable="false" like
  the React drag prop, so touch input drags instead of scrolling.
- acquireProjectionNode only reuses a mounted node, so calling drag()
  again after stop() gets a live projection node.

Parent transforms and scaled SVG viewBoxes are marked test.fail, as
vanilla drag() has no transformPagePoint option yet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015exBWSHQZDwn3zvCFJV5Sh
handOffElementState gave the style effect's values to the new
VisualElement, but left two links to the emptied effect state:

- Each value's owner was still the effect subject, so transform
  animations started on it later read values from the empty state.
- The element's transform group still read through the effect state.
  When it finished, it committed transform: none.

Now the hand-off sets each value's owner to the VisualElement. It also
moves any transform group to the main thread and detaches it, so later
animations form a new group. A detached group no longer removes a newer
group when it finishes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015exBWSHQZDwn3zvCFJV5Sh

Copy link
Copy Markdown
Collaborator Author

ci/circleci: test failed on 2c21437. I cannot read the CircleCI log from my environment, because the network policy blocks circleci.com. I also cannot re-run the job.

I could not reproduce the failure locally. I ran the same command as the job (make test-jest, which runs yarn test) on a clean tree. It passes with both Node 20.20 (CI uses 20.11) and Node 22:

  • motion-utils: 37
  • motion-dom: 897
  • framer-motion SSR: 51
  • framer-motion client: 921
  • motion: 76

I also ran the new drag suites 6 times in parallel under load, and they passed every time. measure and test-html are green on this commit, and the test job is green on other open PRs. So the failure is probably specific to this PR, or it is a flake. I need the failing test name from the CircleCI log to fix it.


Generated by Claude Code

Port of the fix from #3934. The test expected exactly 5 updates in
5 frames, which fails on CI when a slow frame produces no change. It
now checks that no frame gets more than one update, which is the
orphaned-animation case the test exists to catch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015exBWSHQZDwn3zvCFJV5Sh

Copy link
Copy Markdown
Collaborator Author

Probable cause of the ci/circleci: test failure: motion-dom value/__tests__/spring-value.test.ts › "re-attaching mid-animation runs a single animation". That test is a known CI flake: it expects exactly 5 updates in 5 frames, and a slow frame gives 4. This PR does not touch that code. I ported the fix from #3934 in 5338d3e. The test now checks that no frame gets more than one update. I still could not confirm the cause from the CircleCI log.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

ci/circleci: test failed again on 5338d3e, which includes the spring-value fix, and again after about 1.5 minutes. So the cause is probably not that flake. I still cannot read circleci.com from my environment. I reproduced the job locally from a fresh yarn build with CI=true yarn test, using Node 20.11.1 (the CI version), and every package passed: motion-utils 37, motion-dom 897, framer-motion 51 + 921, motion 76. I need the failing test name or error from the job log to fix it.


Generated by Claude Code

Start the animation inside frame.update, so the WAAPI build samples from
the same timestamp as the start. On a slow runner the build happened
tens of ms after the start, shifting every sample.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U63godu6kWaz7rM1GKkvWJ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants