plans/020-vanilla-drag-on-shared-projection-tree.md
drag() API in motion-dom on the shared projection treeExecutor instructions: Follow this plan step by step. Run every verification command and confirm the expected result before moving to the next step. If anything in the "STOP conditions" section occurs, stop and report — do not improvise. When done, update the status row for this plan in
plans/README.md— unless a reviewer dispatched you and told you they maintain the index.Drift check (run first):
git diff --stat 42bfbe3ed..HEAD -- packages/motion-dom/src/layout packages/motion-dom/src/gestures packages/motion-dom/src/index.tsThis plan additionally REQUIRES PR #3748 ("Rewrite animateLayout with batched commits on a shared projection tree", branchanimate-layout-batched-commits) to be merged before starting. Verify:git log --oneline -5 -- packages/motion-dom/src/layout/LayoutAnimationBuilder.tsmust show commit498aa804a(or its merge) in history. If not merged, STOP.
42bfbe3ed, 2026-06-11 (branch facts verified against origin/animate-layout-batched-commits @ 87d61a8e7)Vanilla Motion has animate(), press(), hover(), scroll() — but no drag. The drag engine is the most-requested missing piece of framework-agnostic Motion, and after plan 019 it lives in motion-dom with only a thin options seam separating it from vanilla use. PR #3748 supplies the missing substrate: a persistent, shared projection tree over plain DOM (layoutNodes WeakMap + prepareNode() in LayoutAnimationBuilder.ts), so a vanilla draggable gets the same layout measurement, scroll handling, and — critically — the same mid-drag layout-change compensation and animateLayout() composition that React drag gets from React-managed projection nodes. This closes the motion-dom/src/node/types.ts MotionNode gap incrementally: one real gesture shipping on the shared tree, without waiting for the full MotionNode/effects unification arc.
(All paths in packages/motion-dom/src unless noted. Line numbers for LayoutAnimationBuilder.ts refer to the PR #3748 version — re-locate by symbol name, the merged file may differ slightly.)
layout/LayoutAnimationBuilder.ts (post-#3748, ~661 lines) — owns module-private shared-tree machinery this plan extracts:
layoutNodes: WeakMap<Element, IProjectionNode> — the persistent shared tree, keyed by element.getProjectionParent(element) — walks parentElement chain to find the nearest mounted ancestor node in layoutNodes.createVisualElement() — constructs a headless HTMLVisualElement (props: {}, presenceContext: null, empty latestValues, { allowProjection: true }).prepareNode(element, transition) — gets-or-creates the HTMLProjectionNode for an element: reuses visualElementStore VEs, clears untracked inline transforms before first measurement, constructs HTMLProjectionNode(visualElement.latestValues, getProjectionParent(element)), sets options from data-layout/data-layout-id attributes via readNodeOptions(), calls node.mount(element), registers in layoutNodes.dropNode(element, node) — stops animation if owned, unmounts, deletes from layoutNodes.projection/node/create-projection-node.ts:440-450 — node.mount(instance) calls visualElement.mount(instance) when visualElement.current is unset. So headless VEs from prepareNode are mounted: visualElement.getValue(), visualElement.render(), and visualElementStore registration all work on plain DOM. The drag engine's render path (VisualElementDragControls.ts — this.visualElement.render() in onMove, getValue(axis, ...) in getAxisMotionValue) therefore works against these nodes.gestures/drag/VisualElementDragControls.ts (post-019 location) — the engine. Constructor (post-019) is (visualElement, getOptions?) where getOptions: () => MotionNodeOptions overrides reading options from VE props. addListeners() wires pointerdown, ref-constraint measurement + ResizeObservers, window resize rescaling, and projection "didUpdate" origin compensation; returns a cleanup function. It requires visualElement.projection to exist before addListeners() runs (lines using projection!).gestures/drag/DragControls.ts (post-019) — multi-element imperative start/stop/cancel; subscribe(controls) returns unsubscribe.gestures/press/index.ts — the vanilla gesture API shape to match: press(targetOrSelector, handler, options): VoidFunction, using resolveElements (utils/resolve-elements.ts).node/types.ts — MotionNodeOptions declares the drag prop surface the engine reads via getProps(): drag, dragDirectionLock, dragPropagation, dragConstraints: false | Partial<BoundingBox> | { current: Element | null }, dragElastic, dragMomentum, dragTransition, dragSnapToOrigin, dragListener, dragControls, onDragStart, onDrag, onDragEnd, onDirectionLock, onMeasureDragConstraints, onDragTransitionEnd, _dragX, _dragY.dev/html/public/animate-layout/*.html (post-#3748) and dev/html/public/projection/*.html are plain-DOM fixtures run by Cypress config packages/framer-motion/cypress.html.json. Fixtures must not use bare module imports (Vite serves public/ statically — known trap); copy the import pattern of an existing animate-layout fixture exactly. The fixture list for animate-layout specs lives in packages/framer-motion/cypress/fixtures/animate-layout-tests.json.Repo conventions: named exports, interface, small-bundle bias, optional chaining. New public APIs need TSDoc with @public (see press() for tone).
| Purpose | Command (from repo root) | Expected on success |
|---|---|---|
| Build all | yarn build | exit 0 |
| Rebuild motion-dom only (dev loop) | cd packages/motion-dom && yarn build | exit 0 (note: build output may be suppressed — check exit code) |
| motion-dom unit tests | npx jest --config packages/motion-dom/jest.config.json | all pass |
| animateLayout regression (characterization) | run the animate-layout Cypress HTML specs (below) | all pass |
| Lint | yarn lint | exit 0 |
Cypress HTML-fixture runs (plain-DOM tests; foreground only):
PORT=$((10000 + RANDOM % 50000))
cd dev/html && yarn vite --port $PORT &
DEV_PID=$!
npx wait-on http://localhost:$PORT
cd ../../packages/framer-motion && cypress run --headed --config-file=cypress.html.json --config baseUrl=http://localhost:$PORT --spec "cypress/integration-html/<spec>.ts"
kill $DEV_PID
(Verify the exact integration folder name in cypress.html.json — read that file first; the projection/animate-layout specs it references are the pattern to follow.)
packages/framer-motion/cypress.html.json and one existing animate-layout spec + fixture pair end-to-end before writing your own.packages/motion-dom/src/gestures/press/index.ts before designing drag()'s file — match its structure and TSDoc style.In scope (modify/create only these):
packages/motion-dom/src/layout/layout-tree.ts (create — extracted shared-tree module)packages/motion-dom/src/layout/LayoutAnimationBuilder.ts (imports only — consume the extracted module)packages/motion-dom/src/gestures/drag/index.ts (create — the drag() API)packages/motion-dom/src/gestures/drag/types.ts (add DragOptions)packages/motion-dom/src/index.ts (export drag, DragOptions)packages/motion-dom/src/gestures/drag/__tests__/drag-options.test.ts (create)dev/html/public/drag/*.html (create fixtures)packages/framer-motion/cypress/integration-html/ (or wherever cypress.html.json points) — new spec file(s)plans/README.md (status row)Out of scope (do NOT touch):
VisualElementDragControls.ts logic — if the engine needs a change beyond what the getOptions seam provides, STOP and report; don't fork its behavior.packages/framer-motion/src/gestures/drag/index.ts, Reorder) — must be unaffected.whileDrag / variants support for vanilla — explicitly deferred (no variant system on headless VEs).node/types.ts surface beyond what exists) — this plan ships one gesture, not the node API.addValue (no styleEffect wiring here).advisor/020-vanilla-drag off main (after #3748 is in).layout/layout-tree.tsMove from LayoutAnimationBuilder.ts into a new packages/motion-dom/src/layout/layout-tree.ts, exporting (module-internal to the package; do NOT add to the public index in this step):
layoutNodes (the WeakMap)getProjectionParent(element)createVisualElement()dropNode(element, node)prepareNode:export interface LayoutNodeOptions {
layout?: boolean
layoutId?: string
animationType?: "both" | "size" | "position" | "preserve-aspect"
transition?: Transition
}
export function acquireLayoutNode(
element: HTMLElement,
options: LayoutNodeOptions
): IProjectionNode
acquireLayoutNode contains prepareNode's body verbatim except the readNodeOptions(element, transition) call sites: it takes the resolved options directly. LayoutAnimationBuilder.ts keeps readNodeOptions (data-attribute parsing is builder-specific) and reimplements prepareNode as acquireLayoutNode(element, readNodeOptions(element, transition)) plus its existing isPresent/onExitComplete reset lines. Net behavior for animateLayout() must be identical.
Careful with the get-or-create branch: on reuse, prepareNode currently calls node.setOptions(readNodeOptions(...)) — in the extracted version, acquireLayoutNode calls node.setOptions(options) on reuse. Preserve the first-time-only inline-transform clearing and the visualElementStore lookup exactly.
Verify: cd packages/motion-dom && yarn build → exit 0; then run ALL existing animate-layout Cypress HTML specs (the list in cypress/fixtures/animate-layout-tests.json / the specs referencing dev/html/public/animate-layout/) → all pass. If plan 009's characterization tests exist, run them too → all pass.
DragOptions and the options adapterIn packages/motion-dom/src/gestures/drag/types.ts, add:
export interface DragOptions {
/** Drag axis. true = both. @default true */
axis?: boolean | "x" | "y"
/** Constraint area: an Element to measure, or per-edge pixel offsets. */
constraints?: Element | Partial<BoundingBox>
elastic?: DragElastic
momentum?: boolean
transition?: Transition // maps to dragTransition
snapToOrigin?: boolean | "x" | "y"
directionLock?: boolean
propagation?: boolean
distanceThreshold?: number
controls?: DragControls // imperative start/stop/cancel
x?: MotionValue<number> // external output values
y?: MotionValue<number>
onDragStart?: DragHandler
onDrag?: DragHandler
onDragEnd?: DragHandler
onDirectionLock?: (axis: "x" | "y") => void
onMeasureConstraints?: (constraints: BoundingBox) => BoundingBox | void
}
Write a pure mapper in gestures/drag/index.ts:
function mapDragOptions(element: HTMLElement, options: DragOptions): MotionNodeOptions {
const { axis = true, constraints = false, ... } = options
return {
drag: axis,
dragConstraints:
constraints instanceof Element ? { current: constraints } : constraints,
dragElastic: options.elastic,
dragMomentum: options.momentum,
dragTransition: options.transition,
dragSnapToOrigin: options.snapToOrigin,
dragDirectionLock: options.directionLock,
dragPropagation: options.propagation,
onDragStart: ..., onDrag: ..., onDragEnd: ...,
onDirectionLock: ..., onMeasureDragConstraints: options.onMeasureConstraints,
onDragTransitionEnd: ...,
}
}
Only map keys with defined values, so the engine's own defaulting in getProps() (drag-prop defaults at the bottom of VisualElementDragControls.ts) stays the single source of defaults. Note { current: constraints } deliberately satisfies the structural isRefObject check — vanilla element constraints reuse the entire ref-constraints path (measurement, ResizeObservers, window-resize rescaling) with zero engine changes.
Verify: cd packages/motion-dom && yarn build → exit 0 (types compile; drag() not wired yet — keep the mapper exported for the unit test).
drag()In packages/motion-dom/src/gestures/drag/index.ts:
export function drag(
targetOrSelector: ElementOrSelector,
options: DragOptions = {}
): VoidFunction {
const cancelFns: VoidFunction[] = []
for (const element of resolveElements(targetOrSelector)) {
if (!(element instanceof HTMLElement)) continue
// 1. Acquire the element's node from the shared tree.
// Drag-only elements join with no layout/layoutId options;
// elements that also carry data-layout keep whatever options
// a builder set — do not overwrite layout/layoutId here.
const node = acquireLayoutNode(element, {})
const visualElement = node.options.visualElement as HTMLVisualElement
// 2. Register external output values so the VE render pipeline
// is the single writer of element.style.transform.
options.x && visualElement.addValue("x", options.x)
options.y && visualElement.addValue("y", options.y)
// 3. Instantiate the engine with the injected options resolver.
const dragControls = new VisualElementDragControls(
visualElement,
() => mapDragOptions(element, options)
)
const removeListeners = dragControls.addListeners()
const unsubscribe = options.controls?.subscribe(dragControls)
cancelFns.push(() => {
unsubscribe?.()
removeListeners?.()
dragControls.isDragging
? dragControls.cancel()
: dragControls.endPanSession()
})
}
return () => cancelFns.forEach((fn) => fn())
}
Resolve these specifics while implementing (decisions, not options):
acquireLayoutNode(element, {}) on an element already in the tree must NOT clobber existing layout options — check the Step 1 reuse branch: when called with {}, skip setOptions entirely if the node already exists and the options object has no keys. Add that guard in acquireLayoutNode.addListeners() assumes projection exists (it does — step 3.1) and calls projection.updateLayout() if no layout yet; that's the desired eager measure.addValue API: confirm the method name on motion-dom's VisualElement (grep -n "addValue" packages/motion-dom/src/render/VisualElement.ts); if the signature differs, adapt the call, not the engine.drag and DragOptions from packages/motion-dom/src/index.ts. There is no existing export named drag in motion-dom or framer-motion (verified at planning time) — if the build reports a collision, STOP.@public, modeled on press().Verify: yarn build (root) → exit 0; node -e "console.log(typeof require('./packages/motion-dom/dist/cjs/index.js').drag)" → function.
Create packages/motion-dom/src/gestures/drag/__tests__/drag-options.test.ts covering mapDragOptions:
{} → { drag: true, dragConstraints: false } and no other defined keysaxis: "x" → drag: "x"constraints: element → dragConstraints: { current: element } (use document.createElement)constraints: { left: 0, right: 100 } → passed throughonDragStart/onDrag/onDragEnd/onMeasureConstraints arrive under the engine's prop namesModel file structure on an existing motion-dom gesture test (packages/motion-dom/src/gestures/utils/__tests__/is-primary-pointer.test.ts).
Verify: npx jest --config packages/motion-dom/jest.config.json --testPathPattern="drag-options" → all pass.
(Do not attempt full JSDOM drag simulation — JSDOM lacks real layout and getBoundingClientRect returns zeros; pointer-flow coverage belongs in the Cypress HTML fixtures below.)
Create fixtures in dev/html/public/drag/, copying the import/setup pattern of an existing dev/html/public/animate-layout/*.html fixture byte-for-byte (script tag style, no bare imports):
drag-basic.html — a 100×100 box with drag(box). Spec: pointerdown at center, three pointermoves (+100, +50), pointerup; assert mid-gesture transform translates track the pointer (read computed style, single-point .then() assertions, not .should() retries).drag-axis-x.html — drag(box, { axis: "x" }); assert y never moves.drag-constraints-element.html — box inside a larger bordered container, drag(box, { constraints: container }); drag far past the right edge, assert final x clamps to container bounds (allow elastic overshoot mid-gesture, settle after release with momentum: false, elastic: 0 for determinism).drag-momentum.html — flick then assert continued movement after pointerup (model on the React drag-momentum spec's approach).drag-with-animate-layout.html — the composition case this plan exists for: a draggable box and a sibling with data-layout; mid-drag, call animateLayout() mutating the sibling so the draggable's layout shifts; assert the box does not jump relative to the pointer (the projection "didUpdate" origin-compensation path in addListeners).Spec file(s) go where cypress.html.json's integrationFolder/specPattern points; register fixtures the same way the animate-layout specs do (check cypress/fixtures/animate-layout-tests.json for whether a fixture-list JSON is the mechanism, and mirror it).
Verify: run the new spec(s) via the Cypress HTML command block → all pass. Then re-run the animate-layout specs → still all pass (drag-only nodes in the shared tree must not perturb builder behavior).
yarn build && yarn lint, motion-dom jest suite, framer-motion yarn test-client (React drag must be untouched), new + existing HTML Cypress specs.
Verify: all green.
drag-options.test.ts — 5+ cases listed in Step 4.drag.ts Cypress React spec (engine untouched proof).ALL must hold:
yarn build and yarn lint exit 0npx jest --config packages/motion-dom/jest.config.json passes incl. new drag-options testscd packages/framer-motion && yarn test-client passes (pre-existing failures excepted); no React-side source files modified (git status)drag is exported: node -e "console.log(typeof require('./packages/motion-dom/dist/cjs/index.js').drag)" prints functionplans/README.md status row updatedStop and report back (do not improvise) if:
LayoutAnimationBuilder.ts differs structurally from the described prepareNode/layoutNodes shape (re-plan Step 1 against reality).VisualElementDragControls in motion-dom, or no getOptions constructor seam).addListeners throws on a node acquired via acquireLayoutNode, or visualElement.render() doesn't write the transform in fixture #1). Report the exact failure — the fix belongs in a deliberate engine change, not an improvised fork.willUpdate/snapshot timing differs for drag-only nodes — this is a real architectural finding; report it with the failing fixture rather than papering over it with waits.drag surfaces anywhere in the export chain (motion-dom → framer-motion dom.ts → motion).drag() + animateLayout()).drag()'s cancel function intentionally does NOT dropNode — the node stays in the layoutNodes WeakMap (GC'd with the element) so an element that's both draggable and data-layout keeps its tree identity. If a leak is ever suspected, the WeakMap keying makes element GC the backstop; revisit only with evidence.acquireLayoutNode reuse-branch guard (empty options must not clear builder-set layout/layoutId), and the single-writer invariant (only the VE render pipeline writes style.transform; user-supplied x/y values are registered via addValue, never bound separately).whileDrag (needs vanilla variants), dragListener: false equivalent (vanilla users simply don't call drag(); imperative-only start is covered by options.controls + DragControls.start), SVG draggables (engine is typed VisualElement<HTMLElement>), and any styleEffect auto-binding.drag, DragOptions, option names without the drag prefix) should get maintainer sign-off in PR review — flag it explicitly in the PR description.