Skip to content

Commit

Permalink
Initial View Transition Support (#7511)
Browse files Browse the repository at this point in the history
* Basic support

* Add the fade transition

* Move CSS into a separate file

* Add transition name

* View Transitions changeset

* Replace the boolean transition with 'morph'

* Update to use `transition:animate`

* Use head propagation

* Move CSS into a separate file

* Add builtin animations and namespaced module

* Misquote

* Remove unused code

* Add automatic prefetching to the View Transitions router

* Use a data attribute for back nav animations

* Use [data-astro-transition]

* Add view transitions to examples

* Wait on the HTML response before calling startViewTransition

* Updated stuff

* Update the compiler

* Fix

* Fallback support

* Properly do fallback

* Simplify the selectors

* Put viewTransitions support behind a flag

* Upgrade the compiler

* Remove unused import

* Add tests

* Use an explicit import instead of types

* Fix case where the click comes from within nested content

* Fix linting

* Add a test for the back button

* Prevent glitch in fallback

* Do not combine selectors

* Fallback to MPA nav if there is an issue fetching

* Fallback swap if there are no animations

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update packages/astro/components/ViewTransitions.astro

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>

* Update packages/astro/components/ViewTransitions.astro

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>

* Update the changeset

* PR review changes

* Update more based on review comments.

* Update the updateDOM default

* Pass in transitions options to the compiler

* Update broken tests

* Update .changeset/silly-garlics-live.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update .changeset/silly-garlics-live.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update .changeset/silly-garlics-live.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update .changeset/silly-garlics-live.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* h2 -> h4

* Upgrade to stable compiler

* Remove exp redirects from sitemap

* Remove usage from examples

* Remove example updates

---------

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
  • Loading branch information
3 people committed Jul 19, 2023
1 parent eafe996 commit 6a12fce
Show file tree
Hide file tree
Showing 30 changed files with 779 additions and 21 deletions.
62 changes: 62 additions & 0 deletions .changeset/silly-garlics-live.md
@@ -0,0 +1,62 @@
---
'astro': minor
---

Built-in View Transitions Support (experimental)

Astro now supports [view transitions](https://developer.chrome.com/docs/web-platform/view-transitions/) through the new `<ViewTransitions />` component and the `transition:animate` (and associated) directives. View transitions are a great fit for content-oriented sites, and we see it as the best path to get the benefits of client-side routing (smoother transitions) without sacrificing the more simple mental model of MPAs.

Enable support for view transitions in Astro 2.9 by adding the experimental flag to your config:

```js
import { defineConfig } from 'astro/config';

export default defineConfig({
experimental: {
viewTransitions: true,
},
})
```

This enables you to use the new APIs added.

#### <ViewTransitions />

This is a component which acts as the *router* for transitions between pages. Add it to the `<head>` section of each individual page where transitions should occur *in the client* as you navigate away to another page, instead of causing a full page browser refresh. To enable support throughout your entire app, add the component in some common layout or component that targets the `<head>` of every page.

__CommonHead.astro__

```astro
---
import { ViewTransitions } from 'astro:transitions';
---
<meta charset="utf-8">
<title>{Astro.props.title}</title>
<ViewTransitions />
```

With only this change, your app will now route completely in-client. You can then add transitions to individual elements using the `transition:animate` directive.

#### Animations

Add `transition:animate` to any element to use Astro's built-in animations.

```astro
<header transition:animate="slide">
```

In the above, Astro's `slide` animation will cause the `<header>` element to slide out to the left, and then slide in from the right when you navigate away from the page.

You can also customize these animations using any CSS animation properties, for example, by specifying a duration:

```astro
---
import { slide } from 'astro:transition';
---
<header transition:animate={slide({ duration: 200 })}>
```

#### Continue learning

Check out the [client-side routing docs](https://docs.astro.build/en/guides/client-side-routing/) to learn more.
2 changes: 1 addition & 1 deletion examples/basics/src/layouts/Layout.astro
@@ -1,4 +1,4 @@
---
---
interface Props {
title: string;
}
Expand Down
1 change: 0 additions & 1 deletion examples/portfolio/src/components/MainHead.astro
Expand Up @@ -25,7 +25,6 @@ const {
href="https://fonts.googleapis.com/css2?family=Public+Sans:ital,wght@0,400;0,700;1,400&family=Rubik:wght@500;600&display=swap"
rel="stylesheet"
/>

<script is:inline>
// This code is inlined in the head to make dark mode instant & blocking.
const getThemePreference = () => {
Expand Down
9 changes: 9 additions & 0 deletions packages/astro/client-base.d.ts
Expand Up @@ -70,6 +70,15 @@ declare module 'astro:assets' {
export const { getImage, getConfiguredImageService, Image }: AstroAssets;
}

declare module 'astro:transitions' {
type TransitionModule = typeof import('./dist/transitions/index.js');
export const slide: TransitionModule['slide'];
export const fade: TransitionModule['fade'];

type ViewTransitionsModule = typeof import('./components/ViewTransitions.astro');
export const ViewTransitions: ViewTransitionsModule['default'];
}

type MD = import('./dist/@types/astro').MarkdownInstance<Record<string, any>>;
interface ExportedMarkdownModuleEntities {
frontmatter: MD['frontmatter'];
Expand Down
146 changes: 146 additions & 0 deletions packages/astro/components/ViewTransitions.astro
@@ -0,0 +1,146 @@
---
type Fallback = 'none' | 'animate' | 'swap';
export interface Props {
fallback?: Fallback;
}
const { fallback = 'animate' } = Astro.props as Props;
---
<meta name="astro-view-transitions-enabled" content="true">
<meta name="astro-view-transitions-fallback" content={fallback}>
<script>
type Fallback = 'none' | 'animate' | 'swap';
type Direction = 'forward' | 'back';

// The History API does not tell you if navigation is forward or back, so
// you can figure it using an index. On pushState the index is incremented so you
// can use that to determine popstate if going forward or back.
let currentHistoryIndex = history.state?.index || 0;
if(!history.state) {
history.replaceState({index: currentHistoryIndex}, document.title);
}

const supportsViewTransitions = !!document.startViewTransition;
const transitionEnabledOnThisPage = () => !!document.querySelector('[name="astro-view-transitions-enabled"]');

async function getHTML(href: string) {
const res = await fetch(href)
const html = await res.text();
return { ok: res.ok, html };
}

function getFallback(): Fallback {
const el = document.querySelector('[name="astro-view-transitions-fallback"]');
if(el) {
return el.getAttribute('content') as Fallback;
}
return 'animate';
}

const parser = new DOMParser();

async function updateDOM(dir: Direction, html: string, fallback?: Fallback) {
const doc = parser.parseFromString(html, 'text/html');
doc.documentElement.dataset.astroTransition = dir;
const swap = () => document.documentElement.replaceWith(doc.documentElement);

if(fallback === 'animate') {
let isAnimating = false;
addEventListener('animationstart', () => isAnimating = true, { once: true });

// Trigger the animations
document.documentElement.dataset.astroTransitionFallback = 'old';
doc.documentElement.dataset.astroTransitionFallback = 'new';
// If there are any animations, want for the animationend event.
addEventListener('animationend', swap, { once: true });
// If there are no animations, go ahead and swap on next tick
// This is necessary because we do not know if there are animations.
// The setTimeout is a fallback in case there are none.
setTimeout(() => !isAnimating && swap());
} else {
swap();
}
}

async function navigate(dir: Direction, href: string) {
let finished: Promise<void>;
const { html, ok } = await getHTML(href);
// If there is a problem fetching the new page, just do an MPA navigation to it.
if(!ok) {
location.href = href;
return;
}
if(supportsViewTransitions) {
finished = document.startViewTransition(() => updateDOM(dir, html)).finished;
} else {
finished = updateDOM(dir, html, getFallback());
}
try {
await finished;
} finally {
document.documentElement.removeAttribute('data-astro-transition');
}
}

// Prefetching
function maybePrefetch(pathname: string) {
if(document.querySelector(`link[rel=prefetch][href="${pathname}"]`)) return;
if(navigator.connection){
let conn = navigator.connection;
if(conn.saveData || /(2|3)g/.test(conn.effectiveType || '')) return;
}
let link = document.createElement('link');
link.setAttribute('rel', 'prefetch');
link.setAttribute('href', pathname);
document.head.append(link);
}

if(supportsViewTransitions || getFallback() !== 'none') {
document.addEventListener('click', (ev) => {
let link = ev.target;
if(link instanceof Element && link.tagName !== 'A') {
link = link.closest('a');
}
// This check verifies that the click is happening on an anchor
// that is going to another page within the same origin. Basically it determines
// same-origin navigation, but omits special key combos for new tabs, etc.
if (link &&
link instanceof HTMLAnchorElement &&
link.href &&
(!link.target || link.target === '_self') &&
link.origin === location.origin &&
ev.button === 0 && // left clicks only
!ev.metaKey && // new tab (mac)
!ev.ctrlKey && // new tab (windows)
!ev.altKey && // download
!ev.shiftKey &&
!ev.defaultPrevented &&
transitionEnabledOnThisPage()
) {
ev.preventDefault();
navigate('forward', link.href);
currentHistoryIndex++;
history.pushState({index: currentHistoryIndex}, '', link.href);
}
});
window.addEventListener('popstate', () => {
if(!transitionEnabledOnThisPage()) return;
const nextIndex = history.state?.index ?? (currentHistoryIndex + 1);
const direction: Direction = nextIndex > currentHistoryIndex ? 'forward' : 'back';
navigate(direction, location.href);
currentHistoryIndex = nextIndex;
});

['mouseenter', 'touchstart', 'focus'].forEach(evName => {
document.addEventListener(evName, ev => {
if(ev.target instanceof HTMLAnchorElement) {
let el = ev.target;
if(el.origin === location.origin && el.pathname !== location.pathname && transitionEnabledOnThisPage()) {
maybePrefetch(el.pathname);
}
}
}, { passive: true, capture: true });
});
}
</script>
1 change: 1 addition & 0 deletions packages/astro/components/index.ts
@@ -1,2 +1,3 @@
export { default as Code } from './Code.astro';
export { default as Debug } from './Debug.astro';
export { default as ViewTransitions } from './ViewTransitions.astro';
32 changes: 32 additions & 0 deletions packages/astro/components/viewtransitions.css
@@ -0,0 +1,32 @@
@keyframes astroFadeInOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

@keyframes astroFadeIn {
from { opacity: 0; }
}

@keyframes astroFadeOut {
to { opacity: 0; }
}

@keyframes astroSlideFromRight {
from { transform: translateX(100%); }
}

@keyframes astroSlideFromLeft {
from { transform: translateX(-100%); }
}

@keyframes astroSlideToRight {
to { transform: translateX(100%); }
}

@keyframes astroSlideToLeft {
to { transform: translateX(-100%); }
}
8 changes: 8 additions & 0 deletions packages/astro/e2e/fixtures/view-transitions/astro.config.mjs
@@ -0,0 +1,8 @@
import { defineConfig } from 'astro/config';

// https://astro.build/config
export default defineConfig({
experimental: {
viewTransitions: true,
},
});
8 changes: 8 additions & 0 deletions packages/astro/e2e/fixtures/view-transitions/package.json
@@ -0,0 +1,8 @@
{
"name": "@e2e/view-transitions",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}
@@ -0,0 +1,17 @@
---
import { ViewTransitions } from 'astro:transitions';
---
<html>
<head>
<title>Testing</title>
<ViewTransitions />
</head>
<body>
<header transition:animate="morph">
<h1>testing</h1>
</header>
<main transition:animate="slide">
<slot />
</main>
</body>
</html>
12 changes: 12 additions & 0 deletions packages/astro/e2e/fixtures/view-transitions/src/pages/four.astro
@@ -0,0 +1,12 @@
---
import Layout from '../components/Layout.astro';
---
<Layout>
<p id="four">Page 4</p>
<a id="click-one" href="/one">
<div>
Nested
<span>go to 1</span>
</div>
</a>
</Layout>
@@ -0,0 +1,8 @@
---
import Layout from '../components/Layout.astro';
---
<Layout>
<p id="one">Page 1</p>
<a id="click-two" href="/two">go to 2</a>
<a id="click-three" href="/three">go to 3</a>
</Layout>
11 changes: 11 additions & 0 deletions packages/astro/e2e/fixtures/view-transitions/src/pages/three.astro
@@ -0,0 +1,11 @@
<html>
<head>
<title>Page 3</title>
</head>
<body>
<main>
<p id="three">Page 3</p>
<a id="click-two" href="/two">go to 2</a>
</main>
</body>
</html>
@@ -0,0 +1,6 @@
---
import Layout from '../components/Layout.astro';
---
<Layout>
<p id="two">Page 2</p>
</Layout>

0 comments on commit 6a12fce

Please sign in to comment.