1. Basic Cross-Document View Transition#
Earlier before this feature official go live, you have to add the meta-tag: <meta name="view-transition" content="same-origin">
, but this is no longer the case, as view transition api has been updated to use a css-based at-rule @view-transition
.
In the @view-transition
at-rule, set the navigation
descriptor to auto
to enable view transitions for cross-document, same-origin navigations. Also you might have noticed the @media
at-rule wrapping the transition, this is for respcting user’s prefers-reduced-motion setting (e.g. MacOS when you have “Reduce Motion” on it will not use view transtion)
1
2
3
4
5
6
7
| @media (prefers-reduced-motion: no-preference) {
@view-transition { navigation: auto; } /* By default the view transition use css fade in/out animation */
}
::view-transition-group(*) { /* Apply a animation curve for all the view transition animation groups */
animation-duration: 0.3s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
|

Note that during the animation, things will not be clickable/interactable, this is to prevent the user atttempting to interact with the component during its transition state. Imaging if there’s a button that will get transitioned into an image wrapped in <a>
tag, if the user will to click on its semi-state, should the action trigger the button’s onclick
function or open the link in the <a>
tag ?
2. Customize View Transition Animation#
You may customize your view transition aniamtion via the normal css animation, for instance you can customize the all view transition animation’s speed and timing function in ::view-transition-group(*)
(the astrik sign):
1
2
3
4
| ::view-transition-group(*) {
animation-duration: 0.3s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
|
And in the below example, we will replace the default cross fade animation with a scale in/out
and slide in/out
animation:
1
2
3
4
5
6
| /* Transition the whole page with the scale in/out animation */
::view-transition-group(root) { animation-duration: 0.3s; }
::view-transition-old(root) { animation-name: scale-out; }
::view-transition-new(root) { animation-name: scale-in; }
@keyframes scale-in { from{scale: 0;} }
@keyframes scale-out { to {scale: 0;} }
|

1
2
3
4
5
6
| /* Transition the whole page with theslide in/out animation */
::view-transition-group(root) { animation-duration: 0.3s; }
::view-transition-old(root) { animation-name: slide-out; }
::view-transition-new(root) { animation-name: slide-in; }
@keyframes slide-in { from{transform:translateX(+100vw);} }
@keyframes slide-out { to {transform:translateX(-100vw);} }
|

In the previous demo we’ve demonstrated you can customize the view transition animation on a page level using ::view-transition-group(root)
. However between the pages, only the content is changing (and need transition), the header (and the footer) component are rather static, and hence don’t really need the dramatic animations.
In fact, we can control the view transition on a more grainular level, to have different animations for elements/components:
1
2
3
| /* You may simply surpress the transition via giving a transition-name (note that the header don't shift anymore)*/
body > div > header { view-transition-name: header; }
body > div > footer.footer.container { view-transition-name: footer; }
|

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| /* You may also give different animation for different component in the page */
/* Transition for the header section only */
body > div > header { view-transition-name: header; }
::view-transition-group(header) { animation-duration: 0.8s; }
::view-transition-old(header) { animation-name: header-collapse; }
::view-transition-new(header) { animation-name: header-expand; }
@keyframes header-collapse { to {transform:translateY(-50vw);} }
@keyframes header-expand { from {transform:translateY(-50vw);} }
/* Transition for the header section only */
body > div > footer.footer.container { view-transition-name: footer; }
::view-transition-group(footer) { animation-duration: 0.8s; }
::view-transition-old(footer) { animation-name: footer-collapse; }
::view-transition-new(footer) { animation-name: footer-expand ; }
@keyframes footer-collapse { to {transform:translateY(+50vw);} }
@keyframes footer-expand { from {transform:translateY(+50vw);} }
|

4. View Transition on Different Element (Title / Image)#
Besides for specifying animation for component that are rather static between different pages, you may also have transition animation for component that changes in position and size. Looking at the home page of the above example, in the list view each article have a title and a thumbnail image; Similarly in the article page, each article also have both title and the thumbnail image, but in different position and size (the intrinsic html component are also different).
To enable the view transition for title/image from home page to the aritcle page, we need to specify the transition-name
for the title/image as pair (i.e. Article-X’s title on home page will have the same transition-name
as the title on the article page) .
1
2
3
4
5
6
7
8
9
10
| /*For image*/
html:has(link[href*="/node/1"]) .page-node-type-article .content .field--name-field-image > img,
.path-frontpage .view-display-id-page_1 .view-content a[href*="/node/1"] > img {
view-transition-name: image-article-1;
}
/*For title*/
html:has(link[href*="/node/1"]) .page-node-type-article h1.page-header:has(span),
.path-frontpage .main-container .view-display-id-page_1 .view-content a[href*="/node/1"][rel="bookmark"]:has(span){
view-transition-name: title-article-1;
}
|

NOTE: when you have more than one component having the same transition-name
on the source/target page, the view transition for this compoennt will no longer work (will default back to the fading animation), because the api can no longer determine the one-to-one corresponding relationship between the components.
As you might have noticed, since each paired component’s view transition name have to be unique, if we want to apply the same animation for all images/titles, we fall into the condition where we have to define the transition name for every pairs of image/title:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| /*For image*/
html:has(link[href*="/node/1"]) .page-node-type-article .content .field--name-field-image > img,
.path-frontpage .view-display-id-page_1 .view-content a[href*="/node/1"] > img { view-transition-name: image-article-1; }
html:has(link[href*="/node/2"]) .page-node-type-article .content .field--name-field-image > img,
.path-frontpage .view-display-id-page_1 .view-content a[href*="/node/2"] > img { view-transition-name: image-article-2; }
html:has(link[href*="/node/3"]) .page-node-type-article .content .field--name-field-image > img,
.path-frontpage .view-display-id-page_1 .view-content a[href*="/node/3"] > img { view-transition-name: image-article-3; }
/*For title*/
html:has(link[href*="/node/1"]) .page-node-type-article h1.page-header:has(span),
.path-frontpage .main-container .view-display-id-page_1 .view-content a[href*="/node/1"][rel="bookmark"]:has(span){ view-transition-name: title-article-1; }
html:has(link[href*="/node/2"]) .page-node-type-article h1.page-header:has(span),
.path-frontpage .main-container .view-display-id-page_1 .view-content a[href*="/node/2"][rel="bookmark"]:has(span){ view-transition-name: title-article-2; }
html:has(link[href*="/node/3"]) .page-node-type-article h1.page-header:has(span),
.path-frontpage .main-container .view-display-id-page_1 .view-content a[href*="/node/3"][rel="bookmark"]:has(span){ view-transition-name: title-article-3; }
|
At the moment there’s no quick way of mitigating it except for using JavaScript, of which will be mentioned in the next sections.
5. Batch Applying View Transition using JavaScript#
NOTE: In order to page-reveal and page-swap listeners to work, this script must be loaded in the <header>
section; So if you are in a drupal context, you might want to have the following in your theme’s .library.yml
file:
1
2
3
4
5
6
7
| // -------------------------------------
// | xxyyzz_library: |
// | version: -1 |
// | header: true | ← This is the important bit
// | js: |
// | js/view_transition.js: {} |
// -------------------------------------
|
As shown in the previous section, it is not practically convinient to apply transition for all paired component systematically using just CSS, we need the help from the JavaScript event pageswap
and pagereveal
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
| // ========================= (OLD Page ViewTransition Logic)
// === FROM PAGE LISTENER == (Customize the Outgoing Animations)
// =========================
window.addEventListener('pageswap', async (event) => {
// Check if view transitions are supported
if (event.viewTransition) {
// Handle transitions from front page
if (check_frontPage(document)) {
console.log("TRANSITION FROM: [front page]");
const oldUrl = new URL(event.activation.from.url).pathname;
const newUrl = new URL(event.activation.entry.url).pathname;
// Find the image element that matches the target URL
const imgSelector = `.path-frontpage .view-display-id-page_1 .view-content a[href*="${newUrl}"] > img`;
// Generate unique transition class name
const imgTransitionClass = `image-article-${newUrl.split('/').pop()}-${Math.floor(Math.random() * 1000)}`;
const imgElement = document.querySelector(imgSelector);
// Apply transition class if image found
if (imgElement) {
imgElement.style.viewTransitionName = imgTransitionClass;
// Store transition class for the receiving page
localStorage.setItem("TransitionClass_Image", imgTransitionClass);
}
}
// Handle transitions from article pages
else if (check_articlePage(document)) {
console.log("TRANSITION FROM: [article page]");
const oldUrl = new URL(event.activation.from.url).pathname;
const newUrl = new URL(event.activation.entry.url).pathname;
// Find image element in article page
const imgSelector = `html:has(link[href*="${newUrl}"]) .page-node-type-article .content .field--name-field-image > img`;
const imgTransitionClass = `image-article-${newUrl.split('/').pop()}-${Math.floor(Math.random() * 1000)}`;
const imgElement = document.querySelector(imgSelector);
if (imgElement) {
imgElement.style.viewTransitionName = imgTransitionClass;
// Store transition class for return to front page
localStorage.setItem("TransitionClass_Image_BACK", imgTransitionClass);
}
}
}
});
// ======================= (NEW Page Viewtransition Logic)
// === TO PAGE LISTENER == (Customize the Incoming Animations)
// =======================
window.addEventListener('pagereveal', async (event) => {
// Check if view transitions are supported
if (event.viewTransition) {
// Handle transitions to article pages
if (check_articlePage(document)) {
console.log("TRANSITION TO : [article page]");
const oldUrl = new URL(navigation.activation.from.url).pathname;
const newUrl = new URL(navigation.activation.entry.url).pathname;
// Find target image in article
const imgSelector = `html:has(link[href*="${newUrl}"]) .page-node-type-article .content .field--name-field-image > img`;
// Get stored transition class from previous page
const imgTransitionClass = localStorage.getItem('TransitionClass_Image');
const imgElement = document.querySelector(imgSelector);
if(imgElement && imgTransitionClass){
// Clean up stored transition class
localStorage.removeItem('TransitionClass_Image');
// Apply transition and wait for completion
imgElement.style.viewTransitionName = imgTransitionClass;
await event.viewTransition.finished;
// Remove transition name after animation
imgElement.style.viewTransitionName = '';
}
}
// Handle transitions back to front page
else if (check_frontPage(document)) {
console.log("TRANSITION TO : [front page]");
const oldUrl = new URL(navigation.activation.from.url).pathname;
const newUrl = new URL(navigation.activation.entry.url).pathname;
// Find target image on front page
const imgSelector = `.path-frontpage .view-display-id-page_1 .view-content a[href*="${oldUrl}"] > img`;
// Get stored transition class from article page
const imgTransitionClass = localStorage.getItem('TransitionClass_Image_BACK');
const imgElement = document.querySelector(imgSelector);
if(imgElement && imgTransitionClass){
// Clean up stored transition class
localStorage.removeItem('TransitionClass_Image_BACK');
// Apply transition and wait for completion
imgElement.style.viewTransitionName = imgTransitionClass;
await event.viewTransition.finished;
// Remove transition name after animation
imgElement.style.viewTransitionName = '';
}
}
}
});
// =======================
// === HELPER FUNCTIONS ==
// =======================
const check_frontPage = (document) => {
return document.querySelector('body.path-frontpage') !== null;
}
const check_articlePage = (document) => {
return document.querySelector('body.page-node-type-article') !== null;
}
|

6. Animation Depending on Type of Transition using JavaScript#
Similarly you can also apply different animation to transition between differnt type of pages, via conditionally adding data attribute to the root <html>
tag to denote the transition direction, and have css to apply different transition-name
depending on the attribute selector [data-attribute=X] / [data-attribute=Y]
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
|
// ========================= (OLD Page ViewTransition Logic)
// === FROM PAGE LISTENER == (Customize the Outgoing Animations)
// =========================
// Page swap will be triggered when a page is about to be swapped/disabled
// (IE the page that is about to be discarded after the view transition take place)
window.addEventListener('pageswap', async (event) => {
// If the view transition is not supported or not enabled, return
if(!event.viewTransition){console.log("View transition not supported - pageswap"); return;}
// Get the old page and new page
const old_url = new URL(event.activation.from.url).pathname;
const new_url = new URL(event.activation.entry.url ).pathname;
console.log(`old_url [${old_url}] new_url [${new_url}]`);
// Handle transition animation for different transition
const transition_direction =check_direction(old_url, new_url);
if(transition_direction === -1){ document.documentElement.dataset.direction = "backward"; } // (Moving backward) Transition animation from child to parent
else if(transition_direction === 1){ document.documentElement.dataset.direction = "forward"; } // (Moving forward) Transition animation from parent to child
else{ document.documentElement.dataset.direction = "horizontal"; } // (Moving horizontally) Transition at a same level
// Cleanup the data-direction attribute when the transition is complete
await event.viewTransition.finished;
delete document.documentElement.dataset.direction;
});
// ======================= (NEW Page Viewtransition Logic)
// === TO PAGE LISTENER == (Customize the Incoming Animations)
// =======================
// Page reveal will be triggered when a page is about to be revealed/enabled
// (IE the page that is about to be displayed after the view transition take place)
window.addEventListener('pagereveal', async (event) => {
// If the view transition is not supported or not enabled, return
if(!event.viewTransition){console.log("View transition not supported - pagereveal"); return;}
// Get the old page and new page
const old_url = new URL(navigation.activation.from.url).pathname;
const new_url = new URL(navigation.activation.entry.url ).pathname;
console.log(`old_url [${old_url}] new_url [${new_url}]`);
// Handle transition animation for different transition
const transition_direction =check_direction(old_url, new_url);
if(transition_direction === -1){ document.documentElement.dataset.direction = "backward"; } // (Moving backward) Transition animation from child to parent
else if(transition_direction === 1){ document.documentElement.dataset.direction = "forward"; } // (Moving forward) Transition animation from parent to child
else{ document.documentElement.dataset.direction = "horizontal"; } // (Moving horizontally) Transition at a same level
// Cleanup the data-direction attribute when the transition is complete
await event.viewTransition.finished;
delete document.documentElement.dataset.direction;
});
// =======================
// === HELPER FUNCTIONS ==
// =======================
// Check the direction of the transition
const check_direction = (old_url, new_url) => {
if (old_url === new_url) { return 0; }
if (old_url === '/' && new_url.includes('/node/')) { return 1; }
if (old_url.includes('/node/') && new_url === '/') { return -1; }
if (old_url.includes('/node/') && new_url.includes('/node/')) { return 0; }
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| /* Some Animations to use later on */
@keyframes slide-in-forward { from{transform:translateX(+100vw);} }
@keyframes slide-out-forward { to {transform:translateX(-100vw);} }
@keyframes slide-in-backward { from{transform:translateX(-100vw);} }
@keyframes slide-out-backward { to {transform:translateX(+100vw);} }
@keyframes slide-in-horizontal { from{transform:translateY(+100vw);} }
@keyframes slide-out-horizontal { to {transform:translateY(-100vw);} }
@keyframes scale-in-horizontal { from{scale: 0;} }
@keyframes scale-out-horizontal { to {scale: 0;} }
/* Target animation at a ROOT level*/
[data-direction="forward"]::view-transition-old(root) {animation: slide-out-forward 0.3s ease-in-out; }
[data-direction="forward"]::view-transition-new(root) {animation: slide-in-forward 0.3s ease-in-out; }
[data-direction="backward"]::view-transition-old(root) {animation: slide-out-backward 0.3s ease-in-out; }
[data-direction="backward"]::view-transition-new(root) {animation: slide-in-backward 0.3s ease-in-out; }
[data-direction="horizontal"]::view-transition-old(root) {animation: scale-out-horizontal 0.3s ease-in-out; }
[data-direction="horizontal"]::view-transition-new(root) {animation: scale-in-horizontal 0.3s ease-in-out; }
/* Alternatively, to achieve the same effect
you can also target certain elements with the data-direction attribute*/
/* html[data-direction="forward"] div[role="main"] { view-transition-name: forward; } */
/* html[data-direction="backward"] div[role="main"] { view-transition-name: backward; } */
/* html[data-direction="horizontal"] div[role="main"] { view-transition-name: horizontal; } */
/* ::view-transition-old(forward) { animation-name: slide-out-forward; } */
/* ::view-transition-new(forward) { animation-name: slide-in-forward; } */
/* ::view-transition-old(backward) { animation-name: slide-out-backward; } */
/* ::view-transition-new(backward) { animation-name: slide-in-backward; } */
/* ::view-transition-old(horizontal) { animation-name: scale-out-horizontal; } */
/* ::view-transition-new(horizontal) { animation-name: scale-in-horizontal; } */
|

Reference#
Official Documentations (MDN)
Official Documentation (Google)
Some YouTube Tutorials