Skip to content

Repository files navigation

View Transition Debugger

A Chrome DevTools extension that instruments the View Transitions API so you can see what a transition actually did — which groups were captured, how their geometry changed, which animations ran on the pseudo-elements, where the time went, and what went wrong.

It records same-document transitions (document.startViewTransition) and cross-document ones (pageswap / pagereveal), runs a set of diagnostic rules over each recording, and lets you slow the animation down to 10×, pause it, step it frame-zero, or replay it.

No dependencies. No build step. No Chrome Web Store listing — you load it unpacked, which is covered in detail below.


Contents


Why

A view transition is deliberately hard to observe. The browser snapshots the old state, runs your DOM update, snapshots the new state, builds a tree of pseudo-elements that exist for a few hundred milliseconds, animates them, then throws all of it away. By the time you have inspected an element, the thing you wanted to look at no longer exists.

Worse, the failure modes are quiet. A duplicate view-transition-name aborts the whole transition and the DOM simply updates instantly — no error, no warning, just a missing animation. A slow update callback freezes the page on a stale snapshot and shows up as an interaction-latency problem somewhere else entirely.

This extension records each transition as it happens and keeps the recording around so you can read it afterwards.

Install

This extension is not on the Chrome Web Store, and there is no plan to publish it there. The only supported install path is loading it unpacked:

  1. Clone the repository:

    git clone https://github.com/css-scroll-driven/view-transition-debugger.git
    cd view-transition-debugger
  2. Open chrome://extensions in Chrome. (Paste it into the address bar — Chrome blocks links to chrome:// pages, so no link here would work.)

  3. Turn on Developer mode with the toggle in the top-right corner.

  4. Click Load unpacked and select the cloned view-transition-debugger folder — the one containing manifest.json, not a subfolder.

  5. Open DevTools on any page (F12) and find the View Transitions tab. It sits alongside Elements, Console, and Sources; if the tab strip is crowded, it will be under the » overflow menu.

After loading the extension, reload any tab you already had open. Content scripts are only injected into pages loaded after the extension was installed.

Requirements

Item Version
Chrome 111 or newer (world: "MAIN" on declarative content scripts landed in 111)
Same-document transitions Chrome 111+
Cross-document transitions Chrome 126+
Node (only for running the tests) 20 or newer

Other browsers

  • Edge and Brave are Chromium-based and load this extension the same way. Edge's unpacked-extension flow is at edge://extensions, Brave's at brave://extensions. Both need Developer mode enabled first. Brave ships a slightly older Chromium in its release channel from time to time; if the panel is missing, check the Chromium version in brave://version against the table above.
  • Firefox is not supported. Firefox does not implement the View Transitions API in a release channel, so there would be nothing to instrument. Separately, its extension model has no equivalent of a declarative world: "MAIN" content script — the same effect requires exportFunction and wrappedJSObject gymnastics through Xray vision, which is a different implementation, not a port.
  • Safari is not supported. Safari implements view transitions, but it has no DevTools extension API at all: Safari Web Extensions cannot add panels to the Web Inspector. There is no way to build this UI there.

Quick start

The repository ships a self-contained demo so you can exercise every feature without wiring the extension into your own app:

python3 -m http.server 8000 --directory test-page
# or: npm run serve

Then open the demo at http://localhost:8000/ (an ordinary file:// load will not work — cross-document transitions require an HTTP origin), open DevTools, and switch to the View Transitions panel.

The demo has three pages:

Page What it exercises
index.html Same-document transitions in every call form: plain callback, the { update, types } options object, a deliberately slow callback, an explicit skipTransition(), and a 24-group stress case.
gallery.htmldetail.html Cross-document transitions with a shared hero element that morphs across a real navigation.
broken.html Four planted bugs: a duplicate view-transition-name, an oversized snapshot, a width/clip-path animation on the pseudo-elements, and no reduced-motion handling.

Start with broken.html — it should light up the findings list immediately, and its Un-duplicate the name and retry button shows the error disappearing once the name collision is resolved.

Panel walkthrough

The panel has a toolbar, a timeline on the left, and a detail view on the right.

Toolbar

  • Clear — drops the recorded transitions in the panel and in the page's buffer.
  • Replay last — re-fires the most recent same-document update callback. See the caveat about what replay can and cannot reproduce.
  • Speed — the slow-motion multiplier: 1×, 2×, 5×, 10×, or paused.
  • Step mode — when enabled, each transition freezes at its first frame until you press Continue.
  • The status line on the right reports the connection state, the current step, or the last error.

