beta
Page Transition .mv-page-transition
Transiciones de página nativas con la View Transitions API: fundido, deslizamiento tipo push, zoom en profundidad y una revelación circular desde el clic, cada una con su versión de retroceso, para sitios de varias páginas y SPA, llevando el foco a la nueva vista.
| Categoría | Transiciones |
|---|---|
| Tipo | Utilidad JavaScript (.mv-page-transition) |
| Estado | beta |
| 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
Instalación
node scripts/add.mjs page-transition --out ./src/marvelousAgente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["page-transition"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Archivos copiados (dependencias incluidas): tokens/tokens.css, core/base.css, core/motion.js, components/page-transition/page-transition.css, components/page-transition/page-transition.js.
Uso
Marcado de referencia: parte de él y personalízalo con atributos, data-* y 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 | Tipo | 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).