plans/034-move-inview-to-motion-dom.md
inView() from framer-motion to motion-domExecutor 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/framer-motion/src/render/dom/viewport packages/framer-motion/src/utils/use-in-view.ts packages/framer-motion/src/dom.ts packages/framer-motion/src/motion/features/viewport/index.ts packages/motion-dom/src/index.tsIf any in-scope file changed since this plan was written, compare the "Current state" excerpts against the live code before proceeding; on a mismatch, treat it as a STOP condition. (Note: plan 026 also editsfeatures/viewport/index.ts— if its documented change toonIntersectionUpdatehas landed, that is expected drift; reconcile and continue.)
42bfbe3ed, 2026-06-11inView() is framework-agnostic DOM code (zero React imports) that lives in the React package. Every other vanilla utility — hover, press, resize, scroll, view transitions — has already been migrated to motion-dom (cf. commits ea266671d "Move animateVisualElement and dependencies to motion-dom", 9920cfc6a "Refactor MotionProps into vanilla package"); inView is the last straggler. Costs today: standalone motion-dom consumers have no inView, and the repo carries two parallel IntersectionObserver wrappers with a duplicated threshold table. This plan moves the function verbatim, keeps every existing public import path working via the established re-export chain, dedupes the threshold table, and gives the function its first unit tests. It does not merge the two observer implementations (see Out of scope).
packages/framer-motion/src/render/dom/viewport/index.ts — the entire standalone inView implementation (68 lines): inView(), InViewOptions, ViewChangeHandler, private MarginType/MarginValue types, and a private threshold table. Its only externally-sourced import is already from motion-dom:// packages/framer-motion/src/render/dom/viewport/index.ts:1
import { ElementOrSelector, resolveElements } from "motion-dom"
// packages/framer-motion/src/render/dom/viewport/index.ts:18-21
const thresholds = {
some: 0,
all: 1,
}
packages/framer-motion/src/dom.ts:8 — export { inView } from "./render/dom/viewport" (public API). Note dom.ts:1 is already export * from "motion-dom", and framer-motion/src/index.ts:12 is export * from "./dom" — so once motion-dom exports inView, the star chain re-publishes it on framer-motion, framer-motion/dom, motion, and motion/react with no further work.packages/framer-motion/src/utils/use-in-view.ts:4 — import { inView, InViewOptions } from "../render/dom/viewport".// packages/framer-motion/src/motion/features/viewport/index.ts:5-8
const thresholdNames = {
some: 0,
all: 1,
}
export *-per-module, grouped/ordered by path — the resize precedent:// packages/motion-dom/src/index.ts:87-99 (abridged)
export * from "./gestures/utils/is-primary-pointer"
export * from "./node/types"
...
export * from "./resize"
resolveElements lives at packages/motion-dom/src/utils/resolve-elements.ts — the moved file imports it relatively (see packages/motion-dom/src/gestures/hover.ts:1 for the convention).inView, InViewOptions, or ViewChangeHandler (the src/view/ directory is view transitions; no overlap).packages/framer-motion/src/utils/__tests__/mock-intersection-observer.ts (installs window.IntersectionObserver, exposes getActiveObserver() returning the last observed callback). motion-dom tests run via ts-jest against source (packages/motion-dom/jest.config.json, jsdom) — no build needed for them. framer-motion tests resolve motion-dom to its built dist — yarn build from repo root is required after motion-dom edits and before framer-motion tests.check-bundle.js size gate; this move shifts ~0.3 kB from framer-motion to motion-dom and the gated entry bundles (size-rollup-*) don't gain anything new, so the gates should be unaffected — if one trips, that's a STOP condition, not a threshold to edit.| Purpose | Command | Expected on success |
|---|---|---|
| Build all (repo root; REQUIRED after motion-dom edits, runs both size gates) | yarn build | exit 0 |
| New motion-dom tests | npx jest --config packages/motion-dom/jest.config.json --testPathPattern="in-view" (repo root) | all pass |
| Hook + feature tests | `npx jest --config packages/framer-motion/jest.config.json --testPathPattern="(use-in-view | viewport)"` |
| SSR safety | cd packages/framer-motion && yarn test-server | no NEW failures (pre-existing TextEncoder is not defined failures are known — compare against a pre-change run) |
| Lint | yarn lint (repo root) | exit 0 |
In scope (the only files you should modify/create/delete):
packages/motion-dom/src/in-view/index.ts (create)packages/motion-dom/src/in-view/__tests__/index.test.ts (create)packages/motion-dom/src/in-view/__tests__/mock-intersection-observer.ts (create — copy of the framer-motion mock)packages/motion-dom/src/index.ts (one export line)packages/framer-motion/src/render/dom/viewport/index.ts (delete, and its directory if then empty)packages/framer-motion/src/dom.ts (remove one line)packages/framer-motion/src/utils/use-in-view.ts (one import line)packages/framer-motion/src/motion/features/viewport/index.ts (threshold dedupe only)Out of scope (do NOT touch, even though they look related):
features/viewport/observers.ts shares observers per root+options and fires per-element callbacks; standalone inView creates one observer per call with unobserve-on-no-return semantics. Unifying them changes observable behavior (observer instance lifetimes, initial-fire timing) — that's a separate, behavior-risk plan if ever wanted. Keep observers.ts exactly as is.packages/framer-motion/src/utils/use-in-view.ts beyond the import line — the hook's logic is correct.packages/framer-motion/src/index.ts and packages/motion/src/* — the star chains make changes there unnecessary; touching them risks the public surface.bundlesize threshold in either package.json.refactor/move-inview-to-motion-domgit log, cf. ea266671d): refactor: move inView to motion-domCreate packages/motion-dom/src/in-view/index.ts with the full contents of packages/framer-motion/src/render/dom/viewport/index.ts, with exactly two changes:
import {
ElementOrSelector,
resolveElements,
} from "../utils/resolve-elements"
export const inViewThresholds = {
some: 0,
all: 1,
}
inView() (thresholds[amount]) becomes inViewThresholds[amount].Everything else — ViewChangeHandler, MarginType/MarginValue (still unexported), InViewOptions, the inView body — moves character-for-character. Do not "improve" anything in transit.
Then add to packages/motion-dom/src/index.ts, between the gestures block and export * from "./node/types" (path-alphabetical position):
export * from "./in-view"
Verify: yarn build → exit 0 (motion-dom compiles and its size gate passes; framer-motion still builds — old file still present and unreferenced changes pending).
Copy packages/framer-motion/src/utils/__tests__/mock-intersection-observer.ts verbatim into packages/motion-dom/src/in-view/__tests__/mock-intersection-observer.ts (cross-package test imports aren't allowed; the file is 32 lines). Create packages/motion-dom/src/in-view/__tests__/index.test.ts importing inView from "../index" and the mock from "./mock-intersection-observer" (the mock installs on import). Cover:
inView(el, onStart); trigger getActiveObserver()?.([{ target: el, isIntersecting: true }]) → onStart called once with (el, entry).onStart returns onEnd; trigger leave → onEnd called once; trigger enter again → onStart called twice (re-arms).onStart returns undefined; after enter, the mock's active observer is cleared by unobserve (assert getActiveObserver() is undefined, matching the mock's unobserve behavior) and a further enter triggers nothing.isIntersecting: true entries → onStart once (the Boolean(onEnd) guard).getActiveObserver() is undefined.inView(".target", onStart) with two matching divs appended to document.body → both observed (extend the mock minimally with an observed-elements Set if needed to assert this — keep the accessor pattern).Model file structure on packages/framer-motion/src/utils/__tests__/use-in-view.test.tsx's use of the mock (the enter/leave helper pattern at its top).
Verify: npx jest --config packages/motion-dom/jest.config.json --testPathPattern="in-view" → ≥6 tests pass.
packages/framer-motion/src/utils/use-in-view.ts:4 → import { inView, InViewOptions } from "motion-dom".packages/framer-motion/src/dom.ts → delete line 8 (export { inView } from "./render/dom/viewport"); the export * from "motion-dom" on line 1 now supplies it.packages/framer-motion/src/render/dom/viewport/index.ts; if the viewport/ directory is then empty, delete the directory.Verify: grep -rn "render/dom/viewport" packages/framer-motion/src packages/motion/src → no matches. Then yarn build → exit 0 (this also proves declaration emit for UseInViewOptions extends Omit<InViewOptions, ...> resolves against the motion-dom types).
Verify (from repo root, after Step 3's build):
node -e "const m = require('./packages/framer-motion/dist/cjs/index.js'); if (typeof m.inView !== 'function') throw new Error('inView missing from framer-motion')" → exits 0. (If the CJS dist filename differs, check packages/framer-motion/package.json main and adjust the path — do not skip the check.)node -e "const m = require('./packages/motion-dom/dist/cjs/index.js'); if (typeof m.inView !== 'function') throw new Error('inView missing from motion-dom')" → exits 0 (same caveat via motion-dom's main).grep -n "inView" packages/framer-motion/dist/index.d.ts (or the emitted types entry per types in package.json) → inView present.In packages/framer-motion/src/motion/features/viewport/index.ts: delete the local thresholdNames const (lines 5–8), import inViewThresholds from "motion-dom" (extend the existing import { Feature } from "motion-dom" line), and change the one usage (thresholdNames[amount] → inViewThresholds[amount]).
Verify: yarn build → exit 0. npx jest --config packages/framer-motion/jest.config.json --testPathPattern="(use-in-view|viewport)" → all pass.
Verify: yarn lint → exit 0. cd packages/framer-motion && yarn test-server → no new failures vs. a pre-change baseline run (record both counts in your report). The Cypress specs while-in-view.ts / while-in-view-remount.ts cover the feature path in CI; the feature's only change is the threshold import, so running them locally is optional — if you do, follow the React 18 + React 19 Vite procedure in CLAUDE.md.
Step 2 is the new coverage (the standalone inView had zero tests before this plan — these double as the move's behavior lock). Existing use-in-view.test.tsx exercises the moved function through the hook against built motion-dom — it passing post-build is the integration gate. No new Cypress spec: observer behavior is fully mockable in jsdom and the browser path is already covered by the two while-in-view specs.
Machine-checkable. ALL must hold:
yarn build exits 0 (both packages' size gates included)in-view tests: ≥6 tests exist and pass(use-in-view|viewport) tests passgrep -rn "render/dom/viewport" packages/ (excluding dist/, node_modules/, plans/) → no matchesnode -e export checks exit 0grep -c "some: 0" packages/framer-motion/src/motion/features/viewport/index.ts → 0 (table deduped)git status shows no modified files outside the in-scope listplans/README.md status row updated (and the "Move inView()" deferred bullet already points here)Stop and report back (do not improvise) if:
render/dom/viewport exists beyond the two listed (the grep in Step 3 finds more) — the consumer map has drifted.yarn build fails in declaration emit after Step 3 (e.g. TS4023-style "cannot be named" on UseInViewOptions) — report the exact error; do not restructure types to silence it.check-bundle.js) fails — do not edit thresholds; report the delta.inView/InViewOptions/ViewChangeHandler/inViewThresholds in motion-dom's export * chain.observers.ts or change inView's observable behavior — both are explicitly out of scope.InViewOptions, ViewChangeHandler, and inViewThresholds become exported from motion-dom and therefore from framer-motion/motion via the star chains (previously only the inView function was public). This is additive; the reviewer should just be aware the d.ts surface grows by three names.framer-motion/dist/es/render/dom/viewport/... (unsupported but possible) breaks; supported specifiers (framer-motion, framer-motion/dom, motion, motion/react, motion-dom) are unchanged.inView vs. features/viewport/observers.ts) is now at least co-located by name; a future consolidation plan would start from observers.ts's shared-observer model and must treat inView's per-call observer + unobserve-on-no-return semantics as public behavior.viewport.once) edits features/viewport/index.ts in the onIntersectionUpdate body; this plan edits the top-of-file const + import. Whichever lands second: trivial rebase, re-run the viewport jest pattern.