bêta
Page Transition .mv-page-transition
Transitions de page natives sur la View Transitions API : fondu, glissement push, zoom en profondeur et révélation circulaire depuis le clic, chacun avec une version retour, pour les sites multipages et les SPA, avec le focus déplacé vers la nouvelle vue.
| Catégorie | Transitions |
|---|---|
| Type | Utilitaire JavaScript (.mv-page-transition) |
| Statut | bêta |
| Keywords | page-transition, view-transitions, route, spa, mpa, navigation, reveal, push, focus-management |
When to use
- A multi-page site should animate between pages with a fade, push slide, depth zoom or a reveal from the clicked link
- A single-page app needs animated route changes that play in reverse on back and move focus to the new view
- Only the main content area should transition while the header and navigation stay still
Avoid when
- The transition is a card opening into its detail view on the same page → use Expandable Card instead
- Only a container changes size between views, not the page → use Morph Panel instead
- Users must get identical transitions in every browser; unsupported browsers navigate without animation
Installation
node scripts/add.mjs page-transition --out ./src/marvelousAgent IA avec le serveur MCP de Marvelous UI : install_components({ slugs: ["page-transition"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Fichiers copiés (dépendances comprises) : tokens/tokens.css, core/base.css, core/motion.js, components/page-transition/page-transition.css, components/page-transition/page-transition.js.
Utilisation
Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables CSS :
<div id="pt-demo">
<style>
#pt-demo { display: grid; gap: 1rem; justify-items: center; width: 100%; }
#pt-demo .pt-window { width: min(40rem, 100%); border: 1px solid var(--mv-border); border-radius: var(--mv-radius-xl); background: var(--mv-surface); box-shadow: var(--mv-shadow-lg); overflow: hidden; }
#pt-demo .pt-chrome { display: flex; align-items: center; gap: .75rem; padding: .55rem .85rem; border-bottom: 1px solid var(--mv-border); background: var(--mv-bg-subtle); }
#pt-demo .pt-lights { display: flex; gap: 6px; }
#pt-demo .pt-lights i { width: 10px; height: 10px; border-radius: 50%; background: var(--mv-border-strong); }
#pt-demo .pt-address { flex: 1; min-width: 0; padding: .28rem .7rem; border-radius: var(--mv-radius-full); background: var(--mv-bg); border: 1px solid var(--mv-border); font: 500 .75rem var(--mv-font-mono); color: var(--mv-fg-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#pt-demo nav { display: flex; align-items: center; gap: .25rem; padding: .5rem .75rem; border-bottom: 1px solid var(--mv-border); }
#pt-demo nav a { padding: .35rem .7rem; border-radius: var(--mv-radius-md); color: var(--mv-fg-muted); font-size: .8125rem; font-weight: 500; text-decoration: none; }
#pt-demo nav a:hover { color: var(--mv-fg); background: var(--mv-bg-muted); }
#pt-demo nav a:focus-visible { outline: none; box-shadow: var(--mv-focus-ring); }
#pt-demo nav a[aria-current="page"] { color: var(--mv-fg); background: var(--mv-bg-muted); }
#pt-demo .mv-page-transition-scope { height: 15.5rem; padding: 1.5rem 1.75rem; background: var(--mv-surface); outline: none; }
#pt-demo h3 { width: fit-content; margin: 0 -.25rem .4rem; padding: 0 .25rem; font-size: 1.35rem; letter-spacing: -.02em; border-radius: var(--mv-radius-sm); }
#pt-demo h3:focus { outline: none; }
#pt-demo h3:focus-visible { box-shadow: var(--mv-focus-ring); }
#pt-demo .pt-lead { margin: 0 0 1.1rem; color: var(--mv-fg-muted); font-size: .9rem; max-width: 36ch; }
#pt-demo .pt-overview { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1.25rem; align-items: center; height: 100%; }
#pt-demo .pt-spark { display: grid; gap: .45rem; padding: .9rem; border: 1px solid var(--mv-border); border-radius: var(--mv-radius-lg); background: var(--mv-bg-subtle); font-size: .75rem; color: var(--mv-fg-muted); }
#pt-demo .pt-spark strong { color: var(--mv-fg); font-size: 1.5rem; letter-spacing: -.02em; }
#pt-demo .pt-bars { display: flex; align-items: end; gap: 4px; height: 3.25rem; }
#pt-demo .pt-bars i { flex: 1; border-radius: 3px 3px 0 0; background: var(--mv-accent); opacity: .85; }
#pt-demo .pt-plans { display: grid; grid-template-columns: repeat(3, 1fr); gap: .6rem; }
#pt-demo .pt-plan { padding: .85rem; border: 1px solid var(--mv-border); border-radius: var(--mv-radius-lg); font-size: .8rem; color: var(--mv-fg-muted); }
#pt-demo .pt-plan b { display: block; color: var(--mv-fg); font-size: .85rem; }
#pt-demo .pt-plan strong { display: block; margin: .35rem 0 .15rem; color: var(--mv-fg); font-size: 1.35rem; letter-spacing: -.02em; }
#pt-demo .pt-plan[data-featured] { border-color: var(--mv-accent); box-shadow: 0 0 0 3px var(--mv-accent-subtle); }
#pt-demo .pt-list { display: grid; gap: .5rem; margin: 0; padding: 0; list-style: none; }
#pt-demo .pt-list li { display: flex; align-items: center; gap: .75rem; padding: .5rem .65rem; border: 1px solid var(--mv-border); border-radius: var(--mv-radius-lg); font-size: .8rem; color: var(--mv-fg-muted); }
#pt-demo .pt-list b { color: var(--mv-fg); font-size: .82rem; font-weight: 600; }
#pt-demo .pt-list time { margin-inline-start: auto; font-variant-numeric: tabular-nums; }
#pt-demo .pt-dot { width: .5rem; height: .5rem; border-radius: 50%; background: var(--mv-accent); flex: none; }
#pt-demo .pt-controls { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: .375rem; }
#pt-demo .pt-controls .mv-button[aria-pressed="true"] { background: var(--mv-fg); color: var(--mv-bg); }
@media (max-width: 560px) {
#pt-demo .pt-plans { grid-template-columns: 1fr 1fr; }
#pt-demo .pt-overview { grid-template-columns: 1fr; }
#pt-demo .mv-page-transition-scope { height: auto; min-height: 15.5rem; }
}
</style>
<div class="pt-window">
<div class="pt-chrome" aria-hidden="true"><span class="pt-lights"><i></i><i></i><i></i></span><span class="pt-address" data-pt-address>northwind.app/</span></div>
<nav aria-label="Demo app">
<a href="#overview" data-pt-page="0" aria-current="page">Overview</a>
<a href="#pricing" data-pt-page="1">Pricing</a>
<a href="#activity" data-pt-page="2">Activity</a>
</nav>
<main class="mv-page-transition-scope" tabindex="-1" data-pt-view></main>
</div>
<div class="pt-controls" role="group" aria-label="Transition preset">
<button class="mv-button" data-variant="secondary" data-size="sm" type="button" data-pt-type="fade" aria-pressed="false">Fade</button>
<button class="mv-button" data-variant="secondary" data-size="sm" type="button" data-pt-type="slide" aria-pressed="true">Slide</button>
<button class="mv-button" data-variant="secondary" data-size="sm" type="button" data-pt-type="scale" aria-pressed="false">Zoom</button>
<button class="mv-button" data-variant="secondary" data-size="sm" type="button" data-pt-type="reveal" aria-pressed="false">Reveal</button>
</div>
<template data-pt-tpl="overview">
<div class="pt-overview">
<div>
<h3>Good morning, Aiko</h3>
<p class="pt-lead">Revenue is up this week. Pick a page above, or go back, to see the transition play in both directions.</p>
</div>
<div class="pt-spark">
<span>Weekly revenue</span>
<strong>$48,210</strong>
<div class="pt-bars" aria-hidden="true"><i style="height:38%"></i><i style="height:52%"></i><i style="height:46%"></i><i style="height:64%"></i><i style="height:58%"></i><i style="height:81%"></i><i style="height:100%"></i></div>
</div>
</div>
</template>
<template data-pt-tpl="pricing">
<h3>Pricing</h3>
<p class="pt-lead">Start free, upgrade when your team grows.</p>
<div class="pt-plans">
<div class="pt-plan"><b>Starter</b><strong>$0</strong>2 seats</div>
<div class="pt-plan" data-featured><b>Growth</b><strong>$24</strong>per seat, monthly</div>
<div class="pt-plan"><b>Scale</b><strong>$59</strong>SSO and audit log</div>
</div>
</template>
<template data-pt-tpl="activity">
<h3>Activity</h3>
<p class="pt-lead">What changed across your workspace today.</p>
<ul class="pt-list">
<li><span class="pt-dot"></span><b>Mateo Silva</b> shipped the checkout redesign<time datetime="09:42">9:42 AM</time></li>
<li><span class="pt-dot"></span><b>Grace Okafor</b> invited 3 teammates<time datetime="11:05">11:05 AM</time></li>
<li><span class="pt-dot"></span><b>Lena Fischer</b> closed 12 support tickets<time datetime="14:18">2:18 PM</time></li>
</ul>
</template>
<script type="module">
// A tiny router: three views inside the frame, direction taken from the nav order.
const root = document.getElementById("pt-demo");
const src = document.querySelector('script[src*="page-transition/page-transition.js"]')?.src;
const { navigateWithTransition } = await import(src ?? "/components/page-transition/page-transition.js");
const pages = [["overview", "/"], ["pricing", "/pricing"], ["activity", "/activity"]];
const view = root.querySelector("[data-pt-view]");
const address = root.querySelector("[data-pt-address]");
const links = [...root.querySelectorAll("[data-pt-page]")];
let current = 0;
let type = "slide";
const render = (i) => {
view.replaceChildren(root.querySelector(`[data-pt-tpl="${pages[i][0]}"]`).content.cloneNode(true));
address.textContent = `northwind.app${pages[i][1]}`;
links.forEach((a, j) => (j === i ? a.setAttribute("aria-current", "page") : a.removeAttribute("aria-current")));
current = i;
};
render(0);
root.addEventListener("click", (e) => {
const link = e.target.closest("[data-pt-page]");
if (link) {
e.preventDefault();
const i = Number(link.dataset.ptPage);
if (i === current) return;
navigateWithTransition(() => render(i), { type, direction: i < current ? "back" : "forward", origin: e, scope: view });
return;
}
const preset = e.target.closest("[data-pt-type]");
if (preset) {
type = preset.dataset.ptType;
for (const b of root.querySelectorAll("[data-pt-type]")) b.setAttribute("aria-pressed", String(b === preset));
}
});
</script>
</div>API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
class="mv-page-transition" (on <html>) | Animates the whole page (root group) with the chosen preset. Including the CSS also enables @view-transition { navigation: auto } for same-origin multi-page navigations, except with reduced motion. | ||
data-transition (on <html>) | fade | slide | scale | reveal | fade | Preset. Set for the duration of a transition by navigateWithTransition(). |
data-transition-direction (on <html>) | forward | back | Plays the preset in reverse on back. Filled in automatically (SPA: direction option; multi-page: Navigation API history moves). Slide also follows dir="rtl". | |
data-page-focus | On an element of the new view: receives focus after navigation instead of the first h1-h3. |
Methods
| Name | Description |
|---|---|
navigateWithTransition(update, { type, direction, origin, scope, focus, announce }) | Runs update() inside a same-document View Transition (with transition types when supported). origin = the click event or {x, y}: center of “reveal”, whose circle is sized to the farthest corner; keyboard clicks use the clicked element’s center. focus = element, selector or false (default: the scope, else <main>; its first [data-page-focus] or h1-h3 is focused). announce = "auto" (the new title is spoken when focus did not move), a string, or false. Without the API or with reduced motion the update runs at once and focus still moves. Returns the ViewTransition or a settled stand-in. |
initPageTransitions({ type, signal }) | Multi-page sites: sets the class and preset, plays back history moves in reverse and starts “reveal” from the link clicked on the previous page. Skips the transition under data-motion="reduce". Load it in <head> with blocking="render". |
PRESETS | The available presets: fade, slide, scale, reveal. |
CSS classes
| Name | Description |
|---|---|
mv-page-transition | On <html>: full-page transitions. |
mv-page-transition-scope | Only this region animates (e.g. <main>); the rest of the page stays still. One per page (view-transition-name: mv-page). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-page-transition-duration | 420ms | Base duration; every preset’s timings derive from it. |
--mv-page-transition-easing | cubic-bezier(0.32, 0.72, 0, 1) | Curve of the slide and of the group (size/position). |
--mv-pt-x / --mv-pt-y / --mv-pt-r | 50% 50% 150% | Center and radius of the “reveal” circle, set from origin. |
Accessibility
Focus moves to the new view after every navigation (its first [data-page-focus] or h1-h3, made focusable with tabindex="-1" until it blurs), which screen readers read out; when focus is not moved the new title is announced in a polite live region. Reduced motion (OS setting or data-motion="reduce"): no animation, the update is instant, focus still moves. Without View Transitions support navigation works normally (progressive enhancement).