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.
- Why
- Install
- Quick start
- Panel walkthrough
- How it works internally
- Finding rules
- Slow motion and step mode
- Message protocol
- Permissions
- Limitations
- Troubleshooting
- Development
- Contributing
- Further reading
- License
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.
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:
-
Clone the repository:
git clone https://github.com/css-scroll-driven/view-transition-debugger.git cd view-transition-debugger -
Open
chrome://extensionsin Chrome. (Paste it into the address bar — Chrome blocks links tochrome://pages, so no link here would work.) -
Turn on Developer mode with the toggle in the top-right corner.
-
Click Load unpacked and select the cloned
view-transition-debuggerfolder — the one containingmanifest.json, not a subfolder. -
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.
| 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 |
- Edge and Brave are Chromium-based and load this extension the same way.
Edge's unpacked-extension flow is at
edge://extensions, Brave's atbrave://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 inbrave://versionagainst 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 requiresexportFunctionandwrappedJSObjectgymnastics 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.
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 serveThen 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.html → detail.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.
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, andanimating, 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
startViewTransitionwas called from, with the extension's own frames stripped out.
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
Why each piece exists:
injected/instrument.jsruns in the main world, so it shares the page's global object and can replace the page's realdocument.startViewTransition. It has no access tochrome.*. It is declared atdocument_startso the patch is installed before any page script can capture a reference to the original function.content/bridge.jsruns in the isolated world, so it haschrome.*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.jsregisters 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'schrome.runtime.connect()reaches it directly. That removes an entire lifecycle (and the MV3 worker-termination races that come with it).panel/panel.jsrenders. 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.
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:
- Return the browser's real
ViewTransitionobject, never a substitute. Page code calls.skipTransition()on it and reads.types. An object carrying three copied promises would break both silently. - Never alter what the promises resolve or reject with, and never create an
unhandled rejection.
readyrejecting 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. - Support both call forms.
startViewTransition(callback)andstartViewTransition({ update, types }). The options object is spread through unchanged sotypesstill reaches the browser and:active-view-transition-type()still matches. - Invoke the update callback with the same
thisand 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. - Handle being called with no argument, which is valid and is used to capture-then-skip.
- 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.startViewTransitionbeforedocument_startcannot 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 agetComputedStyleper element, twice per transition. On a very large DOM this is measurable, so treat the recordedcall → callbackphase as including some debugger overhead. TheDOM update callbackphase does not: it is measured strictly around your callback. - If the page replaces
document.startViewTransitionitself afterdocument_start, the patch is lost and nothing is recorded.
The pseudo-elements only exist between ready and finished, and they cannot be
queried with querySelector. The extension reconstructs the tree from three
sources:
- Before the DOM update, every element is scanned for a computed
view-transition-namethat is notnone. 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 agetBoundingClientRect(). - 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.
- After
readyresolves,document.documentElement.getAnimations({ subtree: true })returns the animations the browser armed, including pseudo-element ones. Each carries apseudoElementstring such as::view-transition-group(card), which is parsed to attribute the animation to its group, andgetKeyframes()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.
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 is applied two ways, because neither alone is sufficient.
- An injected stylesheet. A
<style>element setsanimation-durationandtransition-durationon the view-transition pseudo-elements tocalc(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 of0.25sunless you declare your own duration through the--vtd-base-durationcustom property on the group (the demo'sherogroup does exactly that). At the paused setting the stylesheet instead setsanimation-play-state: paused, which is exact and needs no duration at all. Animation.playbackRate. Whenreadyresolves, every pseudo-element animation getsplaybackRate = 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.
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.
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 ownmatchespattern. - 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.
- Top frame only.
all_framesisfalse, so transitions inside iframes are not recorded. - Cross-origin stylesheets are invisible.
cssRulesthrows on them, so themissing-reduced-motionandcross-document-not-opted-infindings 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 → callbackphase includes debugger overhead from the two full-document scans. TheDOM update callbackphase 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
pageswaphalf and the incoming page records thepagerevealhalf; 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. TheplaybackRatemechanism 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.
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.
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.
Bug reports and pull requests are welcome. See CONTRIBUTING.md for the workflow, and CHANGELOG.md for release history.
Background material that explains the mechanics this extension exposes:
- How view transitions work under the hood — the snapshot, pseudo-tree, and teardown sequence the panel is built around.
- Same-document vs cross-document view transitions — why the two kinds are recorded differently.
- View Transition API vs the FLIP technique — useful context for reading the geometry deltas in the pseudo-element tree.
- SPA page-swap animations — patterns for router-driven transitions.
- Cross-route element morphing — naming strategies that avoid the duplicate-name error.
- Detecting View Transition support — feature detection that survives the monkey-patch.
- View Transitions and INP impact — why the update-callback budget is set at 50 ms.
- Diagnosing main-thread jank in the Performance panel — the next step once a finding points at a non-composited animation.
- Reduced-motion view transition variants — how to satisfy the reduced-motion finding properly.
MIT © 2026 css-scroll-driven.
Maintained alongside css-scroll-driven.com, a reference site on scroll-driven animations and view transitions.