diff --git a/docs/configuration.md b/docs/configuration.md index 288139b29..8dd939ecb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -23,6 +23,7 @@ This part of the configuration concerns anything that can affect the whole site. - `pageTitle`: title of the site. This is also used when generating the [[RSS Feed]] for your site. - `pageTitleSuffix`: a string added to the end of the page title. This only applies to the browser tab title, not the title shown at the top of the page. - `enableSPA`: whether to enable [[SPA Routing]] on your site. +- `enableViewTransitions`: whether to enable browser-native [[View Transitions]] on your site. - `enablePopovers`: whether to enable [[popover previews]] on your site. - `analytics`: what to use for analytics on your site. Values can be - `null`: don't use analytics; diff --git a/docs/features/View Transitions.md b/docs/features/View Transitions.md new file mode 100644 index 000000000..ace700baa --- /dev/null +++ b/docs/features/View Transitions.md @@ -0,0 +1,63 @@ +--- +title: "View Transitions" +--- + +View Transitions provide smooth, animated page transitions when navigating between pages in Quartz. This progressively enhances the [[SPA Routing]] experience by utilizing the browser-native [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) where available. + +When navigating between pages, if the View Transitions API is supported and enabled, Quartz will wrap the content update in a view transition. This creates smooth, customizable animations as the page content changes, providing a more polished and app-like experience. + +Under the hood, Quartz uses [[SPA Routing]] to intercept link clicks and perform client-side navigation. The View Transitions feature builds on top of this by wrapping the DOM updates in `document.startViewTransition()`, allowing the browser to automatically animate the changes between the old and new page states. + +> [!info] +> View Transitions require [[SPA Routing]] to be enabled in the [[configuration]]. Browsers that don't support the View Transitions API will fall back to instant page updates without animations. + +## Customization + +Quartz assigns appropriate `view-transition-name` properties to its component, allowing you to create custom animations for different parts of the page. + +### Styling Transitions + +You can customize the behavior of these transitions by adding CSS to your `quartz/styles/custom.scss` file. For example: + +```scss title="quartz/styles/custom.scss" +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 0.5s; +} +``` + +For more advanced customization options, see the [MDN documentation on customizing view transition animations](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API/Using#customizing_your_animations). + +### Programmatic Transitions + +When [[creating components|creating your own components]], you can use the `startViewTransition` utility function to wrap DOM updates in a view transition. This ensures your custom scripts respect your view transition settings. + +```typescript +import { startViewTransition } from "./scripts/util" + +// Wrap your DOM update in a view transition +startViewTransition(() => { + // Your DOM updates here + element.textContent = "New content" +}) +``` + +The `startViewTransition` function will automatically use the View Transitions API if the browser is supported, otherwise it will fallback to eager execution. + +## Configuration + +View Transitions require both [[SPA Routing]] and the View Transitions feature to be enabled in the [[configuration]]: + +1. Enable [[SPA Routing]]: set the `enableSPA` field to `true` +2. Enable View Transitions: set the `enableViewTransitions` field to `true` + +```typescript title="quartz.config.ts" +const config: QuartzConfig = { + configuration: { + // ... + enableSPA: true, + enableViewTransitions: true, + // ... + }, +} +``` diff --git a/quartz.config.ts b/quartz.config.ts index b3db3d60d..598884f50 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -11,6 +11,7 @@ const config: QuartzConfig = { pageTitle: "Quartz 4", pageTitleSuffix: "", enableSPA: true, + enableViewTransitions: false, enablePopovers: true, analytics: { provider: "plausible", diff --git a/quartz/cfg.ts b/quartz/cfg.ts index c97d613bb..b84bb9ed8 100644 --- a/quartz/cfg.ts +++ b/quartz/cfg.ts @@ -61,6 +61,12 @@ export interface GlobalConfiguration { pageTitleSuffix?: string /** Whether to enable single-page-app style rendering. this prevents flashes of unstyled content and improves smoothness of Quartz */ enableSPA: boolean + /** + * `enableSPA: true` required; Whether to enable browser-native view transitions for single-page-app style rendering. + * + * Customizing your animations: https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API/Using#customizing_your_animations. + */ + enableViewTransitions: boolean /** Whether to display Wikipedia-style popovers when hovering over links */ enablePopovers: boolean /** Analytics mode */ diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx index 2c1bf5e75..e8fff006d 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -262,7 +262,7 @@ export function renderPage( const doc = ( - +
{LeftComponent} diff --git a/quartz/components/scripts/spa.inline.ts b/quartz/components/scripts/spa.inline.ts index 22fcd72b4..225648d49 100644 --- a/quartz/components/scripts/spa.inline.ts +++ b/quartz/components/scripts/spa.inline.ts @@ -1,6 +1,6 @@ import micromorph from "micromorph" import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path" -import { fetchCanonical } from "./util" +import { fetchCanonical, startViewTransition } from "./util" // adapted from `micromorph` // https://github.com/natemoo-re/micromorph @@ -102,32 +102,34 @@ async function _navigate(url: URL, isBack: boolean = false) { html.body.appendChild(announcer) // morph body - micromorph(document.body, html.body) + startViewTransition(() => { + micromorph(document.body, html.body) - // scroll into place and add history - if (!isBack) { - if (url.hash) { - const el = document.getElementById(decodeURIComponent(url.hash.substring(1))) - el?.scrollIntoView() - } else { - window.scrollTo({ top: 0 }) + // scroll into place and add history + if (!isBack) { + if (url.hash) { + const el = document.getElementById(decodeURIComponent(url.hash.substring(1))) + el?.scrollIntoView() + } else { + window.scrollTo({ top: 0 }) + } } - } - // now, patch head, re-executing scripts - const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])") - elementsToRemove.forEach((el) => el.remove()) - const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])") - elementsToAdd.forEach((el) => document.head.appendChild(el)) + // now, patch head, re-executing scripts + const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])") + elementsToRemove.forEach((el) => el.remove()) + const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])") + elementsToAdd.forEach((el) => document.head.appendChild(el)) - // delay setting the url until now - // at this point everything is loaded so changing the url should resolve to the correct addresses - if (!isBack) { - history.pushState({}, "", url) - } + // delay setting the url until now + // at this point everything is loaded so changing the url should resolve to the correct addresses + if (!isBack) { + history.pushState({}, "", url) + } - notifyNav(getFullSlug(window)) - delete announcer.dataset.persist + notifyNav(getFullSlug(window)) + delete announcer.dataset.persist + }) } async function navigate(url: URL, isBack: boolean = false) { diff --git a/quartz/components/scripts/util.ts b/quartz/components/scripts/util.ts index f71790104..c3ab774c3 100644 --- a/quartz/components/scripts/util.ts +++ b/quartz/components/scripts/util.ts @@ -44,3 +44,17 @@ export async function fetchCanonical(url: URL): Promise { const [_, redirect] = text.match(canonicalRegex) ?? [] return redirect ? fetch(`${new URL(redirect, url)}`) : res } + +/** + * Wraps a DOM update in a View Transition if supported by the browser and enabled in config. + * Falls back to immediate execution if the API is unavailable or disabled. + */ +export function startViewTransition(callback: () => void): void { + const enableViewTransitions = document.body.dataset.viewTransitions === "true" + + if (enableViewTransitions && document.startViewTransition) { + document.startViewTransition(() => callback()) + } else { + callback() + } +} diff --git a/quartz/components/styles/backlinks.scss b/quartz/components/styles/backlinks.scss index 478e118d2..b3e094a7e 100644 --- a/quartz/components/styles/backlinks.scss +++ b/quartz/components/styles/backlinks.scss @@ -2,6 +2,7 @@ .backlinks { flex-direction: column; + view-transition-name: backlinks; & > h3 { font-size: 1rem; diff --git a/quartz/components/styles/comments.scss b/quartz/components/styles/comments.scss new file mode 100644 index 000000000..285cbadf2 --- /dev/null +++ b/quartz/components/styles/comments.scss @@ -0,0 +1,3 @@ +.giscus { + view-transition-name: giscus; +} diff --git a/quartz/components/styles/darkmode.scss b/quartz/components/styles/darkmode.scss index b328743d4..6c5980fff 100644 --- a/quartz/components/styles/darkmode.scss +++ b/quartz/components/styles/darkmode.scss @@ -9,6 +9,7 @@ margin: 0; text-align: inherit; flex-shrink: 0; + view-transition-name: darkmode; & svg { position: absolute; diff --git a/quartz/components/styles/explorer.scss b/quartz/components/styles/explorer.scss index bc3335347..3633d07af 100644 --- a/quartz/components/styles/explorer.scss +++ b/quartz/components/styles/explorer.scss @@ -30,9 +30,10 @@ display: flex; flex-direction: column; overflow-y: hidden; - min-height: 1.2rem; flex: 0 1 auto; + view-transition-name: explorer; + &.collapsed { flex: 0 1 1.2rem; & .fold { diff --git a/quartz/components/styles/footer.scss b/quartz/components/styles/footer.scss index 9c8dbf8c1..cf696f4bb 100644 --- a/quartz/components/styles/footer.scss +++ b/quartz/components/styles/footer.scss @@ -2,6 +2,7 @@ footer { text-align: left; margin-bottom: 4rem; opacity: 0.7; + view-transition-name: footer; & ul { list-style: none; diff --git a/quartz/components/styles/graph.scss b/quartz/components/styles/graph.scss index cb1b7b464..53a5fa553 100644 --- a/quartz/components/styles/graph.scss +++ b/quartz/components/styles/graph.scss @@ -1,6 +1,10 @@ @use "../../styles/variables.scss" as *; .graph { + &:not(:has(.active)) { + view-transition-name: graph; + } + & > h3 { font-size: 1rem; margin: 0; @@ -64,6 +68,7 @@ transform: translate(-50%, -50%); height: 80vh; width: 80vw; + overflow: hidden; @media all and not ($desktop) { width: 90%; diff --git a/quartz/components/styles/readermode.scss b/quartz/components/styles/readermode.scss index 79332c3b6..d42eabbb8 100644 --- a/quartz/components/styles/readermode.scss +++ b/quartz/components/styles/readermode.scss @@ -9,6 +9,7 @@ margin: 0; text-align: inherit; flex-shrink: 0; + view-transition-name: readermode; & svg { position: absolute; diff --git a/quartz/components/styles/recentNotes.scss b/quartz/components/styles/recentNotes.scss index 726767198..ee206455c 100644 --- a/quartz/components/styles/recentNotes.scss +++ b/quartz/components/styles/recentNotes.scss @@ -1,4 +1,6 @@ .recent-notes { + view-transition-name: recent-notes; + & > h3 { margin: 0.5rem 0 0 0; font-size: 1rem; diff --git a/quartz/components/styles/toc.scss b/quartz/components/styles/toc.scss index 6a7723bdc..1705c8c28 100644 --- a/quartz/components/styles/toc.scss +++ b/quartz/components/styles/toc.scss @@ -6,6 +6,8 @@ overflow-y: hidden; min-height: 1.4rem; flex: 0 0.5 auto; + view-transition-name: toc; + &:has(button.toc-header.collapsed) { flex: 0 1 1.4rem; } diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss index 391139cdd..968e5a809 100644 --- a/quartz/styles/base.scss +++ b/quartz/styles/base.scss @@ -4,6 +4,10 @@ @use "./syntax.scss"; @use "./callouts.scss"; +::view-transition { + pointer-events: none; +} + html { scroll-behavior: smooth; text-size-adjust: none;