Skip to content

Commit f4effe4

Browse files
authored
Merge pull request #7391 from GrandSchtroumpf/v2-view-transition
Implement ViewTransition in v2
2 parents ee042b8 + 2dfd443 commit f4effe4

File tree

6 files changed

+198
-60
lines changed

6 files changed

+198
-60
lines changed

.changeset/sweet-bees-punch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@qwik.dev/router': patch
3+
---
4+
5+
Implement View Transition on SPA navigation

packages/docs/src/routes/docs/cookbook/view-transition/index.mdx

Lines changed: 106 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,29 @@ contributors:
55
---
66

77
# View Transition API
8+
89
By default Qwik will start a view transition when SPA navigation. We can run animation either with CSS or WAAPI.
910

1011
## CSS
12+
1113
```tsx
1214
export default component$(({ list }) => {
1315
return (
1416
<ul>
1517
{list.map((item) => (
1618
// Create a name per item
17-
<li key={item.id} class="item" style={{viewTransitionName: `_${item.id}_`}}>...</li>
19+
<li key={item.id} class="item" style={{ '--view-transition-name': `_${item.id}_` }}>
20+
...
21+
</li>
1822
))}
1923
</ul>
20-
)
21-
})
24+
);
25+
});
2226
```
2327

2428
```css
2529
.item {
30+
view-transition-name: var(--view-transition-name);
2631
/* Alias to target all .item with a view-transition-name */
2732
view-transition-class: animated-item;
2833
}
@@ -34,35 +39,50 @@ export default component$(({ list }) => {
3439
::view-transition-old(.animated-item):only-child {
3540
animation: fade-out 200ms;
3641
}
42+
43+
/* If view transition type is supported */
44+
@supports selector(html:active-view-transition-type(type)) {
45+
/* Remove view transition name when view transition is not "qwik-navigation" */
46+
html:not(:active-view-transition-type(qwik-navigation)) .items {
47+
view-transition-name: none;
48+
}
49+
}
3750
```
3851

39-
Sometime we need to have some specific logic before the animation start. In this case you can listen to the `qviewTransition` event.
52+
Sometime we need to have some specific logic before the animation start. In this case you can listen to the `qviewtransition` event.
4053

4154
For example if you want to only animate visible element:
55+
4256
```tsx
4357
export default component$(() => {
4458
// In this case we need the callback to be sync, else the transition might have already happened
45-
useOnDocument('qviewTransition', sync$((event: CustomEvent<ViewTransition>) => {
46-
const transition = event.detail;
47-
const items = document.querySelectorAll('.item');
48-
for (const item of items) {
49-
if (!item.checkVisibility()) continue;
50-
item.dataset.hasViewTransition = true;
51-
}
52-
}))
59+
useOnDocument(
60+
'qviewtransition',
61+
sync$((event: CustomEvent<ViewTransition>) => {
62+
const transition = event.detail;
63+
const items = document.querySelectorAll('.item');
64+
for (const item of items) {
65+
if (!item.checkVisibility()) continue;
66+
item.dataset.hasViewTransition = true;
67+
}
68+
})
69+
);
5370
return (
5471
<ul>
5572
{list.map((item) => (
5673
// Create a name per item
57-
<li key={item.id} class="item" style={{viewTransitionName: `_${item.id}_`}}>...</li>
74+
<li key={item.id} class="item" style={{ '--view-transition-name': `_${item.id}_` }}>
75+
...
76+
</li>
5877
))}
5978
</ul>
60-
)
61-
})
79+
);
80+
});
6281
```
6382

6483
```css
65-
.item[data-has-view-transition="true"] {
84+
.item[data-has-view-transition='true'] {
85+
view-transition-name: var(--view-transition-name);
6686
view-transition-class: animated-item;
6787
}
6888
::view-transition-new(.animated-item):only-child {
@@ -71,14 +91,24 @@ export default component$(() => {
7191
::view-transition-old(.animated-item):only-child {
7292
animation: fade-out 200ms;
7393
}
94+
95+
/* If view transition type is supported */
96+
@supports selector(html:active-view-transition-type(type)) {
97+
/* Remove view transition name when view transition is not "qwik-navigation" */
98+
html:not(:active-view-transition-type(qwik-navigation)) .items[data-has-view-transition='true'] {
99+
view-transition-name: none;
100+
}
101+
}
74102
```
75103

76104
> **Note**: `ViewTransition` interface is available with Typescript >5.6.
77105
78106
## WAAPI
107+
79108
With Web Animation API you can get more precise, but for that we need to wait for the ::view-transition pseudo-element to exist in the DOM. To achieve that you can wait the `transition.ready` promise.
80109

