Skip to content

Commit 0ed5c5a

Browse files
committed
Morph (WIP)
1 parent 633e041 commit 0ed5c5a

5 files changed

Lines changed: 177 additions & 0 deletions

File tree

demo/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ <h1>View Transitions Toolkit</h1>
1818
<li><a href="playback-control/">Playback Control</a></li>
1919
<li><a href="navigation-types/">Navigation Types</a></li>
2020
<li><a href="scroll-driven-view-transition/">Scroll-Driven View Transition</a></li>
21+
<li><a href="morph/">Morph</a></li>
2122
</ul>
2223
</body>
2324
</html>

demo/morph/index.html

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Morph Utility Demo</title>
7+
<style>
8+
:root {
9+
view-transition-name: none;
10+
}
11+
#card {
12+
view-transition-name: card;
13+
}
14+
#card a {
15+
view-transition-name: text;
16+
}
17+
::view-transition-group(*) {
18+
animation-duration: 2s;
19+
}
20+
21+
* {
22+
margin: 0;
23+
padding: 0;
24+
box-sizing: border-box;
25+
}
26+
27+
#card {
28+
display: block;
29+
padding: 2em;
30+
aspect-ratio: 16/9;
31+
width: min-content;
32+
place-content: center;
33+
text-align: center;
34+
margin: 0 auto;
35+
background: #ccc;
36+
border: 3px solid gray;
37+
border-radius: 0.25rem;
38+
}
39+
40+
#card.big {
41+
aspect-ratio: 4/1;
42+
font-size: 2em;
43+
background: lightblue;
44+
border-width: 8px;
45+
border-radius: 3rem;
46+
}
47+
48+
html, body {
49+
height: 100%;
50+
}
51+
body {
52+
place-content: center;
53+
}
54+
</style>
55+
<script type="module" src="./scripts.js"></script>
56+
</head>
57+
<body>
58+
<div id="card">
59+
<a href="#">Toggle</a>
60+
</div>
61+
</body>
62+
</html>

demo/morph/scripts.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { morph } from "../js/morph.js";
2+
3+
const $card = document.querySelector('#card');
4+
const $link = document.querySelector('#card a');
5+
6+
$link.addEventListener('click', async (e) => {
7+
e.preventDefault();
8+
9+
const t = document.startViewTransition(() => {
10+
$card.classList.toggle('big');
11+
});
12+
13+
await t.ready;
14+
15+
const css = morph(t, $card, [
16+
"border-radius",
17+
"border-width",
18+
"border-color",
19+
"background-color",
20+
// "aspect-ratio",
21+
// "font-size"
22+
]);
23+
24+
console.log(css);
25+
26+
if (css) {
27+
const style = document.createElement("style");
28+
style.textContent = `#card { font-family: monospace; } ${css}`;
29+
document.head.appendChild(style);
30+
31+
try {
32+
await t.finished;
33+
} finally {
34+
style.remove();
35+
}
36+
}
37+
});

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
"./misc": {
2828
"types": "./dist/misc.d.ts",
2929
"import": "./dist/misc.js"
30+
},
31+
"./morph": {
32+
"types": "./dist/morph.d.ts",
33+
"import": "./dist/morph.js"
3034
}
3135
},
3236
"files": [

src/morph.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Copyright 2026 Google LLC
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { getAnimations, ViewTransitionPart } from "./animations.js";
7+
8+
/**
9+
* Returns CSS needed to morph an element during a View Transition,
10+
* following the technique described in https://www.bram.us/2025/05/15/view-transitions-border-radius-revisited/
11+
*
12+
* @param vt The active ViewTransition
13+
* @param element The element to apply the morph to
14+
* @param properties A list of CSS properties to transition
15+
* @returns A string containing the required CSS, or an empty string if it couldn't be generated
16+
*/
17+
export function morph(
18+
vt: ViewTransition,
19+
element: HTMLElement,
20+
properties: string[]
21+
): string {
22+
// 1. Get view-transition-name
23+
const styles = window.getComputedStyle(element);
24+
const vtName = styles.viewTransitionName;
25+
26+
if (!vtName || vtName === "none") {
27+
console.warn("morph: Element has no view-transition-name. Skipping.");
28+
return "";
29+
}
30+
31+
// 2. Warn if element already has a transition
32+
const durations = styles.transitionDuration.split(",").map(d => parseFloat(d));
33+
if (durations.some(d => d > 0)) {
34+
console.warn("morph: Element already has a CSS transition applied. It will be overwritten by morph.");
35+
}
36+
37+
// 3. Get the duration and easing from the original ::view-transition-group
38+
const groupAnimations = getAnimations(vt, vtName, ViewTransitionPart.Group);
39+
if (groupAnimations.length === 0) {
40+
console.warn(`morph: Could not find ::view-transition-group animation for ${vtName}. Skipping.`);
41+
return "";
42+
}
43+
44+
const anim = groupAnimations[0];
45+
const effect = anim.effect as KeyframeEffect;
46+
const timing = effect.getComputedTiming();
47+
const durationMs = timing.duration;
48+
const easing = effect.getTiming().easing;
49+
50+
// 4. Generate the CSS string
51+
if (!element.id) {
52+
console.warn("morph: Element must have an ID to be targeted. Skipping.");
53+
return "";
54+
}
55+
56+
return `
57+
#${element.id} {
58+
transition-property: ${properties.join(", ")};
59+
transition-duration: ${durationMs}ms;
60+
transition-timing-function: ${easing};
61+
}
62+
63+
::view-transition-old(${vtName}) {
64+
display: none;
65+
}
66+
67+
::view-transition-new(${vtName}) {
68+
animation: none;
69+
width: 100%;
70+
height: 100%;
71+
}
72+
`.trim();
73+
}

0 commit comments

Comments
 (0)