View Transitions
The framework wraps every same-document route change in document.startViewTransition automatically. You don't opt in. Style the default root transition with ::view-transition-old(root) and ::view-transition-new(root), and respect prefers-reduced-motion.
On top of that, four primitives let you scale view transitions across many elements, hook into the navigation lifecycle, target CSS by direction, and persist live DOM across navigations.
Named elements
Use <ViewTransitionName> to give an element a stable identity that participates in the transition. The component is polymorphic (Base UI useRender style): pass a render prop to control which element actually mounts.
import { ViewTransitionName } from 'hono-preact';
// list page
{
posts.map((post) => (
<ViewTransitionName
key={post.id}
name={`post-${post.id}`}
groupClass="post-card"
render={<article class="card" />}
>
<h2>{post.title}</h2>
</ViewTransitionName>
));
}
// detail page
<ViewTransitionName name={`post-${post.id}`} render={<header />}>
<h1>{post.title}</h1>
</ViewTransitionName>;
The matching name between list and detail tells the browser to animate the elements as a continuous group.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | null | undefined | required | The view-transition-name to apply; null/undefined clears it. |
groupClass | string | string[] | none | Adds a view-transition-class for grouping. |
render | VNode | string | ((props) => VNode) | <div> | The element to render: a tag string, an element, or a function. |
children | ComponentChildren | none | Content. |
For hand-written components, the useViewTransitionName hook returns a ref callback:
import { useViewTransitionName } from 'hono-preact';
function PostCard({ post }: { post: Post }) {
const vt = useViewTransitionName(`post-${post.id}`);
return <article ref={vt}>{post.title}</article>;
}
<ViewTransitionGroup class="post-card"> (or useViewTransitionClass) sets view-transition-class so you can target many elements via ::view-transition-group(.post-card) in CSS.
Lifecycle hooks
useViewTransitionLifecycle exposes four phases the framework controls:
import { useViewTransitionLifecycle } from 'hono-preact';
useViewTransitionLifecycle({
onBeforeTransition: (event) => {
// Before the View Transition starts.
// event.types.push('my-type') to add a type, event.skip() to bypass.
},
onBeforeSwap: (event) => {
// After the framework has begun the transition. Last chance to mutate
// the DOM before the new-frame snapshot is captured.
},
onAfterSwap: (event) => {
// The new DOM is settled and the browser is ready to animate.
},
onAfterTransition: (event) => {
// After transition.finished resolves (or rejects).
// event.reason is 'skipped' | 'unsupported' | 'aborted' if the transition
// didn't run.
},
});
Each callback receives a ViewTransitionEvent:
| Member | Type | Description |
|---|---|---|
to | string | Destination path. |
from | string | undefined | Source path; undefined on initial load. |
direction | 'initial' | 'push' | 'replace' | 'back' | 'forward' | Navigation direction. |
types | string[] | Mutable list of transition type names. |
skip() | () => void | Skip this transition. |
set(key, value) / get(key) | method | Per-event scratch shared across callbacks. |
The four callbacks (onBeforeTransition, onBeforeSwap, onAfterSwap, onAfterTransition) each have the signature (event) => void | Promise<void>.
Direction-driven CSS via types
The framework adds three types to every transition:
nav-initialon the first navigation after hydrate, otherwise one ofnav-push,nav-replace,nav-back,nav-forward.nav-same-origin.
Target them with :active-view-transition-type(...):
:active-view-transition-type(nav-back) ::view-transition-old(root) {
animation: slide-right-out 0.3s ease;
}
:active-view-transition-type(nav-back) ::view-transition-new(root) {
animation: slide-right-in 0.3s ease;
}
Add your own types with useViewTransitionTypes:
import { useViewTransitionTypes } from 'hono-preact';
useViewTransitionTypes((nav) =>
nav.from?.startsWith('/posts/') && nav.to === '/posts' ? ['back-to-list'] : []
);
For a rule that should apply regardless of what is mounted, for example a calm
transition whenever a navigation enters or leaves a whole section, use the
always-on subscribeViewTransitionTypes. A hook in a section layout only sees
navigations within the section: it is not subscribed yet when you navigate in, and
is torn down before you navigate out. A single subscriber registered at client
startup sees every navigation's from and to.
import { subscribeViewTransitionTypes } from 'hono-preact';
subscribeViewTransitionTypes((nav) => {
const inDocs = (p?: string) => p === '/docs' || p?.startsWith('/docs/');
return inDocs(nav.to) || inDocs(nav.from) ? ['docs'] : [];
});
It returns an unsubscribe and is a no-op on the server, so it is safe to register as a module side effect.
Persistent elements
<Persist> keeps an element's DOM and JS state alive across route changes. The framework auto-mounts a single <PersistHost /> outside the SPA root; <Persist id="..."> writes its children into a registry that the host renders. Same VNode reference plus stable host DOM means Preact's diff preserves the underlying nodes (audio playback continues, video position is retained, chat widgets stay initialized).
import { Persist } from 'hono-preact';
<Persist id="player" viewTransitionName="player-shell">
<AudioPlayer src={song.url} />
</Persist>;
The optional viewTransitionName makes the persisted shell animate as a single unit across page transitions.
SSR renders persisted children inline at their declared position; persistence kicks in after the first client-side navigation.
See also
- Optimistic UI: the
transitionoption onuseOptimisticanduseOptimisticActionwraps mutations in a view transition. - Loading States: coordinating loading indicators with transitions.