81110
In this example we add some delay for each item :
111+
82112
```tsx
83113
export default component$(() => {
84114
// Remove default style on the pseudo-element.
@@ -90,41 +120,72 @@ export default component$(() => {
90120
animation: none;
91121
}
92122
`);
93-
useOnDocument('qviewTransition', $(async (event: CustomEvent<ViewTransition>) => {
94-
// Get visible item's viewTransitionName (should happen before transition is ready)
95-
const items = document.querySelectorAll<HTMLElement>('.item');
96-
const names = Array.from(items)
97-
.filter((item) => item.checkVisibility())
98-
.map((item) => item.style.viewTransitionName);
99-
100-
// Wait for ::view-transition pseudo-element to exist
101-
const transition = event.detail;
102-
await transition.ready;
103-
104-
// Animate each leaving item
105-
for (let i = 0; i < names.length; i++) {
106-
// Note: we animate the <html> element
107-
document.documentElement.animate({
108-
opacity: 0,
109-
transform: 'scale(0.9)'
110-
}, {
111-
// Target the pseudo-element inside the <html> element
112-
pseudoElement: `::view-transition-old(${names[i]})`,
113-
duration: 200,
114-
fill: "forwards",
115-
delay: i * 50, // Add delay for each pseudo-element
116-
})
117-
}
118-
}))
123+
useOnDocument(
124+
'qviewtransition',
125+
$(async (event: CustomEvent<ViewTransition>) => {
126+
// Get visible item's viewTransitionName (should happen before transition is ready)
127+
const items = document.querySelectorAll<HTMLElement>('.item');
128+
const names = Array.from(items)
129+
.filter((item) => item.checkVisibility())
130+
.map((item) => item.style.viewTransitionName);
131+
132+
// Wait for ::view-transition pseudo-element to exist
133+
const transition = event.detail;
134+
await transition.ready;
135+
136+
// Animate each leaving item
137+
for (let i = 0; i < names.length; i++) {
138+
// Note: we animate the <html> element
139+
document.documentElement.animate(
140+
{
141+
opacity: 0,
142+
transform: 'scale(0.9)',
143+
},
144+
{
145+
// Target the pseudo-element inside the <html> element
146+
pseudoElement: `::view-transition-old(${names[i]})`,
147+
duration: 200,
148+
fill: 'forwards',
149+
delay: i * 50, // Add delay for each pseudo-element
150+
}
151+
);
152+
}
153+
})
154+
);
119155
return (
120156
<ul>
121157
{list.map((item) => (
122158
// Create a name per item
123-
<li key={item.id} class="item" style={{viewTransitionName: `_${item.id}_`}}>...</li>
159+
<li key={item.id} class="item" style={{ viewTransitionName: `_${item.id}_` }}>
160+
...
161+
</li>
124162
))}
125163
</ul>
126-
)
127-
})
164+
);
165+
});
128166
```
129167

168+
When listening on the `qviewTransition` we know that
169+
130170
> **Note**: For it to work correctly, we need to **remove the default view transition** animation else it happens on top of the `.animate()`. I'm using `view-transition-class` which is only working with Chrome right now.
171+
172+
## Root transition
173+
174+
By default Qwik disables root view transition inside the `@layer qwik`. If you want to enable it you need to reset it :
175+
176+
```css
177+
:root {
178+
view-transition-name: root;
179+
}
180+
```
181+
182+
or within a `@layer` :
183+
184+
```css
185+
@layer qwik, reset;
186+
@layer reset {
187+
:root {
188+
view-transition-name: root;
189+
}
190+
}
191+
```

packages/qwik-router/src/runtime/src/qwik-router-component.tsx

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import type {
6666
import { loadClientData } from './use-endpoint';
6767
import { useQwikRouterEnv } from './use-functions';
6868
import { isSameOrigin, isSamePath, toUrl } from './utils';
69+
import { startViewTransition } from './view-transition';
6970

7071
/**
7172
* @deprecated Use `QWIK_ROUTER_SCROLLER` instead (will be removed in V3)
@@ -122,7 +123,18 @@ const internalState = { navCount: 0 };
122123

123124
/** @public */
124125
export const QwikRouterProvider = component$<QwikRouterProps>((props) => {
125-
useStyles$(`:root{view-transition-name:none}`);
126+
useStyles$(`
127+
@layer qwik {
128+
@supports selector(html:active-view-transition-type(type)) {
129+
html:active-view-transition-type(qwik-router-spa) {
130+
:root{view-transition-name:none}
131+
}
132+
}
133+
@supports not selector(html:active-view-transition-type(type)) {
134+
:root{view-transition-name:none}
135+
}
136+
}
137+
`);
126138
const env = useQwikRouterEnv();
127139
if (!env?.params) {
128140
throw new Error(
@@ -452,11 +464,6 @@ export const QwikRouterProvider = component$<QwikRouterProps>((props) => {
452464
documentHead.frontmatter = resolvedHead.frontmatter;
453465

454466
if (isBrowser) {
455-
if (props.viewTransition !== false) {
456-
// mark next DOM render to use startViewTransition API
457-
(document as any).__q_view_transition__ = true;
458-
}
459-
460467
let scrollState: ScrollState | undefined;
461468
if (navType === 'popstate') {
462469
scrollState = getScrollHistory();
@@ -653,8 +660,26 @@ export const QwikRouterProvider = component$<QwikRouterProps>((props) => {
653660
saveScrollHistory(scrollState);
654661
}
655662

656-
clientNavigate(window, navType, prevUrl, trackUrl, replaceState);
657-
_waitUntilRendered(elm as Element).then(() => {
663+
const navigate = () => {
664+
clientNavigate(window, navType, prevUrl, trackUrl, replaceState);
665+
return _waitUntilRendered(elm as Element);
666+
};
667+
668+
const _waitNextPage = () => {
669+
if (isServer || props.viewTransition === false) {
670+
return navigate();
671+
} else {
672+
const viewTransition = startViewTransition({
673+
update: navigate,
674+
types: ['qwik-navigation'],
675+
});
676+
if (!viewTransition) {
677+
return Promise.resolve();
678+
}
679+
return viewTransition.ready;
680+
}
681+
};
682+
_waitNextPage().then(() => {
658683
const container = _getQContainerElement(elm as _ElementVNode)!;
659684
container.setAttribute('q:route', routeName);
660685
const scrollState = currentScrollState(scroller);
@@ -670,11 +695,11 @@ export const QwikRouterProvider = component$<QwikRouterProps>((props) => {
670695
}
671696
}
672697
}
673-
const promise = run();
698+
674699
if (isServer) {
675-
return promise;
700+
return run();
676701
} else {
677-
return;
702+
run();
678703
}
679704
});
680705

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// This types are missing in current typescript version: 5.4.5
2+
3+
interface StartViewTransitionOptions {
4+
types?: string[] | null;
5+
update?: ViewTransitionUpdateCallback | null;
6+
}
7+
8+
interface DocumentViewTransition extends Omit<Document, 'startViewTransition'> {
9+
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/startViewTransition) */
10+
startViewTransition(
11+
callbackOptions?: ViewTransitionUpdateCallback | StartViewTransitionOptions
12+
): ViewTransition;
13+
}
14+
15+
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/ViewTransition) */
16+
interface ViewTransition {
17+
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/ViewTransition/finished) */
18+
readonly finished: Promise<undefined>;
19+
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/ViewTransition/ready) */
20+
readonly ready: Promise<undefined>;
21+
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/ViewTransition/updateCallbackDone) */
22+
readonly updateCallbackDone: Promise<undefined>;
23+
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/ViewTransition/skipTransition) */
24+
skipTransition(): void;
25+
types?: Set<string>;
26+
}
27+
28+
export const startViewTransition = (params: StartViewTransitionOptions) => {
29+
if (!params.update) {
30+
return;
31+
}
32+
if ('startViewTransition' in document) {
33+
let transition: ViewTransition;
34+
try {
35+
// Typed transition starts with Chrome 125 & Safari 18
36+
transition = (document as DocumentViewTransition).startViewTransition(params);
37+
} catch {
38+
// Fallback for Chrome 111 until Chrome 125
39+
transition = (document as DocumentViewTransition).startViewTransition(params.update);
40+
}
41+
const event = new CustomEvent('qviewtransition', { detail: transition });
42+
document.dispatchEvent(event);
43+
return transition;
44+
} else {
45+
params.update?.();
46+
}
47+
};

packages/qwik/src/core/shared/jsx/types/jsx-qwik-attributes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ type AllEventMapRaw = HTMLElementEventMap &
114114
qinit: QwikInitEvent;
115115
qsymbol: QwikSymbolEvent;
116116
qvisible: QwikVisibleEvent;
117-
qviewTransition: QwikViewTransitionEvent;
117+
qviewtransition: QwikViewTransitionEvent;
118118
};
119119

120120
/** This corrects the TS definition for ToggleEvent @public */

starters/e2e/qwikrouter/nav.e2e.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ test.describe("actions", () => {
105105

106106
await scrollDetector1;
107107
await expect(page.locator("h1")).toHaveText("Page Short");
108-
expect(page).toHaveURL(
108+
await expect(page).toHaveURL(
109109
"/qwikrouter-test/scroll-restoration/page-short/",
110110
);
111111
expect(await getWindowScrollXY(page)).toStrictEqual([0, 0]);
@@ -124,7 +124,7 @@ test.describe("actions", () => {
124124

125125
await scrollDetector2;
126126
await expect(page.locator("h1")).toHaveText("Page Long");
127-
expect(page).toHaveURL(
127+
await expect(page).toHaveURL(
128128
"/qwikrouter-test/scroll-restoration/page-long/",
129129
);
130130
expect(await getWindowScrollXY(page)).toStrictEqual([
@@ -137,7 +137,7 @@ test.describe("actions", () => {
137137

138138
await scrollDetector3;
139139
await expect(page.locator("h1")).toHaveText("Page Short");
140-
expect(page).toHaveURL(
140+
await expect(page).toHaveURL(
141141
"/qwikrouter-test/scroll-restoration/page-short/",
142142
);
143143
expect(await getWindowScrollXY(page)).toStrictEqual([

0 commit comments

Comments
 (0)