Timeline lists every recorded transition newest-first, showing its id, wall clock time, kind (same-doc or cross-doc), total duration, whether it was skipped, and a badge per finding severity. Click one to inspect it.

Detail shows, for the selected transition:

  • The URL, the navigation source for cross-document transitions, and the skip reason if there was one.
  • Phases — a CSS bar chart of call → callback, DOM update callback, callback → ready, and animating, laid out on a shared scale so their relative cost is obvious at a glance.
  • Summary — DOM update time, time to ready, animating time, total, and the number of captured groups.
  • Findings — each rule that fired, with severity, what happened, how to fix it, the affected names, and a Learn more link.
  • Pseudo-element tree — collapsible, mirroring the real hierarchy. Each group shows its old and new rects, the delta between them (Δx, Δy, Δw, Δh and the scale factors), and every animation running on it. Animations on non-compositor-friendly properties are highlighted.
  • Call stack — where startViewTransition was called from, with the extension's own frames stripped out.

How it works internally

Architecture

Four contexts, three hops. The split exists because no single context can both see the page's JavaScript globals and talk to the extension.

flowchart LR
    subgraph tab["Inspected tab"]
        MW["injected/instrument.js<br/>MAIN world<br/>patches startViewTransition"]
        BR["content/bridge.js<br/>ISOLATED world<br/>relay only"]
    end
    subgraph dt["DevTools window"]
        DP["devtools/devtools.js<br/>router + buffer"]
        PN["panel/panel.js<br/>UI"]
    end

    MW -- "window.postMessage" --> BR
    BR -- "window.postMessage" --> MW
    BR -- "runtime port 'vtd-bridge'" --> DP
    DP -- "runtime port 'vtd-bridge'" --> BR
    DP -- "runtime port 'vtd-panel'" --> PN
    PN -- "runtime port 'vtd-panel'" --> DP
Loading

Why each piece exists:

  • injected/instrument.js runs in the main world, so it shares the page's global object and can replace the page's real document.startViewTransition. It has no access to chrome.*. It is declared at document_start so the patch is installed before any page script can capture a reference to the original function.
  • content/bridge.js runs in the isolated world, so it has chrome.* but cannot see the page's globals. It does nothing but relay, and it buffers messages while no port is open so records are not lost between panel opens.
  • devtools/devtools.js registers the panel and routes between the bridge and the panel. It is the only long-lived context: the bridge dies on every navigation and the panel only exists while it is open, so the router holds the buffer that survives both. There is no background service worker — a DevTools page is an extension page, so a content script's chrome.runtime.connect() reaches it directly. That removes an entire lifecycle (and the MV3 worker-termination races that come with it).
  • panel/panel.js renders. It holds no authoritative state; on open it asks the page for its buffer and rebuilds from that.

The pure logic — finding rules, timing math, pseudo-tree construction and rect diffing — lives in lib/ as ES modules imported by the panel and covered by tests. Neither content script can import them, because MV3 content scripts are always classic scripts; there is no "type": "module" option, the main world has no chrome.runtime to resolve an extension URL from, and page CSP would often block a dynamic chrome-extension: import anyway. The instrumentation therefore repeats the wire constants inline inside marked >>> PROTOCOL / <<< PROTOCOL comments, and tests/protocol.test.js parses both files and fails if the two copies drift.

The monkey-patch strategy

Wrapping document.startViewTransition looks trivial and is not. The wrapper holds these invariants, each of which corresponds to a real way naive wrappers break pages:

  1. Return the browser's real ViewTransition object, never a substitute. Page code calls .skipTransition() on it and reads .types. An object carrying three copied promises would break both silently.
  2. Never alter what the promises resolve or reject with, and never create an unhandled rejection. ready rejecting is a normal control-flow path — it is how a skipped or aborted transition reports itself — so the instrumentation attaches handlers with .then(onOk, onErr) and rethrows nothing.
  3. Support both call forms. startViewTransition(callback) and startViewTransition({ update, types }). The options object is spread through unchanged so types still reaches the browser and :active-view-transition-type() still matches.
  4. Invoke the update callback with the same this and arguments, and forward its return value untouched — the browser awaits it, so replacing a returned promise would change when the transition proceeds. Timing is measured by attaching handlers to the page's own promise rather than by wrapping it.
  5. Handle being called with no argument, which is valid and is used to capture-then-skip.
  6. Never let instrumentation break the page. Every measurement, stylesheet scan, and geometry read is individually wrapped; a throw inside the debugger must not reach page code. If the native call itself throws, the original error is rethrown unchanged after the failure is recorded.

