Skip to content

Commit 923db4e

Browse files
committed
feat: add Pointer Driven View Transitions documentation and associated styles
1 parent 80d9758 commit 923db4e

4 files changed

Lines changed: 184 additions & 101 deletions

File tree

public/pointer-driver-og.png

168 KB
Loading
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
title: Pointer Driven View Transitions
3+
description: "Learn how to create interactive view transitions that adapt in real time to pointer movement."
4+
draft: true
5+
head:
6+
- tag: meta
7+
attrs:
8+
property: "og:image"
9+
content: "/pointer-driven-og.png"
10+
- tag: meta
11+
attrs:
12+
property: "draft"
13+
content: "true"
14+
---
15+
Wouldn't it be cool to have images of whatever complex DOM subtrees of your current page that you can bend and move around as you like?
16+
17+
One way to look at the view transition api is this: The API generates snapshots that you can use for animations _…in whatever way you want_.
18+
19+
20+
**In a nutshell**
21+
* You name the DOM elements you want snapshots for.
22+
* The View Transition API generates the images for your DOM nodes, no matter how complex the subtree is. And they come in two flavors: bitmaps plus captured CSS properties for the old image and replaced elements for the new images.
23+
* Those images are rendered in a layer above everything else. It is easy to move your images freely across the viewport. Even more fun with element-scoped view transitions.
24+
* The magic lasts as long as there exists at least some animation on one of the generated pseudo elements. Doesn't have to be a CSS animation, CSS transitions and WAAPI animations work as well. You can even provide a promise to the API to wait for.
25+
* If you want, you can cancel unnecessary default animations in your stylesheet. or you keep them and scrub through their timeline by assigning to the currentTime property of their animations.
26+
27+
## Benefits of Animating Images instead of DOM Subtrees
28+
Moving real subtrees through the DOM for animations might quickly become a complex task when the elements are nested in the general layout. . Moving images that render above the normal elements is much easier. Libraries for taking screenshots of DOM elements exist, but they are usually not very accurate. New techniques like
29+
30+
Ok, that was a lot of information. To see what is possible with this approach, take a look at the [Forge](https://vtbag.dev/forge) project. It is a small web application that allows you to drag and drop elements into a forge and combine them into new elements. The Forge uses the View Transition API to animate the elements as they are dragged around.
31+
32+
Here are the details step by step.
33+
34+
## Drag-Driven View Transitions
35+
36+
Grab and drag your transition pseudo-elements.
37+
38+
The natural way to find out how to forge new elements in the example is to just try it out. We want to visually move the element from the centre into one of the forges to see what happens.
39+
40+
This is very similar to the typical drag and drop code:
41+
```ts title="Drag and Drop"
42+
```

src/pages/forge/_main.ts

Lines changed: 68 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -21,30 +21,6 @@ const chambers = document.querySelectorAll<HTMLDivElement>(".chamber")!;
2121
const breadcrumbs =
2222
document.querySelector<HTMLDivElement>(".breadcrumbs")!;
2323

24-
const effects = [
25-
`
26-
filter: saturate(2.5) brightness(1.2) hue-rotate(340deg);
27-
transform: skewX(-3deg);
28-
`,
29-
`
30-
filter: saturate(0.2) brightness(1.2) hue-rotate(190deg);
31-
transform: skewX(3deg);
32-
`,
33-
`
34-
filter: saturate(3) brightness(1.2) contrast(1.3);
35-
transform: scaleY(0.5) scaleX(1.5) skewX(3deg);
36-
`,
37-
`
38-
filter: saturate(3) hue-rotate(90deg) brightness(1.3) contrast(1.2);
39-
transform: skewX(-3deg);
40-
`,
41-
];
42-
const shadows = [
43-
`box-shadow: inset 0 0 30px rgba(255, 0,0, 0.6), 0 0 20px rgba(255, 230, 200, 0.8);`,
44-
`box-shadow: inset 0 0 30px rgba(0, 210, 255, 0.6), 0 0 20px rgba(135, 206, 250, 0.8);`,
45-
`box-shadow: inset 0 0 30px rgba(0, 210, 255, 0.6), 0 0 20px rgba(135, 206, 250, 0.8);`,
46-
`box-shadow: inset 0 0 35px rgba(0, 210, 255, 0.6), 0 0 60px rgba(135, 206, 250, 0.8);`,
47-
];
4824
const styles = new CSSStyleSheet();
4925
document.adoptedStyleSheets.push(styles);
5026

@@ -65,6 +41,7 @@ const moveState = {
6541
moving: false,
6642
pointerId: -1,
6743
raf: 0,
44+
lastWhere: -2,
6845
};
6946

7047
resetForge();
@@ -114,9 +91,10 @@ function turnElement(icon?: string, name?: string) {
11491
mayStartViewTransition(
11592
{
11693
update: () => {
117-
const node = levelState.elements[levelState.elements.length - 1]!;
118-
elementImage.innerText = icon || node.icon;
119-
elementName.innerText = name || node.name;
94+
const last = levelState.elements.length - 1;
95+
const node = levelState.elements[last];
96+
elementImage.innerText = icon || node?.icon || elementImage.innerText;
97+
elementName.innerText = name || node?.name || elementName.innerText;
12098

12199
},
122100
types: ["shuffle"],
@@ -126,12 +104,11 @@ function turnElement(icon?: string, name?: string) {
126104
}
127105
function passiveUpdateElement() {
128106
const node = levelState.elements[levelState.elements.length - 1]!;
129-
elementImage.innerText = node.icon;
130-
elementName.innerText = node.name;
107+
elementImage.textContent = node.icon;
108+
elementName.textContent = node.name;
131109
}
132110

133111
async function initializeLevel() {
134-
styles.replaceSync(``);
135112
write("");
136113
disableGame();
137114
levelState.tree = (levelState.level + levelState.random) % data.length;
@@ -152,7 +129,7 @@ async function initializeLevel() {
152129
levelState.elements.length = 0;
153130
const level = data[levelState.tree]!;
154131
let slot = 0;
155-
for (let i = 0; i < (reducedMotion() ? 1 : 4); i++) {
132+
for (let i = 0; i < (reducedMotion() ? 1 : 1); i++) {
156133
slot = ~~(Math.random() * level.items.length);
157134
while (level.items[slot]!.depth <= levelState.level) {
158135
slot = (slot + 1) % level.items.length;
@@ -216,26 +193,23 @@ function startMove(event: PointerEvent) {
216193
moveState.y = event.clientY;
217194
moveState.moving = true;
218195
moveState.pointerId = event.pointerId;
219-
styles.replaceSync(`
220-
::view-transition-group(element) {
221-
top: 0;
222-
left: 0;
223-
}`);
224-
mayStartViewTransition(
196+
moveState.lastWhere = -2;
197+
drag(0, 0, -1, 0);
198+
enableGame();
199+
const transition = mayStartViewTransition(
225200
{
226201
types: ["drag"],
227202
},
228203
{ useTypesPolyfill: "always", collisionBehavior: "chaining", respectReducedMotion: false },
229-
).finished.then(() => {
230-
styles.replaceSync(`
231-
::view-transition-group(.element) {
232-
top: 0px;
233-
left: 0px;
234-
}`);
235-
});
236-
levelState.elements.push(
237-
levelState.elements[levelState.elements.length - 1]!,
238204
);
205+
transition.ready.then(() => [...document.getAnimations()]
206+
.filter(animation => animation.effect?.pseudoElement?.startsWith("::view-transition"))
207+
.forEach((animation) => animation.pause()));
208+
transition.finished.finally(() => {
209+
styles.replaceSync("");
210+
turnElement();
211+
});
212+
levelState.elements.push(levelState.elements[levelState.elements.length - 1]!);
239213
}
240214

241215
function stopMove(event: PointerEvent) {
@@ -244,30 +218,28 @@ function stopMove(event: PointerEvent) {
244218
moveState.moving = false;
245219
moveState.pointerId = -1;
246220
disableGame();
247-
const current = levelState.elements.length - 1;
248-
const node = levelState.elements[current]!;
221+
let current = levelState.elements.length - 1;
222+
let node = levelState.elements[current]!;
249223
if (
250224
node.name === "Invalid Reaction" ||
251225
node.name === levelState.elements[current - 1]?.name
252226
) {
253227
levelState.elements.pop();
254-
passiveUpdateElement();
228+
--current;
229+
node = levelState.elements[current]!;
255230
}
256-
document.getAnimations().forEach((animation) => animation.play());
257-
if (reducedMotion()) {
258-
styles.replaceSync(`
259-
::view-transition-group(.element) {
260-
top: 0px;
261-
left: 0px;
262-
}`);
263-
} else {
264-
styles.replaceSync(`
265-
::view-transition-group(.element) {
266-
top: 0px;
267-
left: 0px;
268-
transition: top 0.3s cubic-bezier(0.4, 0, 0.2, 1), left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
269-
}`);
231+
document.documentElement.classList.add("back");
232+
if (!reducedMotion()) {
233+
styles.replaceSync(``);
270234
}
235+
drag(0, 0, -1, 0);
236+
237+
document.getAnimations().forEach((animation) => animation.play());
238+
setTimeout(() => {
239+
document.getAnimations().forEach((animation) => animation.finish());
240+
document.documentElement.classList.remove("back");
241+
}, 300);
242+
271243
updateBreadcrumbs();
272244
chambers.forEach((chamber) => chamber.classList.remove("selected"));
273245
write("");
@@ -308,61 +280,62 @@ function move(event: PointerEvent) {
308280
}
309281

310282
function work() {
311-
312-
313283
const { where, distance, top, left } = geometry(moveState.top, moveState.left);
314-
315284
chambers.forEach((chamber) => chamber.classList.remove("selected"));
316285
if (where !== -1) {
317286
chambers[where]!.classList.add("selected");
318287
}
319288
const current = levelState.elements.length - 1;
320289
const next = levelState.elements[current - 1]?.reactions[where] ?? -1;
321-
if (next !== -1) {
322-
levelState.elements[current] = data[levelState.tree]!.items[next]!;
323-
passiveUpdateElement();
324-
} else {
325-
levelState.elements[current] =
326-
where === -1
290+
levelState.elements[current] =
291+
next !== -1
292+
? data[levelState.tree]!.items[next]!
293+
: where === -1
327294
? levelState.elements[current - 1]!
328295
: {
329296
icon: "⛔",
330297
name: "Invalid Reaction",
331-
reactions: [],
298+
reactions: [-1, -1, -1, -1],
332299
depth: 0,
333300
parent: -1,
334301
};
335-
passiveUpdateElement();
336-
}
302+
passiveUpdateElement();
303+
337304
drag(top, left, where, distance);
338-
scrub(distance);
305+
const time = (distance - 0.33) * 409;
306+
document.documentElement.style.setProperty("--extend", "" + Math.max(0, Math.min(1, time / 250)));
307+
scrub([
308+
"::view-transition-new(element)",
309+
"::view-transition-old(element)",
310+
"::view-transition-new(element-image)",
311+
"::view-transition-old(element-image)",
312+
], time);
339313
}
340314

341-
function scrub(distance: number) {
315+
function scrub(pseudoElement: string | string[], time: number) {
342316
document.getAnimations().forEach((animation) => {
343-
if (animation.effect?.pseudoElement?.startsWith(
344-
"::view-transition-new(element"
345-
) ||
346-
animation.effect?.pseudoElement?.startsWith(
347-
"::view-transition-old(element"
348-
)) {
349-
animation.currentTime = Math.min(248, (distance - 0.5) * 240);
317+
if (Array.isArray(pseudoElement)) {
318+
if (pseudoElement.includes(animation.effect?.pseudoElement ?? "")) {
319+
animation.currentTime = time;
320+
}
321+
} else {
322+
if (animation.effect?.pseudoElement === pseudoElement) {
323+
animation.currentTime = time;
324+
}
350325
}
351326
});
352327
}
353328

354329
function drag(top: number, left: number, where: number, distance: number) {
355-
styles.replaceSync(`
356-
::view-transition-group(.element) {
357-
top: ${top}px;
358-
left: ${left}px;
359-
}
360-
::view-transition-new(element) {${effects[where] ?? ""}}
361-
::view-transition-new(element-image) {${effects[where] ?? ""}}
362-
::view-transition-new(element) {${shadows[where] ?? ""}}
363-
::view-transition-image-pair(.element) {
364-
scale: ${Math.min(2, 1 + distance / 2)};
365-
}`);
330+
document.documentElement.style.setProperty("--top", `${top}px`);
331+
document.documentElement.style.setProperty("--left", `${left}px`);
332+
document.documentElement.style.setProperty("--scale", `${Math.min(2, 1 + distance * 0.5)}`);
333+
334+
document.documentElement.dataset.where = where === 0 ? "furnace" : where === 1 ? "freezing" : where === 2 ? "pressure" : where === 3 ? "radiation" : "";
335+
if (where !== moveState.lastWhere) {
336+
console.log("where", where, "lastWhere", moveState.lastWhere);
337+
moveState.lastWhere = where;
338+
}
366339
}
367340

368341
function geometry(top: number, left: number) {

0 commit comments

Comments
 (0)