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, three primitives let you scale view transitions across many elements, hook into the navigation lifecycle, and target CSS by direction.
Named elements#
Use <ViewTransitionName> to give an element a stable identity that participates in the transition. The component is polymorphic (Base UI renderElement 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.
See also#
- Optimistic UI: the
transitionoption onuseOptimisticanduseOptimisticActionwraps mutations in a view transition. - Loading States: coordinating loading indicators with transitions.