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;