diff --git a/dev/html/public/benchmarks/FINDINGS.md b/dev/html/public/benchmarks/FINDINGS.md
new file mode 100644
index 0000000000..54a0688d5e
--- /dev/null
+++ b/dev/html/public/benchmarks/FINDINGS.md
@@ -0,0 +1,239 @@
+# Renderer & Style Invalidation Findings
+
+Results from the renderer investigation (Aug 2026): can a new write mechanism
+avoid the "massive style recalculation" failure mode of per-frame
+`element.style` writes, and what should Motion's renderer(s) be?
+
+Benchmark pages (serve `dev/html` on port 8000):
+
+- `style-recalc-harness.html` — which environments make style writes expensive,
+ across write mechanisms (inline, CSS vars, registered vars, adopted
+ stylesheet). Supports `&flush` for vsync-independent style-cost timing.
+- `renderer-test-1.html` — granularity: 5 paint values on 300 boxes, then 1.
+- `renderer-test-2.html` — the same 5 values in a hostile invalidation
+ environment (`[style*=]` attribute selectors + descendant subtrees).
+- `renderer-test-3.html` — transforms in the hostile environment:
+ inline vs var vs stylesheet, with/without `will-change`.
+
+Protocol everywhere: 4 runs per condition without reload, first run discarded
+(JIT warmup), results averaged over runs 2–4.
+
+## What triggers the recalculation bomb
+
+Per-frame inline writes of **non-inherited standard properties are cheap by
+default** — Blink/WebKit invalidation is precise. The bomb requires one of
+these subscriptions (flush-timed style cost, 100 boxes × 50 descendants,
+5 props/frame, embedded Chromium):
+
+| Environment | Inline writes | Sheet writes | Bomb? |
+| --- | --- | --- | --- |
+| plain | 0.36 ms/frame | — | no |
+| 50 descendants/box (non-inherited props) | 0.47 | 1.09 | no |
+| `:has()` keyed on classes | 0.30 | — | no |
+| 20k-rule stylesheet (complex selectors, no `[style]`) | 1.08 | 1.53 | no |
+| `[style*="…"]` selectors (1k rules) | **28.5** | **1.04** | yes |
+| inherited property (`color`) + descendants | **27.8 ms frame** | 16.67 (fast path) | yes |
+| unregistered `--var` + descendants (even unreferenced) | ~4 ms/frame hidden | — | at scale |
+| `@container style(--p)` subscriptions in descendants | **22–28 ms frame** | **same** | yes, unavoidable |
+
+Notes:
+
+- `[style*=]` selectors are rare in hand-written CSS (mostly adblock cosmetic
+ filters) but the other triggers are mainstream: animating `color` on an
+ element with children, animating CSS variables, `@container style()` queries.
+- `CSS.registerProperty({ inherits: false })` suppresses the var-inheritance
+ fan-out (~5.7× cheaper) but does **not** help with `[style*=]` (the
+ attribute still mutates) or `@container style()` (subscription is on the
+ value).
+- `@container style()` bombs **every** write mechanism, including adopted
+ stylesheets and (by construction) WAAPI — the subscription is to the
+ computed value. Document as a user-facing footgun; no renderer fixes it.
+- `transition: all` vs named transitions: the `all` keyword itself is ~free;
+ the cost is transition retargeting for any rule covering a JS-animated
+ property (~few ms/frame at 500 transitions). Amplifier, not a bomb.
+- 16.67 ms readings are the vsync floor. Use `&flush` to measure real style
+ cost when under budget.
+
+## Renderer comparison
+
+### Test 1 — benign page (300 boxes, 5 paint props)
+
+Chrome & Safari: **all renderers identical** (paint-bound or vsync floor).
+legacy ≈ styleEffect ≈ varEffect ≈ GSAP ≈ WAAPI in frame time.
+Granularity shows only in render JS/frame (phase B, 1 of 5 values animating):
+legacy 0.36–0.47 ms, styleEffect 0.06–0.10 ms (≈5×), varEffect 0.22–0.26 ms.
+
+### Test 2 — hostile page
+
+Real Chrome, 300 boxes × 80 descendants, 2k `[style*=]` rules:
+
+| Renderer | fps | mean frame |
+| --- | --- | --- |
+| WAAPI (element.animate) | **30.6** | **33 ms** |
+| CSS transition | 26.3 | 38 ms |
+| sheet (adopted stylesheet) | 26.4 | 40 ms |
+| GSAP (inline writes) | 4.0 | 250 ms |
+| styleEffect | 3.9 | 259 ms |
+| legacy (re-apply all) | 3.7 | 267 ms |
+| varEffect (registered vars, inline) | 2.8 | 354 ms |
+
+Real Safari, same scale:
+
+| Renderer | fps | mean frame |
+| --- | --- | --- |
+| CSS transition | 3.9 | 256 ms |
+| WAAPI | **3.9** | **259 ms** |
+| GSAP | 2.9 | 351 ms |
+| styleEffect | 2.7 | 393 ms |
+| legacy | 2.6 | 398 ms |
+| sheet | 2.4 | **411 ms — no escape in WebKit** |
+| varEffect | 1.9 | 588 ms |
+
+Key engine difference: Blink has a scoped fast path for CSSOM rule-declaration
+mutation (sheet ≈ WAAPI); WebKit does not (sheet ≈ inline). WAAPI/transitions
+are the only mechanisms that win or tie in **both** engines.
+
+### Test 3 — transforms in hostile env (embedded Chromium, 300 boxes)
+
+`will-change` is useless while recalc-bound (style/var ≈50 ms with or without).
+Escape recalc first (sheet ~33 ms), then `will-change` removes paint →
+locked 60 fps (16.67 ms). Order of operations matters: attribute-invalidation
+→ paint → compositing.
+
+## Renderer decision
+
+1. **Default to WAAPI for everything keyframeable** — including
+ non-accelerated paint properties. Only strategy that never loses in either
+ engine. Zero main-thread render JS (0.000 in every run). Springs already
+ pregenerate keyframes. Measure retargeting cost (computed-style read forces
+ a flush) before shipping.
+2. **styleEffect (inline writes) remains the frame-driven fallback**
+ (gestures, scroll, useTransform). Inline is ~2× *cheaper* than sheet
+ writes in benign environments (0.47 vs 1.09 ms flush) — the common case.
+3. **No sheet renderer.** Its only value was capping the hostile case, and it
+ only does so in Blink. Not worth cross-engine complexity, cascade weirdness
+ (rule loses to inline styles), and DevTools opacity.
+4. **varEffect (registered vars written inline) is dead.** Worst renderer in
+ both engines: pays attribute invalidation + var indirection.
+ `CSS.registerProperty({ inherits: false })` remains useful for
+ Motion-internal generated variables only — never re-register user vars
+ (changes inheritance semantics).
+
+## GSAP comparison (defensible claims)
+
+- Clean pages: parity (~27 ms both, Chrome; both 60 fps, Safari).
+- Hostile CSS, Chrome: **7.5× faster frames** (33 vs 250 ms) — structural:
+ GSAP must mutate the style attribute from its ticker every frame.
+- Hostile CSS, Safari: 1.4× (259 vs 351 ms).
+- Moderate hostile scale: 1.4–2×.
+- Structural claims: zero main-thread animation JS; compositor animations
+ survive main-thread jank (categorical, not a multiplier).
+- Motion's write-path JS is ~0.3% of frame time under load in Chrome
+ (0.72–0.79 ms of ~260 ms). Caveat: WebKit charges invalidation to the
+ setter (~7 ms/frame at 1,500 values). Frame cost is browser style/paint
+ work triggered by writes — the leverage is the write mechanism, not JS.
+
+## Layout projection performance (Aug 2026 follow-up)
+
+Profiled `dev/react?example=layout-stress-transform` (1,513 projection
+nodes, 1,008 animating) with a V8 sampling profiler via Playwright/CDP
+(`dev/react/profile-layout.mjs`) and an interleaved A/B harness that
+alternates baseline/optimized builds per boot (fresh Vite server per
+build, two profile runs per boot, take the minimum).
+
+### What the "projection JS" actually is
+
+- Skipping only the `style.transform = ...` write dropped total sampled JS
+ from ~200 ms to ~94 ms per 2 s window — **the CSSOM setter (browser
+ string parse + style invalidation, charged to JS) is over half of all
+ "projection JS"**. This is the floor; no JS restructuring touches it.
+- **CSS Typed OM is slower**, not faster: retained
+ `CSSTransformValue` + `attributeStyleMap.set` measured 291 ms vs 199 ms
+ total (Blink's Typed OM set path does spec-mandated normalization).
+- **Individual `translate`/`scale` properties are slower too** (two setter
+ calls: 80.6 ms vs 56.4 ms self-time in `applyProjectionStyles`).
+- **`matrix()` serialization is slower** (~85 vs ~62 ms self-time): it
+ always carries six full-precision floats, while the composed
+ `translate3d`/`scale` string omits identity segments and collapses to
+ `"none"` near rest. Parse cost tracks string content, not function
+ count. `DOMMatrix` has no other route into a style — `.toString()` is
+ this same string plus allocation, and `CSSMatrixComponent` rides the
+ Typed OM set path already measured slower.
+- **Rounding transform values corrupts projection**: rounding
+ translate/scale in `buildProjectionTransform` broke measure/unproject
+ round-trips (measurements happen with the rounded transform in the DOM
+ but are unprojected with exact values), leaving sub-pixel residuals that
+ failed 22 Cypress layout tests. Reverted — don't retry.
+
+### Shipped optimizations (all layout e2e + unit tests green, React 18+19)
+
+1. **Memoized projection style writes**: `applyProjectionStyles` caches the
+ last rendered transform/transformOrigin/opacity/visibility and skips
+ redundant CSSOM writes.
+2. **Projection-only renders**: per-frame projection renders no longer
+ re-write the element's full style set; full renders only when values
+ change. `renderStyles` skips transform/origin when projection owns them.
+3. **Root render sweep**: nodes set a flag and the projection root
+ schedules one frame callback that sweeps the tree, instead of ~1,000
+ `frame.render` schedulings per frame (`schedule` self-time 19.6 ms → 0).
+4. Hoisted `isDisplayContents` to `setOptions`, delta-reuse in
+ `applyTransformsToTarget` when no user transforms, fused
+ `mixBoxInto` (mix + equality + copy in one pass), keepAlive fast path in
+ the frameloop, indexed loops in FlatTree/scale correctors.
+5. On animation complete, a full render restores scale-corrected values
+ (borderRadius etc.) — required by the sweep change.
+6. **Cumulative path transforms**: each ancestor's projection delta is an
+ axis-aligned affine map (p → a·p + b), so a node's full ancestor
+ correction composes in closed form from its parent's cached transform
+ (`updatePathTransform`, stamped per updateProjection sweep). Replaces
+ the per-node walk over the whole ancestor path (`applyTreeDeltas`) —
+ O(nodes × depth) → O(nodes). At depth 30 (1,201 nodes,
+ `layout-stress-deep`): 31.1 ms → 9.2 ms for the path work, calc-side
+ total ~37.5 → ~21 ms per 2 s. Break-even at shallow depth (~5), scales
+ with depth. Shared transitions (layoutId/resumingFrom) keep the legacy
+ walk: they interleave scroll offsets and ancestor `latestValues`
+ transforms whose origins depend on the box being projected, so they
+ don't compose into one per-layer map. Using `DOMMatrix` for this math
+ instead of plain records was considered and rejected: identical idea,
+ but every op crosses a C++ binding and `multiply()` allocates; the
+ specialized {a, b, scale} record is the same matrix without the
+ overhead (and the box {min,max} format itself was never the cost).
+
+### Results (interleaved A/B, median of per-boot minimums)
+
+- Whole-animation window: total sampled JS **−16%** (368 → 309 ms),
+ projection-attributed **−14.5%** (217 → 186 ms). Mid-animation windows:
+ −15–23% across three independent A/B rounds.
+- Excluding the irreducible CSSOM write, the pure JS computation reduced
+ ~25–30%. The original ≥50% target is not reachable by JS restructuring:
+ ~60% of what profilers attribute to projection functions is the browser's
+ style write cost, and both alternative write mechanisms measured slower.
+- Remaining JS hotspots (per 2 s, optimized): `applyProjectionStyles`
+ ~10 ms ex-write, `mixTargetDelta` 13 ms, `resolveTargetDelta` +
+ `calcProjection` ~22 ms, `JSAnimation.tick` 10 ms. Halving further means
+ fewer nodes doing per-frame math (e.g. WAAPI-driven pregenerated
+ projection keyframes) — an architectural project, not an optimization.
+
+### Layout measurement pitfalls
+
+- Single-run profile comparisons swing ±40% with thermals; only the
+ interleaved A/B (alternating builds per boot, min of repeated runs)
+ produced stable deltas.
+- Window placement matters: mid-animation windows exclude the ease tail
+ where write-memoization wins; whole-animation windows include the
+ unoptimized React didUpdate burst. Report which one you measured.
+
+## Measurement pitfalls (repeat offenders)
+
+- **Vsync floor**: 16.67 ms means "under budget", not "equal". Use the
+ harness `&flush` metric or CDP `Performance.getMetrics`
+ (RecalcStyleDuration deltas) to unclamp.
+- **rAF throttling**: background/occluded tabs throttle rAF to ~1–2 fps.
+ Force `Page.setWebLifecycleState { state: "active" }` via CDP, and verify
+ tick rate before trusting a run.
+- **Tween semantics**: `gsap.to()` from current values created zero-change
+ tweens in alternating-phase benchmarks — use explicit endpoints
+ (`fromTo`/keyframes) everywhere or fast runs are fake.
+- **Embedded Chromium inflates paint cost** (software raster). Relative
+ rankings held up in real browsers; absolute numbers did not.
+- LoAF only reports frames ≥50 ms — useless for sub-budget costs.
diff --git a/dev/html/public/benchmarks/renderer-bench-lib.js b/dev/html/public/benchmarks/renderer-bench-lib.js
new file mode 100644
index 0000000000..90b38fb54d
--- /dev/null
+++ b/dev/html/public/benchmarks/renderer-bench-lib.js
@@ -0,0 +1,584 @@
+/**
+ * Shared library for the renderer benchmark pages
+ * (renderer-test-1/2/3.html).
+ *
+ * Provides:
+ * - Renderer strategies: legacy (old VisualElement full re-apply),
+ * style (styleEffect), var (registered CSS properties),
+ * sheet (experimental: adopted stylesheet rule writes - never touches
+ * the element's style attribute after setup).
+ * - A phase runner implementing the protocol: 4 runs without reload,
+ * first run discarded as JIT/warmup.
+ * - Frame metering: wallclock frame stats + time spent in Motion's
+ * render step (the actual style write cost).
+ */
+
+export const PAINT_PROPS = {
+ backgroundColor: ["rgb(255, 40, 40)", "rgb(40, 40, 255)"],
+ borderColor: ["rgb(40, 255, 40)", "rgb(255, 40, 255)"],
+ borderRadius: ["4px", "20px"],
+ boxShadow: ["0 0 2px rgb(255, 40, 40)", "0 0 14px rgb(40, 40, 255)"],
+ outlineColor: ["rgb(20, 20, 20)", "rgb(255, 255, 40)"],
+}
+
+export function buildBoxes(container, n, descendants = 0) {
+ const parts = []
+ let inner = ""
+ for (let j = 0; j < descendants; j++) {
+ inner += `x`
+ }
+ for (let i = 0; i < n; i++) {
+ parts.push(`
${inner}
`)
+ }
+ container.innerHTML = parts.join("")
+ return Array.from(container.querySelectorAll(".box"))
+}
+
+/** ------------------------------------------------------------------ */
+/** Renderer strategies */
+/** ------------------------------------------------------------------ */
+
+export function createStrategies(Motion) {
+ const { motionValue, styleEffect, frame, cancelFrame } = Motion
+
+ /**
+ * Registered-custom-property renderer. Uses Motion's varEffect when
+ * available (experimental branches); otherwise falls back to a
+ * standalone implementation of the same mechanism: register a
+ * per-value custom property with inherits: false, point the real
+ * style at var(--name) once, then write only the custom property
+ * per frame.
+ */
+ const TRANSFORM_KEYS = new Set(["x", "y", "scale", "rotate"])
+ let benchVarId = 0
+ const registerVar = () => {
+ const name = `--bench-var-${benchVarId++}`
+ try {
+ CSS.registerProperty({ name, syntax: "*", inherits: false })
+ } catch {}
+ return name
+ }
+ const camelToDash = (key) => key.replace(/[A-Z]/g, "-$&").toLowerCase()
+
+ const varEffect =
+ Motion.varEffect ||
+ ((element, values) => {
+ const unsubs = []
+ const renders = []
+ const transformKeys = []
+
+ for (const key in values) {
+ if (TRANSFORM_KEYS.has(key)) {
+ transformKeys.push(key)
+ continue
+ }
+
+ const value = values[key]
+ const name = registerVar()
+ element.style.setProperty(camelToDash(key), `var(${name})`)
+ const render = () =>
+ element.style.setProperty(name, String(value.get()))
+ renders.push(render)
+ unsubs.push(value.on("change", () => frame.render(render)))
+ render()
+ }
+
+ if (transformKeys.length) {
+ const name = registerVar()
+ element.style.setProperty("transform", `var(${name})`)
+ const get = (key, fallback) =>
+ values[key] ? values[key].get() : fallback
+ const render = () =>
+ element.style.setProperty(
+ name,
+ `translate(${get("x", 0)}px, ${get(
+ "y",
+ 0
+ )}px) scale(${get("scale", 1)}) rotate(${get(
+ "rotate",
+ 0
+ )}deg)`
+ )
+ renders.push(render)
+ for (const key of transformKeys) {
+ unsubs.push(
+ values[key].on("change", () => frame.render(render))
+ )
+ }
+ render()
+ }
+
+ return () => {
+ for (const unsub of unsubs) unsub()
+ for (const render of renders) cancelFrame(render)
+ }
+ })
+
+ const makeValues = (props) => {
+ const values = {}
+ for (const prop in props) values[prop] = motionValue(props[prop][0])
+ return values
+ }
+
+ /**
+ * Old VisualElement renderer semantics: any value change schedules a
+ * render that re-applies EVERY bound style, including stagnant ones.
+ */
+ const legacy = {
+ name: "legacy (re-apply all)",
+ bind(boxes, props) {
+ const all = []
+ const subscriptions = []
+ const renders = []
+
+ for (const box of boxes) {
+ const values = makeValues(props)
+ const keys = Object.keys(values)
+
+ const render = () => {
+ for (const key of keys) {
+ box.style[key] = values[key].get()
+ }
+ }
+ renders.push(render)
+
+ for (const key of keys) {
+ subscriptions.push(
+ values[key].on("change", () => frame.render(render))
+ )
+ }
+
+ render()
+ all.push(values)
+ }
+
+ return {
+ values: all,
+ cleanup() {
+ for (const unsub of subscriptions) unsub()
+ for (const render of renders) cancelFrame(render)
+ for (const box of boxes) box.removeAttribute("style")
+ },
+ }
+ },
+ }
+
+ /** Granular per-value writes to element.style */
+ const style = {
+ name: "styleEffect (granular)",
+ bind(boxes, props) {
+ const all = []
+ const cleanups = []
+ for (const box of boxes) {
+ const values = makeValues(props)
+ cleanups.push(styleEffect(box, values))
+ all.push(values)
+ }
+ return {
+ values: all,
+ cleanup() {
+ for (const cleanup of cleanups) cleanup()
+ for (const box of boxes) box.removeAttribute("style")
+ },
+ }
+ },
+ }
+
+ /** Registered custom properties (inherits: false), written to inline style */
+ const varStrategy = {
+ name: "varEffect (registered vars)",
+ bind(boxes, props) {
+ const all = []
+ const cleanups = []
+ for (const box of boxes) {
+ const values = makeValues(props)
+ cleanups.push(varEffect(box, values))
+ all.push(values)
+ }
+ return {
+ values: all,
+ cleanup() {
+ for (const cleanup of cleanups) cleanup()
+ for (const box of boxes) box.removeAttribute("style")
+ },
+ }
+ },
+ }
+
+ /**
+ * Experimental: per-element rule in an adopted stylesheet. Values are
+ * written into the rule's declaration block, so the element's style
+ * attribute is never mutated after setup - this dodges [style*=...]
+ * attribute invalidation entirely (see style-recalc-harness).
+ */
+ let sheetId = 0
+ const sheet = {
+ name: "sheet (adopted stylesheet)",
+ bind(boxes, props) {
+ const all = []
+ const subscriptions = []
+ const renders = []
+ const styleSheet = new CSSStyleSheet()
+ const keys = Object.keys(props)
+
+ const src = []
+ const ids = []
+ for (let i = 0; i < boxes.length; i++) {
+ const id = sheetId++
+ ids.push(id)
+ boxes[i].setAttribute("data-mb", id)
+ src.push(`[data-mb="${id}"] {}`)
+ }
+ styleSheet.replaceSync(src.join("\n"))
+ document.adoptedStyleSheets = [
+ ...document.adoptedStyleSheets,
+ styleSheet,
+ ]
+
+ const dash = (key) =>
+ key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
+
+ for (let i = 0; i < boxes.length; i++) {
+ const rule = styleSheet.cssRules[i]
+ const values = makeValues(props)
+
+ for (const key of keys) {
+ const varName = `--mb${ids[i]}-${key}`
+ try {
+ CSS.registerProperty({
+ name: varName,
+ syntax: "*",
+ inherits: false,
+ })
+ } catch (e) {}
+ rule.style.setProperty(varName, values[key].get())
+ rule.style.setProperty(dash(key), `var(${varName})`)
+
+ const render = () => {
+ rule.style.setProperty(varName, values[key].get())
+ }
+ renders.push(render)
+ subscriptions.push(
+ values[key].on("change", () => frame.render(render))
+ )
+ }
+
+ all.push(values)
+ }
+
+ return {
+ values: all,
+ cleanup() {
+ for (const unsub of subscriptions) unsub()
+ for (const render of renders) cancelFrame(render)
+ document.adoptedStyleSheets =
+ document.adoptedStyleSheets.filter(
+ (s) => s !== styleSheet
+ )
+ for (const box of boxes) box.removeAttribute("data-mb")
+ },
+ }
+ },
+ }
+
+ /**
+ * WAAPI (element.animate) on the main thread. Values are applied via
+ * the animation cascade origin, NOT the style attribute - so there is
+ * zero attribute mutation, before, during or after the animation.
+ * These paint properties are not compositable, so the animation still
+ * ticks on the main thread (style + paint per frame) like the others.
+ */
+ const waapi = {
+ name: "WAAPI (element.animate)",
+ bind(boxes) {
+ const binding = {
+ boxes,
+ animations: [],
+ cleanup() {
+ for (const animation of binding.animations) {
+ animation.cancel()
+ }
+ binding.animations = []
+ },
+ }
+ return binding
+ },
+ animate(binding, phaseProps, props, flip, duration) {
+ for (const animation of binding.animations) animation.cancel()
+ binding.animations = []
+
+ for (const box of binding.boxes) {
+ const keyframes = {}
+ for (const prop of phaseProps) {
+ const [a, b] = props[prop]
+ keyframes[prop] = flip ? [b, a] : [a, b]
+ }
+ binding.animations.push(
+ box.animate(keyframes, {
+ duration: duration * 1000,
+ easing: "linear",
+ fill: "forwards",
+ })
+ )
+ }
+ return Promise.all(
+ binding.animations.map((animation) => animation.finished)
+ )
+ },
+ }
+
+ /**
+ * GSAP's JS renderer: its ticker writes inline styles every frame,
+ * so like legacy/styleEffect/varEffect it mutates the style attribute
+ * per frame and pays any attribute-selector invalidation cost.
+ */
+ const gsapStrategy = {
+ name: "GSAP (inline writes)",
+ bind(boxes, props) {
+ for (const box of boxes) {
+ for (const key in props) box.style[key] = props[key][0]
+ }
+ return {
+ boxes,
+ cleanup() {
+ for (const box of boxes) {
+ window.gsap.killTweensOf(box)
+ box.removeAttribute("style")
+ }
+ },
+ }
+ },
+ animate(binding, phaseProps, props, flip, duration) {
+ const tweens = []
+ for (const box of binding.boxes) {
+ /**
+ * fromTo with explicit endpoints, matching the keyframe
+ * semantics of the other strategies. A plain .to() would
+ * create zero-change tweens for any property already at
+ * its target (phases alternate direction), silently
+ * animating fewer values than the other renderers.
+ */
+ const fromVars = {}
+ const toVars = { duration, ease: "none", overwrite: "auto" }
+ for (const prop of phaseProps) {
+ const [a, b] = props[prop]
+ fromVars[prop] = flip ? b : a
+ toVars[prop] = flip ? a : b
+ }
+ tweens.push(window.gsap.fromTo(box, fromVars, toVars))
+ }
+ return Promise.all(tweens)
+ },
+ }
+
+ return { legacy, style, var: varStrategy, sheet, waapi, gsap: gsapStrategy }
+}
+
+/** ------------------------------------------------------------------ */
+/** Metering */
+/** ------------------------------------------------------------------ */
+
+export function createFrameMeter(Motion) {
+ const { frame, cancelFrame } = Motion
+ let deltas = []
+ let renderMs = 0
+ let renderStart = 0
+ let rafId
+ let lastTs
+
+ const preRender = () => {
+ renderStart = performance.now()
+ }
+ const postRender = () => {
+ renderMs += performance.now() - renderStart
+ }
+
+ return {
+ start() {
+ deltas = []
+ renderMs = 0
+ lastTs = undefined
+ frame.preRender(preRender, true)
+ frame.postRender(postRender, true)
+ const tick = (ts) => {
+ if (lastTs !== undefined) deltas.push(ts - lastTs)
+ lastTs = ts
+ rafId = requestAnimationFrame(tick)
+ }
+ rafId = requestAnimationFrame(tick)
+ },
+ stop() {
+ cancelAnimationFrame(rafId)
+ cancelFrame(preRender)
+ cancelFrame(postRender)
+ const sorted = [...deltas].sort((a, b) => a - b)
+ const frames = deltas.length || 1
+ return {
+ frames: deltas.length,
+ mean: deltas.reduce((a, b) => a + b, 0) / frames,
+ p95: sorted[
+ Math.min(sorted.length - 1, Math.floor(0.95 * frames))
+ ],
+ max: sorted[sorted.length - 1] ?? 0,
+ renderMsPerFrame: renderMs / frames,
+ fps: 1000 / (deltas.reduce((a, b) => a + b, 0) / frames),
+ }
+ },
+ }
+}
+
+/** ------------------------------------------------------------------ */
+/** Runner */
+/** ------------------------------------------------------------------ */
+
+/**
+ * Creates a benchmark driver. Phases are named prop subsets, e.g.
+ * { A: [all five], B: ["backgroundColor"] }. Exposes granular
+ * setup/runPhase/teardown for external (CDP) drivers, and runAll()
+ * implementing the standard protocol: per strategy, `runs` iterations
+ * of each phase without reload, first run discarded.
+ */
+export function createRunner({
+ Motion,
+ boxes,
+ strategies,
+ props,
+ phases,
+ duration = 2,
+ runs = 4,
+ onStatus = () => {},
+}) {
+ const { animate } = Motion
+ const meter = createFrameMeter(Motion)
+
+ let active = null
+ let direction = 0
+
+ async function setup(name) {
+ if (active) teardown()
+ direction = 0
+ const strategy = strategies[name]
+ active = { name, strategy, binding: strategy.bind(boxes, props) }
+ await new Promise((resolve) => setTimeout(resolve, 100))
+ }
+
+ async function runPhase(phaseName) {
+ const phaseProps = phases[phaseName]
+ const flip = direction++ % 2 === 1
+
+ meter.start()
+ if (active.strategy.animate) {
+ /**
+ * Strategy drives its own animation (e.g. CSS transitions),
+ * bypassing Motion's main-thread animation loop entirely.
+ */
+ await active.strategy.animate(
+ active.binding,
+ phaseProps,
+ props,
+ flip,
+ duration
+ )
+ } else {
+ const animations = []
+ for (const values of active.binding.values) {
+ for (const prop of phaseProps) {
+ const [a, b] = props[prop]
+ animations.push(
+ animate(values[prop], flip ? [b, a] : [a, b], {
+ duration,
+ ease: "linear",
+ })
+ )
+ }
+ }
+ await Promise.all(animations)
+ }
+ const stats = meter.stop()
+ await new Promise((resolve) => setTimeout(resolve, 100))
+ return stats
+ }
+
+ function teardown() {
+ active?.binding.cleanup()
+ active = null
+ }
+
+ async function runAll(names) {
+ const results = []
+ for (const name of names) {
+ await setup(name)
+ const perPhase = {}
+ for (const phaseName in phases) perPhase[phaseName] = []
+
+ for (let r = 0; r < runs; r++) {
+ for (const phaseName in phases) {
+ onStatus(
+ `${strategies[name].name} — run ${
+ r + 1
+ }/${runs}, phase ${phaseName}`
+ )
+ perPhase[phaseName].push(await runPhase(phaseName))
+ }
+ }
+ teardown()
+
+ const summary = { name: strategies[name].name }
+ for (const phaseName in phases) {
+ /** Discard first run (JIT warmup) */
+ const warm = perPhase[phaseName].slice(1)
+ const avg = (key) =>
+ warm.reduce((a, s) => a + s[key], 0) / warm.length
+ summary[phaseName] = {
+ mean: avg("mean"),
+ p95: avg("p95"),
+ fps: avg("fps"),
+ renderMsPerFrame: avg("renderMsPerFrame"),
+ runs: perPhase[phaseName],
+ }
+ }
+ results.push(summary)
+ }
+ return results
+ }
+
+ return { setup, runPhase, teardown, runAll }
+}
+
+/** ------------------------------------------------------------------ */
+/** Reporting */
+/** ------------------------------------------------------------------ */
+
+export function renderResultsTable(el, results, phases) {
+ const phaseNames = Object.keys(phases)
+ let html = `| renderer | `
+ for (const p of phaseNames) {
+ html += `${p}: fps | ${p}: mean frame (ms) | ${p}: p95 (ms) | ${p}: render JS (ms/frame) | `
+ }
+ html += `
`
+ for (const r of results) {
+ html += `| ${r.name} | `
+ for (const p of phaseNames) {
+ html += `${r[p].fps.toFixed(1)} | ${r[p].mean.toFixed(
+ 2
+ )} | ${r[p].p95.toFixed(2)} | ${r[
+ p
+ ].renderMsPerFrame.toFixed(3)} | `
+ }
+ html += `
`
+ }
+ el.innerHTML = html
+}
+
+export const panelCSS = `
+ #panel {
+ position: fixed; top: 0; left: 0; right: 0;
+ background: rgba(0, 0, 0, 0.88); color: #fff;
+ padding: 10px 14px; z-index: 1000;
+ font-family: system-ui, sans-serif; font-size: 12px;
+ max-height: 40vh; overflow: auto;
+ }
+ #panel table { border-collapse: collapse; margin-top: 6px; }
+ #panel td, #panel th { border: 1px solid #555; padding: 2px 8px; text-align: right; }
+ #panel th:first-child, #panel td:first-child { text-align: left; }
+`
diff --git a/dev/html/public/benchmarks/renderer-test-1.html b/dev/html/public/benchmarks/renderer-test-1.html
new file mode 100644
index 0000000000..a367927ec2
--- /dev/null
+++ b/dev/html/public/benchmarks/renderer-test-1.html
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev/html/public/benchmarks/renderer-test-2.html b/dev/html/public/benchmarks/renderer-test-2.html
new file mode 100644
index 0000000000..ad0f46487f
--- /dev/null
+++ b/dev/html/public/benchmarks/renderer-test-2.html
@@ -0,0 +1,212 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev/html/public/benchmarks/renderer-test-3.html b/dev/html/public/benchmarks/renderer-test-3.html
new file mode 100644
index 0000000000..59e6a09cc7
--- /dev/null
+++ b/dev/html/public/benchmarks/renderer-test-3.html
@@ -0,0 +1,268 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev/html/public/benchmarks/style-recalc-harness.html b/dev/html/public/benchmarks/style-recalc-harness.html
new file mode 100644
index 0000000000..9302aa7767
--- /dev/null
+++ b/dev/html/public/benchmarks/style-recalc-harness.html
@@ -0,0 +1,736 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev/react/profile-layout.mjs b/dev/react/profile-layout.mjs
new file mode 100644
index 0000000000..eaba12373a
--- /dev/null
+++ b/dev/react/profile-layout.mjs
@@ -0,0 +1,142 @@
+/**
+ * Profiles a layout-animation stress example.
+ *
+ * Loads dev/react at ?example=, starts Motion's recordStats() plus the
+ * V8 sampling profiler, clicks to trigger the layout animation, then reports:
+ * - frame rate + projection metrics (from recordStats)
+ * - total sampled JS ms and per-frame JS ms
+ * - self-time hotspots grouped by function
+ * - time attributed to projection source vs everything else
+ *
+ * Usage: node profile-layout.mjs [example] [profileMs] [port]
+ * e.g. node profile-layout.mjs layout-stress 4000 9990
+ */
+import { chromium } from "playwright"
+import { writeFileSync } from "fs"
+
+const example = process.argv[2] || "layout-stress"
+const profileMs = +(process.argv[3] || 4000)
+const port = +(process.argv[4] || 9990)
+/** ms to wait after the click before profiling (skips the didUpdate burst) */
+const skipMs = process.argv[5] === undefined ? 600 : +process.argv[5]
+
+const browser = await chromium.launch()
+const page = await browser.newPage({ viewport: { width: 1400, height: 1000 } })
+await page.goto(`http://localhost:${port}/?example=${example}`)
+await page.waitForFunction(() => typeof window.recordStats === "function")
+/** Let the initial mount settle */
+await page.waitForTimeout(500)
+
+const client = await page.context().newCDPSession(page)
+await client.send("Profiler.enable")
+await client.send("Profiler.setSamplingInterval", { interval: 100 })
+
+/**
+ * recordStats() returns a report function on newer builds. On older
+ * builds it returns undefined - stats are then omitted from the output
+ * and only the V8 profile is reported.
+ */
+const startStats = () =>
+ page.evaluate(() => {
+ window.__report = window.recordStats()
+ })
+
+/** Trigger the layout animation */
+if (skipMs === 0) {
+ await startStats()
+ await client.send("Profiler.start")
+ await page.mouse.click(500, 500)
+} else {
+ await page.mouse.click(500, 500)
+
+ /**
+ * Skip the one-off measure/didUpdate burst at animation start so the
+ * profile captures pure steady-state per-frame work.
+ */
+ await page.waitForTimeout(skipMs)
+
+ await startStats()
+ await client.send("Profiler.start")
+}
+
+await page.waitForTimeout(profileMs)
+
+const { profile } = await client.send("Profiler.stop")
+const stats = await page.evaluate(() =>
+ typeof window.__report === "function" ? window.__report() : null
+)
+
+/** ------------------------------------------------------------------ */
+/** Aggregate the profile */
+/** ------------------------------------------------------------------ */
+
+const nodesById = new Map()
+for (const node of profile.nodes) nodesById.set(node.id, node)
+
+const selfTime = new Map() // key -> µs
+let totalSampled = 0
+const totalWallUs = profile.endTime - profile.startTime
+
+for (let i = 0; i < profile.samples.length; i++) {
+ const delta = profile.timeDeltas[i] ?? 0
+ const node = nodesById.get(profile.samples[i])
+ if (!node) continue
+ const { functionName, url, lineNumber } = node.callFrame
+ if (functionName === "(idle)" || functionName === "(program)") continue
+ totalSampled += delta
+ const key = `${functionName || "(anonymous)"} @ ${url
+ .split("/")
+ .slice(-1)} :${lineNumber}`
+ selfTime.set(key, (selfTime.get(key) || 0) + delta)
+}
+
+const sorted = [...selfTime.entries()].sort((a, b) => b[1] - a[1])
+
+/**
+ * Projection functions live in the framer-motion dep bundle; identify them by
+ * name since the bundle is a single file. Names taken from
+ * motion-dom/src/projection/** and geometry utils.
+ */
+const projectionNames =
+ /projection|targetdelta|dirtynodes|calcprojection|calcrelative|calcboxdelta|calcaxisdelta|removeboxtransforms|applyboxdelta|applyaxisdelta|applytreedelta|boxequal|axisequal|aspectratio|translateaxis|transformbox|scalepoint|applypointdelta|hasscale|has2dtranslate|hastransform|buildprojectiontransform|measure|measurepagebox|measureviewportbox|convertboundingbox|updateprojection|updatelayout|updatesnapshot|notifylayoutupdate|resetskewandrotation|mixaxisdelta|mixbox|mixaxis|snapshot/i
+
+let projectionUs = 0
+for (const [key, us] of selfTime) {
+ if (projectionNames.test(key)) projectionUs += us
+}
+
+const out = {
+ example,
+ profileMs,
+ fps: stats ? stats.frameloop.rate : undefined,
+ projectionMetricsPerFrame: stats
+ ? {
+ nodes: stats.layoutProjection.nodes,
+ calculatedTargetDeltas:
+ stats.layoutProjection.calculatedTargetDeltas,
+ calculatedProjections:
+ stats.layoutProjection.calculatedProjections,
+ }
+ : undefined,
+ animations: stats ? stats.animations : undefined,
+ js: {
+ totalSampledMs: +(totalSampled / 1000).toFixed(1),
+ wallMs: +(totalWallUs / 1000).toFixed(1),
+ jsShareOfWall: +((totalSampled / totalWallUs) * 100).toFixed(1) + "%",
+ projectionAttributedMs: +(projectionUs / 1000).toFixed(1),
+ projectionShareOfJS:
+ +((projectionUs / totalSampled) * 100).toFixed(1) + "%",
+ },
+ top30: sorted
+ .slice(0, 30)
+ .map(([key, us]) => `${(us / 1000).toFixed(1).padStart(7)}ms ${key}`),
+}
+
+console.log(JSON.stringify(out, null, 2))
+writeFileSync(
+ `profile-${example}.json`,
+ JSON.stringify({ out, profile }, null, 0)
+)
+console.log(`\nraw profile written to profile-${example}.json`)
+
+await browser.close()
diff --git a/dev/react/src/App.tsx b/dev/react/src/App.tsx
index 0974ee6be4..6f93e56239 100644
--- a/dev/react/src/App.tsx
+++ b/dev/react/src/App.tsx
@@ -1,5 +1,13 @@
+import { recordStats } from "framer-motion/debug"
import { StrictMode } from "react"
+/**
+ * Expose Motion's stats recorder for benchmarking/automation.
+ * Usage from devtools or a driver:
+ * window.__report = window.recordStats(); ...; window.__report()
+ */
+;(window as any).recordStats = recordStats
+
const examples = import.meta.glob("./examples/*.tsx", {
eager: true,
import: "App",
diff --git a/dev/react/src/examples/layout-stress-deep.tsx b/dev/react/src/examples/layout-stress-deep.tsx
new file mode 100644
index 0000000000..448a4719a7
--- /dev/null
+++ b/dev/react/src/examples/layout-stress-deep.tsx
@@ -0,0 +1,57 @@
+import { motion, MotionConfig } from "framer-motion"
+import * as React from "react"
+import { useState } from "react"
+
+/**
+ * Deep-tree layout stress test: many chains of deeply nested projecting
+ * nodes. Exercises the per-node ancestor-path cost of projection
+ * calculation (O(nodes × depth) with per-node path walks vs O(nodes)
+ * with cumulative path transforms).
+ */
+
+const DEPTH = 30
+const CHAINS = 40
+
+function Chain({ depth }: { depth: number }) {
+ if (depth === 0) return null
+ return (
+
+
+
+ )
+}
+
+export const App = () => {
+ const [expanded, setExpanded] = useState(false)
+
+ return (
+
+ setExpanded(!expanded)}
+ >
+ {Array.from({ length: CHAINS }, (_, i) => (
+
+ ))}
+
+
+ )
+}