The patched function also mimics the native one for feature detection: name, length, and a toString() that reports [native code].

Caveats you should know about:

  • Any script that grabbed document.startViewTransition before document_start cannot be instrumented. In practice nothing runs that early, but a browser-injected script theoretically could.
  • Only the top frame is instrumented (all_frames: false). Transitions inside iframes are not recorded.
  • The wrapper adds a small amount of work per call — a querySelectorAll('*') with a getComputedStyle per element, twice per transition. On a very large DOM this is measurable, so treat the recorded call → callback phase as including some debugger overhead. The DOM update callback phase does not: it is measured strictly around your callback.
  • If the page replaces document.startViewTransition itself after document_start, the patch is lost and nothing is recorded.

Capturing the pseudo-element tree

The pseudo-elements only exist between ready and finished, and they cannot be queried with querySelector. The extension reconstructs the tree from three sources:

  1. Before the DOM update, every element is scanned for a computed view-transition-name that is not none. Computed values are used rather than the stylesheet text, because names are routinely assigned inline from JS right before the call, and a declared name that matches no live element is irrelevant. Each match contributes a name and a getBoundingClientRect().
  2. After the update callback settles, the same scan runs again. Merging the two maps by name yields the group list: a name in both is a morph, a name only in the new state is entering, only in the old state is exiting.
  3. After ready resolves, document.documentElement.getAnimations({ subtree: true }) returns the animations the browser armed, including pseudo-element ones. Each carries a pseudoElement string such as ::view-transition-group(card), which is parsed to attribute the animation to its group, and getKeyframes() supplies the animated property names.

Stylesheets are also scanned once per transition for a @view-transition rule and for a prefers-reduced-motion block that touches the transition machinery. Cross-origin stylesheets throw on cssRules access by design; those are skipped, which is why the reduced-motion finding can produce a false positive on a site whose CSS is served from a separate origin.

Finding rules

Every rule is a pure function over one recorded transition, in lib/findings.js, and every rule id is covered by tests.

Rule Severity Triggered when How to fix it Learn more
duplicate-view-transition-name error Two or more live elements carry the same view-transition-name in the same document state. Make every name unique, or set view-transition-name: none on all but the element you want to morph. For lists, derive the name from a stable record id. Cross-route element morphing
transition-skipped warning ready rejected: skipTransition() was called, a second transition interrupted this one, the document was hidden, or the update callback threw. Look for an explicit skip, an overlapping transition, or a rejecting callback. Awaiting finished and logging the rejection usually pinpoints it. How view transitions work under the hood
long-update-callback warning at 50 ms, error at 150 ms The DOM update callback blocked for longer than the budget. The page is frozen on the old snapshot the entire time. Fetch and prepare data before calling startViewTransition; keep the callback to a pure DOM swap. Avoid forced synchronous layout inside it. View Transitions and INP impact
non-composited-animation warning An animation on a view-transition pseudo-element targets a layout- or paint-affecting property (width, height, clip-path, filter, box-shadow, insets, or transition: all). Animate transform and opacity only. Let the default group animation interpolate size while the image pair cross-fades, or scale with transform: scale(). Diagnosing main-thread jank in the Performance panel
oversized-snapshot warning A group's largest snapshot covers more than 2× the viewport area. Each snapshot becomes a texture the compositor must hold. Name a smaller element — the visible card, not the tall scrolling container. Avoid naming html, body, or a full-page wrapper unless you want the whole-page cross-fade. How view transitions work under the hood
too-many-groups info More than 20 named groups were captured. Each produces four pseudo-elements and at least one snapshot texture. Name only the elements that must morph independently; let the rest ride the default root cross-fade. SPA page-swap animations
extreme-scale-change info A group both moves and changes size by more than 3× in either axis. The default cross-fade stretches the old snapshot across that range and usually smears. Set an explicit object-fit / object-position on the old and new pseudo-elements, or stagger size and position with a custom keyframe animation on the group. View Transition API vs the FLIP technique
missing-reduced-motion info No @media (prefers-reduced-motion: reduce) block touching the transition machinery was found in the readable stylesheets. Add a reduced-motion block that replaces movement with a short cross-fade or sets animation: none on the pseudo-elements. Keep the transition; drop the motion. Reduced-motion view transition variants
cross-document-not-opted-in warning A pageswap or pagereveal fired but no @view-transition rule was found. Add @view-transition { navigation: auto; } to the stylesheets of every page in the flow — both sides of the navigation need it, and both must be same-origin. Same-document vs cross-document view transitions

