SSR framework for Svelte 5 + Bun with islands-based selective hydration
On this page
View Transitions
<ViewTransitions /> opts your app into the browser’s cross-document View Transitions API, animating full-page navigations with zero client JavaScript. Mochi is an MPA — every navigation is a real page load — so the browser does the work; you just declare the animation.
Render it from a component that appears on every page (both the page you leave and the one you land on must opt in), e.g. a shared page shell component:
<script>
import { ViewTransitions } from 'mochi-framework/components';
</script>
<ViewTransitions type="fade" />
...Navigate between pages and they crossfade. That’s the whole setup.
Don’t hydrate it: the component emits static CSS and no markup, so there’s nothing for mochi:hydrate / mochi:defer to do — it throws if invoked as an island.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
type | 'fade' \| 'slide' \| 'scale' \| 'blur' \| 'flip' | 'fade' | The transition preset. |
custom | { out?: string; in?: string } | — | Custom keyframe bodies for the leaving/entering page. Overrides type. |
duration | number (ms) | 250 | Animation duration. |
easing | string | 'ease' | The animation timing function. |
regions | string \| string[] | — | Confine the animation to elements with these view-transition-names. |
keepElementSelectors | string \| string[] | — | CSS selectors for persistent chrome to hold still across navigations. |
<ViewTransitions type="slide" duration={400} easing="cubic-bezier(0.22, 1, 0.36, 1)" />Five presets ship built in: fade (crossfade), slide (horizontal translate), scale (zoom in/out), blur (focus pull), and flip (3D Y-axis rotation). They all animate the page root, so they apply to any page with no per-element setup, and reduced-motion users get no animation automatically. Need a motion none of the presets cover? Reach for custom (below).
Custom transitions
Pass custom to bring your own animation. out and in are the body of each keyframe — the from / to / % rules — for the page you leave and the page you land on; Mochi wraps each into an @keyframes for you:
<ViewTransitions
custom={{
out: 'to { opacity: 0; transform: rotate(8deg) }',
in: 'from { opacity: 0; transform: rotate(-8deg) }',
}}
easing="cubic-bezier(0.22, 1, 0.36, 1)"
/>Either side is optional — supply just in or just out and the other direction won’t animate. custom composes with duration, easing, regions, keepElementSelectors, and reduced-motion exactly like the presets do.
Animating only part of the page
The View Transitions API always snapshots the whole viewport — you can’t restrict the capture to a subtree. What you can scope is which parts animate. Pass regions to confine the transition to elements you’ve given a view-transition-name; everything else swaps instantly instead of cross-fading.
<ViewTransitions type="slide" regions="card" />
<section style="view-transition-name: card">…</section><!-- multiple named regions -->
<ViewTransitions regions={['card', 'hero']} />An empty array (regions={[]}) disables the animation entirely — everything swaps instantly.
Keeping elements still
By default the whole page crossfades. To hold persistent chrome — a banner, sidebar, or header — still instead, pass keepElementSelectors a list of CSS selectors. Each matched element is lifted out of the page crossfade and frozen (both its position and its snapshots) while the rest of the page transitions:
<ViewTransitions type="fade" keepElementSelectors={['.banner', '.sidebar']} />keepElementSelectors assigns each selector a unique view-transition-name and emits the freeze CSS for you, so there’s nothing to hand-write. Render the same list on every page — i.e. from your shared layout — so the page you leave and the page you land on agree.
If you’d rather wire it by hand — or need finer control — give the element a view-transition-name in your own CSS and zero its animations; that’s exactly what keepElementSelectors generates:
.sidebar {
view-transition-name: sidebar;
}
::view-transition-group(sidebar),
::view-transition-old(sidebar),
::view-transition-new(sidebar) {
animation: none;
}