Skip to content

Last updated: First published:

The Utensil Drawer

Pick the tools you need to craft the view transitions you want!

The Utensil Drawer is a collection of scripts and functions that might come in handy when programming view transitions.

Some functions are meant to be imported into your own scripts, while others can be used directly as scripts on websites. For the latter, the Utensil Drawer also provides bundled, ready-to-use versions.

One day, when it grows up, this will be an eclectic collection of snippets for various purposes. The @vtbag/utensil-drawer package are available on npm.

The @vtbag/utensil-drawer/declarative-names script assigns view transition names to HTML elements. This comes in handy if you want to automatically assign view transition names to many elements like all images in a container or all items in a list.

The Utensil-Drawer offers a bundled script for cross-document view transitions and reusable functions for same-document view transitions.

This functionality is similar to assigning dynamic view transition names with the auto value, but it works consistently across browsers and gives you greater control. You specify the names to assign. This allows you to match elements between the old and new pages for cross-document morph animations, which is something not possible with dynamically generated names from view-transition-name: auto.

For both alternatives, start by installing the package from npm:

Terminal window
npm i -D @vtbag/utensil-drawer

For use with cross-document view transitions you load the declarative-names.js script on every page that needs declarative view transition names and specify what names to assign with the data-vtbag-decl attribute.

For vite-based frameworks like Nuxt and Astro this is straight forward:

---
import declarativeNamesURL from "@vtbag/utensil-drawer/declarative-names?url";
---
<head>
<script is:inline src={declarativeNamesURL} data-vtbag-decl='...' />
</head>

To ensure that the code is executed before the pagereveal event, the script must be placed inside the <head> element of your page as a classical inline script. Do not add type="module" or the defer or async attributes as this would delay the script too much.

The reusable functions can be imported from @vtbag/utensil-drawer/set-view-transition-names.

import { setSelectedViewTransitionNames } from "@vtbag/utensil-drawer/set-view-transition-names";
setSelectedViewTransitionNames("h2", "headers-");

For details see the comments in the code

This site uses the declarative-names script for cross-document navigation to make h2 and h3 headings standout when navigating through the pages. It also randomizes the entries of the table of contents visible in the right part of the screen in the desktop view. Here is the configuration used for this:

data-vtbag-decl="h2, h3 = vtbag-h-; starlight-toc span = vtbag-toc~"

This assigns vtbag-h-0, ... to the h2 and h3 headers of every page. This makes headings appear to lift off the page during navigation and smoothly slide into place on the next page.

The second declaration assigns vtbag-toc-0, ... in a random fashion to the entries of the page navigation bar: During navigation, the entries swirl into place, seamlessly adjusting to the new structure.

You instruct the script with a data-vtbag-decl attribute on a script element. Typically on the very script element that loads the declarative-names code, but any other script element found in the DOM when the script executes will do. You can use this to your advantage to compose the declarations from multiple sources, but make sure that those elements a positioned before the declarative-names script.

The structure for the declaration is as shown in the syntax diagram:

css-selector=~=prefix;

The data attribute accepts a semicolon separated list of rules. A rule tells the script to add a view-transition-name, starting with the given prefix, to the style attribute of all DOM elements matching the css-selector. If the rule ends right after the selector, it implicitly uses vtbag-decl-{i}- for some i as the prefix. Prefixes can consist of arbitrary characters but must not contain semicolons (;) or equal signs (=). Selectors can be any valid CSS selector, but can’t contain semicolons and ASCII characters other than A-Za-z0-9_- must be escaped.

To ensure unique view transition names, a numeric suffix is appended to the prefix when a rule applies to multiple elements, unless the prefix is empty, none, or auto. The numbering starts at 0 and increments by 1, following the order in which elements appear in the DOM. If the prefix ends with ~, the ~ is replaced with -, and numbers are assigned in a randomized manner instead of sequentially.

If an element’s style attribute already contains a view-transition-name, it will be overridden unless the rule uses the weak assignment operator ~=.

The escapeViewtransitionName() function escapes characters in a string, making it safe to use as a view transition name.

Terminal window
npm i -D @vtbag/utensil-drawer
import { escapeViewTransitionName } from "@vtbag/utensil-drawer/escape-view-transition-name";
const escaped = escapeViewTransitionName("my:ident"); // => my\:ident

experimental

In most cases, you know your page layout and can predict where elements with view transition names are positioned and how they will animate during a transition. But sometimes, you do not have that static knowledge. Element positions may vary dynamically. Wouldn’t it be useful if animations could adapt based on the positions of those elements, or even react to the distance and angle between the old and new images sharing the same view transition name?

That’s where the setVectors() function comes in:

Assume ::view-transition-group(<group>) having width1, height1 morphs form (x1, y1), to (x2, y2) having width2, height2. Calling setVectors() when the view transitions ready promise fulfills, adds the following CSS definitions to your page:

:root {
--vtbag-vector-${group}-from-x: ${x1}px;
--vtbag-vector-${group}-to-x: ${x2}px;
--vtbag-vector-${group}-from-y: ${y1}px;
--vtbag-vector-${group}-to-y: ${y2}px;
--vtbag-vector-${group}-from-width: ${width1}px;
--vtbag-vector-${group}-to-width: ${width2}px;
--vtbag-vector-${group}-from-height: ${height1}px;
--vtbag-vector-${group}-to-height: ${height2}px;
}
::view-transition-group(${group}) {
--vtbag-vector-from-x: ${x1}px;
--vtbag-vector-to-x: ${x2}px;
--vtbag-vector-from-y: ${y1}px;
--vtbag-vector-to-y: ${y2}px;
--vtbag-vector-from-width: ${width1}px;
--vtbag-vector-to-width: ${width2}px;
--vtbag-vector-from-height: ${height1}px;
--vtbag-vector-to-height: ${height2}px;
}

This way you can access the parameters of the morph animations directly within you CSS rules.

Here is an example from the Derived Trajectories Demo: setVector is called right at the beginning of the animation phase when all old and new images are setup.

Terminal window
npm i -D @vtbag/utensil-drawer
import { setVectors } from "@vtbag/utensil-drawer/vectors";
import { mayStartViewTransition } from "@vtbag/utensil-drawer/may-start-view-transition";
...
const transition = mayStartViewTransition(...);
transition.ready.then(() => {
setVectors();
transition.types.add("with-vectors");
})

This adds the --vtbag-vector-* custom properties to the page as shown in the last section.

For Firefox it is important to avoid accessing the pseudo-properties too early. This is why we set the with-vectors view transition type after the vectors are calculated. The type is then used to guard the arc animation. Otherwise the @keyframes would initialize before the custom-properties are calculated. Alternatively, you could use a CSS class or an HTML attribute and check for its existence from CSS. But hey, we are doing view transitions and have a polyfill for types if they are not supported ;-)

While such a pattern is necessary for Firefox it does no harm for the other browser.

With the guard, the CSS rules can now access the values as soon as they are available and calculate derived parameters for the animations.

:active-view-transition-type(with-vectors)::view-transition-image-pair(.el) {
animation-name: arc;
animation-timing-function: ease-in-out;
}
@keyframes arc {
50% {
transform: translateX(
calc(var(--vtbag-vector-to-y) / 3 - var(--vtbag-vector-from-y) / 3)
);
}
}

Since we are using a view-transition-class in the selector, this CSS rule applies animations to multiple pseudo-elements. Each image pair gets its own version of the keyframe definition, inheriting the custom-property values from its parent, the ::view-transition-group pseudo-element. The var() expressions are evaluated once when the keyframes are defined. Changing the values of the custom properties later will not affect the keyframes or the animations.

By default, setVectors() will do this for every morph animation, i.e. for every view transition group that holds both, the old and the new image.

To optimize the size of the generated CSS for the custom-properties, the setVectors() function accepts two parameters:

setVectors(instructions: Inst[], where: "root" | "pseudo" | "both" = "both")

The instructions parameter can be used to exclude groups and properties. Each instruction has a regular expression that selects group names, and an array of property names. An instruction tells the function to set pseudo properties for the given property names if the regular expression completely matches the group name.

The default instructions are [{pattern: ".*", props: ["x", "y", "width", "height"]}]. This adds properties for x, y, width, and height to all groups. If you, for example, only want custom-properties for x and y for groups where the name starts with elt-, use [{pattern: "elt-.*", props: ["x", "y"]}].

The where parameter tells the function whether the pseudo properties should be added to the ::view-transition-group pseudo, to the :root element, or to both.

experimental

Copy dom-view-transitions-level2.d.ts to your src directory if you are missing declarations for the new types and interfaces.

declare global {
type UpdateCallback = undefined | (() => void | Promise<void>);
type StartViewTransitionParameter = {
types?: string[] | Set<string>;
update?: UpdateCallback;
};
interface Document {
startViewTransition?(
param?: StartViewTransitionParameter | UpdateCallback
): ViewTransition;
}
interface PageSwapEvent extends Event {
viewTransition?: ViewTransition;
activation?: NavigationActivation;
}
interface PageRevealEvent extends Event {
viewTransition?: ViewTransition;
}
interface WindowEventMap {
pageswap: PageSwapEvent;
pagereveal: PageRevealEvent;
}
interface NavigationActivation {
entry: NavigationHistoryEntry;
from: NavigationHistoryEntry;
navigationType: NavigationTypeString;
}
interface AnimationEffect {
target: HTMLElement;
pseudoElement?: string;
getKeyframes: () => Keyframe[];
}
interface ViewTransition {
types?: Set<string>;
}
interface Window {
navigation?: Navigation;
}
interface Navigation {
activation: NavigationActivation;
}
}
export {};

experimental

More than just a wrapper. This function upgrades your view transitions with advanced capabilities and seamless fallbacks.

Terminal window
npm i -D @vtbag/utensil-drawer
import { mayStartViewTransition } from "@vtbag/utensil-drawer/may-start-view-transition";
const transition = mayStartViewTransition(...);

This function provides quite different functionalities.

  1. It wraps document.startViewTransition() to free you from checking whether the browser supports the View Transition API. You can call it the same way in any browser, whether it supports Level 2, Level 1, or nothing at all.

  2. It includes built-in support for users who prefer reduced motion, automatically suppressing transitions based on system or browser settings.

  3. The View Transition API cancels active view transitions when a new one starts. mayStartViewTransition provides alternatives to prevent these cancellations. Here is what’s supported:

    • If the old images have not been captured yet, the function can automatically merge additional update callbacks into the current view transition. This means multiple UI components can trigger transitions close together, and they will seamlessly act as one.

    • If the old images were already captured, the function queues new update callbacks and types and runs them once the current view transitions finishes, combining them into a single update function for a new view transition.

    • It can also skip starting a new transition if one is already running, letting the active one take the lead for a smoother result.

In the simplest use case, mayStartViewTransition works as a drop-in replacement for document.startViewTransition. Like the Level 2 API, it accepts either an update callback or an options object with a callback and view transition types. If the browser only supports the old syntax with the callback, the parameters are mapped accordingly. Types are lost in this case.

When the View Transition API is not supported at all, mayStartViewTransition() falls back gracefully by calling the update function directly and returning a compatible object with updateCallbackDone, ready and finished promises.

You can also pass a second options parameter for advanced behavior not available in the native API:

export interface StartViewTransitionExtensions {
respectReducedMotion?: boolean; // default is true
collisionBehavior?: 'skipOld' | 'chaining' | 'skipNew' | 'never'; // default is "skipOld"
speedUpWhenChained?: number; // default is 1.0
useTypesPolyfill?: 'always' | 'auto' | 'never';
}

mayStartViewTransition is useful when you need to manage concurrent calls to startViewTransition. Common use cases include debouncing transitions triggered by user events, or allowing UI components to start view transitions independently, without requiring them to coordinate or be manually integrated into a single transition.

The Derived Trajectories demo uses mayStartViewTransitions to ensure that rapid clicks triggering element shuffles do not cancel the current view transition animation.

button.addEventListener("click", (e) => {
mayStartViewTransition(shuffle, {
collisionBehavior: "chaining",
});
});

If the user clicked several times during the animation, mayStartViewTransition queues the shuffle callbacks and executes them together in a single update function for a subsequent view transition.

The Game of Life uses the same technique to decouple the calculation of new generations from their rendering. If generations are requested faster than the view transition can display them, mayStartViewTransition automatically queues the update callbacks. You can quickly fill the queue on desktop by “clicking” the NEXT button using the keyboard auto-repeat. You should then see the queued request processed in batches. Visually, life proceeds skipping several generations at a time.

The Tower of Hanoi demo, covered by its own episode of Fun with View Transitions makes multiple independent calls to mayStartViewTransition. One animates the disk movements, another updates the progress bar and counters. A separate view transition runs when the puzzle is solved, and yet another comes in when switching between light and dark mode.

These transitions are conceptually and technically independent. Some share the same trigger, but not all. From the user’s perspective, the result appears as a single coordinated transition, even though there is no shared or global app code managing synchronization.

ProgressContainer
...
mayStartViewTransition(updateProgress, {collisionBehavior: "chaining"});
...
MoveDisks
...
mayStartViewTransition(moveDisks, {collisionBehavior: "chaining"});
...

Assume the ProgressContainer code runs before the MoveDisk code. ProgressContainer calls mayStarViewTransition(), which eventually invokes the browser’s document.startViewTransition(). That creates the result object containing the view transition promises and hands off image capturing and update callback execution to the browser’s render loop.

If MoveDisk calls mayStartViewTransition before the next render loop runs, the function can automatically extend the current view transition to include MoveDisk’s update callback (and, optionally, its types). This mechanism is not limited to just two calls.

In fact, as long as your code does not return control to the macro-task queue, the browser will not re-enter the render loop, and will not start capturing the old images. During that time, all your update callbacks can be included in a single view transition.

Just keep in mind that the browser might lose patience and skip the transition if the (combined) update callback takes too long. Chromium based browsers seem to have a limit of around four seconds, but really, who wants to block rendering for that long? The best practice is handle any time-consuming preparations before starting the view transition, and keep update callback as short and simple a possible, ideally as a synchronous function.

When respectReducedMotion is enabled, mayStartViewTransition behaves as if native view transitions are not supported if the user has turned on reduced motion at the operation system or browser level.

When collisionBehavior is set to "chaining", calls to mayStartViewTransition do not interrupt active view transitions.

Instead, while the old screenshots are not captured yet, the updates are merged into the current transition. Calls arriving after capturing old images are queued and played back in a new view transition that begins automatically once the current one has finished.

Like before, but not combining updates that arrive before the capturing. Updates arriving while a view transition is running are always queued.

Triggering a new call during an ongoing transition can also be used to speed up the current animation if speedUpWhenChained is set to a value greater than 1.

Setting collisionBehavior to skipNew means a new view transition will only start if none is currently active. Otherwise, the update callback is executed immediately as if view transitions weren’t supported and without affecting the ongoing view transition.

Setting collisionBehavior to skipOld give you the default behavior of view transitions, where new transitions cancel active ones.

Setting collisionBehavior to never prevents any collisions by executing only the update callback, skipping pseudo-element creation and animation.

For details see may-start-view-transition.ts

Setting useTypesPolyfill: "always" adds a polyfill for view transition types. This bring view transition types even to browsers that only support Level 1 of the View Transition API.

mayStartViewTransition(
{ update, types: ["my", "type"] },
{ useTypesPolyfill: "always" }
);

The polyfill replaces view transition types with CSS classes set on the :root element during view transitions. As an example, a type called type-name is replaced with a class called vtbag-vtt-type-name, where the vtbag-vtt- prefix avoids name clashes with regular CSS classes. While the view transition is active, this option also sets a vtbag-vtt-0 class on the :root element as a pendant for :active-view-transition.

Setting useTypesPolyfill: "never" completely disables the polyfill. This is the default.
Setting useTypesPolyfill: "auto" enables the polyfill in browsers that do support Level 1 but not Level 2 of the View Transition API.

In your stylesheets, you can have your :active-view-transition-type() and :active-view-transition selectors automatically be rewritten to checks for the inserted classes by using the Bag’s postcss-active-view-transition-type PostCSS plugin.

The module also exports getCurrentViewTransition() to access the active global view transition object. Iff the function returns undefined, no view transition is currently active.

Want to use :active-view-transition-type() and :active-view-transition pseudo-classes with browsers that do not support them natively? This is typically the case when there is a time lag between Level 1 and Level 2 implementations of the API. Some browser versions support same-document view transitions (Level 1) but not view transition types (Level 2). Presumably, this will happen again, when Firefox releases its first version of View Transition API support.

The postcss-active-view-transition-type plugin addresses view transition types for same-document view transitions. Cross-document view transitions are also Level 2, so they do not need additional support.

This PostCSS plugin rewrites the pseudo-class selectors :active-view-transition-type() and :active-view-transition to selectors that work with the Bag’s polyfill for view transition types. Plugin and polyfill together let you use

even if those types are not natively supported on all browsers that implement same-document view transitions.

Install the PostCSS plugin from npm:

Terminal window
npm i -D postcss-active-view-transition-type

Add the plugin to your PostCSS config. If you use vite (or a framework based on vite, like Astro), vite will automatically pick up a postcss.config.cjs in your project home, if it exists.

postcss.config.cjs
module.exports = {
plugins: [
require("postcss-active-view-transition-type"),
// or
// require("postcss-active-view-transition-type")({mode: 'append'})
],
};

The plugin support a mode option:

  • "in-place" (the default) rewrites the selectors in place,
  • "append" appends the rewritten rules to the original stylesheet.

The recommended value depends on your setting of the useTypesPolyfill extension when calling mayStartViewTransition: Use "in-place" for useTypesPolyfill: "always". Use "append" for useTypesPolyfill: "auto".

The following table shows how to combine the plugin and the polyfill parameters in more detail.

When to useuseTypesPolyfillpostcss-active-view-transition-type mode
Activate the polyfill only where needed and have original and rewritten rules in your CSS’auto''append’
You can avoid duplication of rules if you do not use types with cross-document view transitions and all same-document view transitions are started using mayStartViewTransition() with useTypesPlugin: 'always'’always''in-place’
If you do not want to use the polyfill’never’n.a.