Sanding Down the Sharp Edges of View Transitions with view-transitions-toolkit
The browser's CSS View Transitions API is one of the more magical additions to the platform in recent years. You mutate the DOM, the browser snapshots the before-and-after states, and it animates the difference for you, off the main thread. The catch is that the raw API is decidedly low-level. Grabbing the actual animation objects to pause or scrub them is fiddly, size-changing morphs stretch your content into taffy, and wiring up direction-aware page navigation means hand-rolling pageswap and pagereveal listeners. view-transitions-toolkit is a grab-bag of small utilities that each smooth one of those rough spots.
What makes it notable is the pedigree and the philosophy. It comes out of GoogleChromeLabs, authored by Bramus Van Damme, a Chrome Developer Relations engineer who is one of the most prominent public educators on this very API. (Worth noting: the README is explicit that this is not an officially supported Google product, more of a Chrome Labs experiment.) Crucially, it does not try to abstract the API away. You still call document.startViewTransition yourself, you still write @view-transition in your CSS. The toolkit simply augments, turning multi-step recipes that most developers get wrong into single function calls.
Why This Toolkit Exists
If you have spent any time with View Transitions, the pain points will feel familiar. There is no easy, supported way to reach the CSSAnimation objects the browser generates for the transition pseudo-elements, which makes pausing or scrubbing a transition a chore. Size-changing morphs distort content because the group animates width and height by default, both a layout-driving animation and a source of visible squishing. For multi-page transitions you frequently need to temporarily assign view-transition-name to the right element just before navigation and clean it up afterward, which is leak-prone boilerplate. And choosing transition types based on navigation direction requires manual event wiring and URL matching.
The toolkit answers each of these with a focused module. It is tiny (around 58 KB unpacked), TypeScript-authored with bundled type definitions, pure ESM, and ships with zero runtime dependencies. Every module is a separate subpath export, so you import only what you use and your bundler tree-shakes the rest. There is no single barrel entry point by design.
Getting It Into Your Project
Installation is the usual one-liner.
npm install view-transitions-toolkit
yarn add view-transitions-toolkit
Because each feature lives behind its own subpath, your imports are explicit about what you pull in:
import { supports } from "view-transitions-toolkit/feature-detection";
import { optimizeGroupAnimations } from "view-transitions-toolkit/animations";
import { pause, resume, scrub } from "view-transitions-toolkit/playback-control";
One thing to keep in mind: this is pure ESM with subpath exports and no CommonJS fallback, so your bundler or Node setup needs to support that. For modern Vite, Next, Astro, or any current toolchain this is a non-issue.
Knowing What the Browser Can Do
Before you light up a transition, you usually want to know whether the engine in front of you actually supports the sub-feature you are about to use. The feature-detection module exposes a single supports object with a boolean for each capability.
import { supports } from "view-transitions-toolkit/feature-detection";
if (supports.sameDocument) {
// document.startViewTransition is available
}
supports.types; // View Transition Types
supports.crossDocument; // @view-transition / multi-page transitions
supports.elementScoped; // element-scoped view transitions
supports.activeViewTransition; // native document.activeViewTransition
This matters because the toolkit cannot add capability the engine lacks. Same-document transitions ship in Chromium and Safari, while Firefox has historically trailed on the cross-document and types pieces. Use supports to gate your enhanced paths and provide a graceful fallback (a plain DOM update with no animation) everywhere else. The toolkit deliberately does no reduced-motion handling for you, so this is also where you would branch on prefers-reduced-motion.
Fixing the Squish: The Animations Module
This is the headline feature and the reason most people will reach for the library. When a tracked element changes size between states, the browser tweens width and height on the transition group, which stretches the snapshot images during the morph. The well-known fix is to animate transform and scale toward fixed end-dimensions instead. Doing that by hand means measuring before-and-after geometry, computing scale factors, and rewriting keyframes. The animations module does it for you.
import {
optimizeGroupAnimations,
OPTIMIZATION_STRATEGY,
} from "view-transitions-toolkit/animations";
const transition = document.startViewTransition(() => {
// mutate the DOM to the new state
applyNextLayout();
});
await transition.ready; // animations only exist once the transition is ready
optimizeGroupAnimations(transition, "*"); // fix every group morph, default SCALE
The optimization needs a small companion CSS rule so the old and new snapshots fill their boxes correctly:
::view-transition-new(*),
::view-transition-old(*) {
width: 100%;
height: 100%;
object-fit: fill;
}
There are three strategies. SCALE (the default) animates both position and size through transform and scale; it is the most performant but can distort content whose aspect ratio changes a lot. SLIDE animates position only and snaps width and height to the final size, which looks cleaner when only position is moving. NONE disables optimization. You can target specific names rather than the wildcard:
optimizeGroupAnimations(transition, "box-flip", OPTIMIZATION_STRATEGY.SLIDE);
If you need to go lower, the module also exposes getAnimations to read the raw CSSAnimation array, extractGeometry to measure an animation's before-and-after box, and optimizeAnimation to rewrite a single animation. The convenience wrapper covers the common case, but the primitives are there when you want them.
Scrubbing a Live Transition for Gestures
Once you can reach the underlying animations, a whole category of interaction opens up. The playback-control module lets you pause, resume, and scrub the in-flight transition, which is exactly what you need for a swipe-to-go-back gesture or for debugging a tricky morph frame by frame.
import { pause, resume, scrub } from "view-transitions-toolkit/playback-control";
const transition = document.startViewTransition(() => updateView());
await transition.ready;
pause(transition); // freeze every underlying animation
scrub(transition, 0.5); // jump to the 50% frame and hold (scrub auto-pauses)
resume(transition); // let it play out
scrub takes a progress value between 0 and 1, so wiring it to a pointer's horizontal position gives you a transition that follows the user's finger. Because these helpers operate on all of the transition's animations at once, you do not have to enumerate the pseudo-elements yourself.
Direction-Aware Page Navigation
For multi-page apps, the navigation module removes a surprising amount of boilerplate. You hand it a route map, and it automatically assigns from-<name> and to-<name> View Transition Types based on which route you are leaving and which you are entering. A single set of CSS can then drive direction-aware transitions.
import { useAutoTypes } from "view-transitions-toolkit/navigation";
const routeMap = {
index: "/",
detail: "/detail/:id", // URLPattern syntax is supported
about: "/about",
};
useAutoTypes(routeMap);
It must run render-blocking so the types are set before the first paint, otherwise the incoming page paints before your direction-aware CSS can apply:
<script type="module" src="script.js" blocking="render" async></script>
With that in place, you style purely in CSS by direction, and can even combine origin and destination:
:active-view-transition-type(from-index) { /* leaving the homepage */ }
:active-view-transition-type(to-detail) { /* arriving at a detail page */ }
:active-view-transition-type(from-index):active-view-transition-type(to-about) {
/* specifically homepage to about */
}
Under the hood it listens to pageswap and pagereveal and matches the origin and destination URLs against your route map using URLPattern. You write the route names once and never touch the event plumbing.
The Miscellaneous Conveniences
The misc module collects two small but genuinely useful helpers. The first, setTemporaryViewTransitionNames, assigns a batch of view-transition-name values and then automatically removes them once a promise (typically transition.finished) settles. This kills the assign-await-cleanup dance that is so easy to leak.
import { setTemporaryViewTransitionNames } from "view-transitions-toolkit/misc";
window.addEventListener("pageswap", (e) => {
if (!e.viewTransition) return;
const url = new URL(e.activation.entry.url);
if (isProfilePage(url)) {
const profile = extractProfileNameFromUrl(url);
setTemporaryViewTransitionNames(
[
[document.querySelector(`#${profile} span`), "name"],
[document.querySelector(`#${profile} img`), "avatar"],
],
e.viewTransition.finished,
);
}
});
The second, extractViewTransitionName, parses a name out of a pseudo-selector string, handy when you are inspecting animations and need to know which named element they belong to:
import { extractViewTransitionName } from "view-transitions-toolkit/misc";
extractViewTransitionName("::view-transition-new(box-flip)"); // "box-flip"
There is also a track-active-view-transition module that shims document.activeViewTransition, which is not yet widely available natively. Call trackActiveViewTransition() once and any cross-cutting code can then read the in-flight transition off the document. The shim respects native implementations and will not override them where they exist.
Where It Fits Alongside React
There is no React-specific hook or component here, and that is by design. The toolkit is framework-agnostic and DOM-level, so in a React app you call these helpers from your event handlers and effects around your own document.startViewTransition:
import { flushSync } from "react-dom";
import { optimizeGroupAnimations } from "view-transitions-toolkit/animations";
function navigate(next: State) {
const t = document.startViewTransition(() =>
flushSync(() => setState(next)),
);
t.ready.then(() => optimizeGroupAnimations(t, "*"));
}
This is worth understanding clearly. React 19.2 ships an experimental <ViewTransition> component and addTransitionType, and the community react-view-transitions package wires the API into React's render lifecycle. Those solve the React integration problem, naming elements via JSX and tying transitions to commits. view-transitions-toolkit solves a different, complementary problem: the animation-engine ergonomics of distortion, scrubbing, route-based types, and temporary names. You can happily use both together.
When to Reach For It
A few caveats are worth holding in mind. The package is brand-new, with v1.0.0 having landed in April 2026 and modest adoption so far, so pin your versions. The optimizers and getAnimations require await vt.ready before the animations exist. The render-blocking requirements for useAutoTypes and the cross-document shim are real, not optional. And the underlying browser support of the API is always the gating factor, which is exactly why the feature-detection module exists.
That said, the value proposition is sharp. If you are already committed to the native View Transitions API because you want off-main-thread snapshot animation and real cross-document transitions, and you have hit one of its rough edges, this toolkit gives you small, tree-shakeable, zero-dependency, well-typed helpers that turn each painful recipe into a one-liner. It does not take over your transition logic; it just sands down the splinters, written by the person who literally documents this corner of the platform. For anyone serious about native view transitions, that is a very easy dependency to justify.