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↗.
stable
Declarative Names
Automatically assigns dynamic view transition names to HTML elements using CSS selectors.
stable
Escaped Custom Identifiers
Escape view transition names to unlock characters beyond basic ASCII letters and digits.
experimental
Set Morphing Vector
Brand new and experimental: provides the parameters of the morph animations as pseudo properties to your CSS rules
experimental
Interim Type Declarations
A temporary solution until Definitely Typed is ready and Level 2 definitions become available.
experimental
Hardened Start
Wrapper with a non-animating fallback for browsers without native support. Respects the reduced motion preference and supports optional, automatic chaining of view transitions.
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:
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>
<script setup>import { useHead } from '#imports'import declarativeNamesUrl from '@vtbag/utensil-drawer/declarative-names?url'
useHead({ script: [ { src: declarativeNamesUrl, attrs: { 'data-vtbag-decl': '...' } } ]})</script>
After installing the package from npm, copy it to a place where it can be served to the browser.
cp node_modules/@vtbag/utensil-drawer/lib/bundled/declarative-names.js public/declarative-name.js
<head> <script src="/declarative-names.js" data-vtbag-decl="..."></script></head>
Don’t forget to update the copy once you update the npm package!
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:
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.
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.
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.
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.
-
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. -
It includes built-in support for users who prefer reduced motion, automatically suppressing transitions based on system or browser settings.
-
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.
...mayStartViewTransition(updateProgress, {collisionBehavior: "chaining"});...
...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
↗
useTypesPolyfill: “always” | “auto” | “never”
Section titled “useTypesPolyfill: “always” | “auto” | “never””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.
The postcss-active-view-transition-type PostCSS Plugin
Section titled “The postcss-active-view-transition-type PostCSS Plugin”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
- pseudo-class selectors for view transition types in your stylesheets,
- as well as view transition types with
mayStartViewTransition({types:...})
,
even if those types are not natively supported on all browsers that implement same-document view transitions.
Install the PostCSS plugin from npm:
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.
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 use | useTypesPolyfill | postcss-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. |