Thresholds live in the exported THRESHOLDS object in lib/findings.js, so the numbers in this table and the ones the rules use are the same values the tests assert against.

Slow motion and step mode

Slow motion is applied two ways, because neither alone is sufficient.

  1. An injected stylesheet. A <style> element sets animation-duration and transition-duration on the view-transition pseudo-elements to calc(var(--vtd-base-duration, 0.25s) * N). This catches animations that begin in the very frame the pseudo-tree is built, before script can reach them. CSS cannot multiply a duration it does not know, so this branch assumes the UA default of 0.25s unless you declare your own duration through the --vtd-base-duration custom property on the group (the demo's hero group does exactly that). At the paused setting the stylesheet instead sets animation-play-state: paused, which is exact and needs no duration at all.
  2. Animation.playbackRate. When ready resolves, every pseudo-element animation gets playbackRate = 1 / N. This is exact for every animation regardless of its declared duration, including custom keyframes, and it is what actually delivers the multiplier in practice. The stylesheet is the belt; this is the braces.

Step mode freezes a transition at frame zero. The browser's internal ready step cannot be delayed — the pseudo-tree is built and the animations start in the same frame — so instead, the moment ready resolves, every pseudo-element animation is pause()d and the panel is told how many are held. The page's own ready promise is also held open by returning an unresolved promise from the instrumentation's ready handler, so page code chaining off ready (a common place to kick off a follow-up animation) is stepped too. Pressing Continue plays the held animations and resolves the gate. Turning step mode off also releases anything currently held, so it is impossible to leave a page frozen.

Replay re-fires the last recorded update callback through the patched entry point, so the replay is itself recorded. Note that the DOM is already in the post-callback state, so a replay is only visually meaningful for callbacks that toggle between two states — which is how the demo's callbacks are written.

Message protocol

Defined in lib/protocol.js. Every message is an envelope:

{ channel: 'view-transition-debugger', type: 'page/transition-record', payload: {}, sentAt: 1750000000000 }

The channel field exists because window.message is shared with the page and with every other extension; anything without it is ignored. Types are namespaced by direction so a relay can never reflect a message back where it came from.

Type Direction Payload
page/hello page → panel Support flags, current URL, current slow-mo and step settings. Sent on injection and on every buffer request.
page/transition-started page → panel { id, startedAt, url }, sent synchronously at the call site.
page/transition-record page → panel The full record. Sent repeatedly for the same id as marks fill in; consumers upsert by id.
page/step-paused page → panel { id, animationCount } when step mode has frozen a transition.
page/navigated page → panel { url, via } for pushState, replaceState, popstate, and full navigations.
page/error page → panel { message }.
panel/request-buffer panel → page Asks the page to replay its ring buffer. Sent on panel open.
panel/set-slow-mo panel → page { factor }, validated against 1, 2, 5, 10, 0.
panel/set-step-mode panel → page { enabled }.
panel/step-continue panel → page Releases a held transition.
panel/replay-last panel → page Re-fires the last update callback.
panel/clear panel → page Empties the page buffer and the router buffer.

A record keeps the raw performance.now() marks rather than pre-computed durations, so lib/timing.js can derive phases from a partial record and the panel can render a transition that is still in flight.

Permissions

The extension requests three permissions and no host permissions.

Permission Why it is needed What it is not used for
activeTab Scopes the extension's interaction to the tab whose DevTools window is open. The DevTools router additionally checks port.sender.tab.id against chrome.devtools.inspectedWindow.tabId, so records from other tabs can never leak into a panel. Reading page content outside the inspected tab.
scripting Backs the declarative content-script injection, including the world: "MAIN" script that is the only way to reach the page's real startViewTransition. Injecting on demand into arbitrary pages; there are no executeScript calls.
storage Persists panel preferences (slow-motion factor, step mode) across DevTools sessions. Storing anything about the pages you visit. Recorded transitions live in memory only and are gone when the panel closes.

Notably absent:

  • No host_permissions. Declarative content scripts inject on the strength of their own matches pattern.
  • No tabs. The DevTools port is bound to the inspected tab already.
  • No background service worker, so there is no context that can run while DevTools is closed.
  • No network access of any kind. Nothing is transmitted anywhere; the extension makes no requests.

Limitations

  • Top frame only. all_frames is false, so transitions inside iframes are not recorded.
  • Cross-origin stylesheets are invisible. cssRules throws on them, so the missing-reduced-motion and cross-document-not-opted-in findings can produce false positives on sites serving CSS from another origin.
  • Geometry is a bounding rect, not a snapshot. The panel reports the old and new getBoundingClientRect() values, not the rasterised images themselves — there is no API to read a snapshot's pixels.
  • The call → callback phase includes debugger overhead from the two full-document scans. The DOM update callback phase is measured strictly around your callback and is not affected.
  • Replay re-runs a callback, it does not rewind. The DOM is already in the post-callback state, so replay is meaningful for toggling callbacks and misleading for one-way ones.
  • Cross-document recordings are one-sided per document. The outgoing page records the pageswap half and the incoming page records the pagereveal half; they appear as two entries because they genuinely happen in two different documents.
  • Slow motion via stylesheet assumes the UA default duration unless a group declares --vtd-base-duration. The playbackRate mechanism has no such limitation and is what carries the multiplier in practice.
  • Records are capped at 50 in the page and 200 messages in the router, so a long session drops the oldest entries.

Troubleshooting

The View Transitions tab is missing. Fully close and reopen DevTools — DevTools extensions are only registered when a DevTools window is created. If it is still absent, check for a load error on the extension's card at chrome://extensions.

"No instrumented page is connected." The tab was loaded before the extension was installed or reloaded. Reload the tab; content scripts only inject into pages loaded after the extension was.

Nothing is recorded, but transitions clearly animate. Either the transition is inside an iframe (not supported), or a page script replaced document.startViewTransition after document_start. Check document.startViewTransition.toString() in the console: the patch reports [native code], but a third wrapper on top of it will show its own source.

"This page has no startViewTransition." The browser is older than Chrome 111, or the page runs in a context where the API is unavailable.

The panel is empty after reopening it. Records are kept in memory. The page's ring buffer is replayed on open, so anything from the last 50 transitions in the current document should return; a full page navigation clears everything.

A transition is stuck frozen. Step mode is on and something is held. Press Continue, or switch step mode off — turning it off releases any held animation.

Slow motion has no effect on a custom animation. Confirm the animation is on a ::view-transition-* pseudo-element. Animations on ordinary elements running alongside a transition are out of scope and are not scaled.

Development

There is no build step. Edit a file, click the reload icon on the extension's card at chrome://extensions, then reload the inspected page and reopen DevTools.

view-transition-debugger/
├── manifest.json          MV3 manifest
├── injected/
│   └── instrument.js      MAIN-world instrumentation (classic script)
├── content/
│   └── bridge.js          ISOLATED-world relay (classic script)
├── devtools/
│   ├── devtools.html      DevTools host page
│   └── devtools.js        Panel registration + message router
├── panel/
│   ├── panel.html
│   ├── panel.css          Themed for light and dark DevTools
│   ├── panel.js           UI (ES module)
│   └── icon128.png
├── lib/                   Pure logic, ES modules, unit-tested
│   ├── protocol.js        Message types and envelope validation
│   ├── timing.js          Phase math and formatting
│   ├── pseudo-tree.js     Tree construction and rect diffing
│   └── findings.js        Diagnostic rules
├── tests/                 node --test suites
└── test-page/             Self-contained demo

Run the tests:

npm test
# or, without npm:
node --test "tests/*.test.js"

The suite covers the timing math, the pseudo-tree construction and diffing, every finding rule and threshold boundary, the protocol helpers, the constant-sync check between lib/protocol.js and injected/instrument.js, and structural assertions on manifest.json — including that every file it references exists and that neither content script accidentally uses ES module syntax.

Style: vanilla ES modules, four-space-free 2-space indent, no dependencies, no transpilation. Extension pages may use modules; content scripts may not.

Contributing

Bug reports and pull requests are welcome. See CONTRIBUTING.md for the workflow, and CHANGELOG.md for release history.

Further reading

Background material that explains the mechanics this extension exposes:

License

MIT © 2026 css-scroll-driven.

Maintained alongside css-scroll-driven.com, a reference site on scroll-driven animations and view transitions.

About

Chrome DevTools extension for the View Transitions API — record transitions, inspect the pseudo-element tree, slow-mo and step through them, and pinpoint jank.

Topics

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages