hono-preact
Overview
Quick Start
The Route Table
Layouts & Nesting
Adding Pages
Head Management
Active Links
Server Loaders
Loading States
Reloading Data
Prefetching
Streaming
Live Loaders
Realtime Channels
Server Actions
Validation
Optimistic UI
Server Caller
View Transitions
Middleware
CSRF Protection
CLI
Vite Config
Project Structure
Styling
Composing Hono Middleware
WebSockets
Rooms & Presence
renderPage
Link Prefetch
Build & Deploy
Overview
Dialog
Popover
Tooltip
Menu
Context Menu
Select
Combobox
Toast
renderElement
useControllableState
mergeRefs
useListNavigation
useTypeahead
useListboxSelection
usePosition
usePositioner
useDismiss
useFocusReturn

View Transitions#

The framework wraps every same-document route change in document.startViewTransition automatically. You don't opt in. A route change is a path or query change; hash-only URL changes (in-page anchors) never animate. 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#

PropTypeDefaultDescription
namestring | null | undefinedrequiredThe view-transition-name to apply; null/undefined clears it.
groupClassstring | string[]noneAdds a view-transition-class for grouping.
renderVNode | string | ((props) => VNode)<div>The element to render: a tag string, an element, or a function.
childrenComponentChildrennoneContent.

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 at once via ::view-transition-group(*.post-card) in CSS. The class only takes effect on elements that also carry a view-transition-name: a class with no named elements captures nothing. Pair it with a per-element name: <ViewTransitionName name={postName} groupClass="post-card"> sets both on one wrapper.

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:

MemberTypeDescription
tostringDestination path.
fromstring | undefinedSource path; undefined on initial load.
direction'initial' | 'push' | 'replace' | 'back' | 'forward'Navigation direction.
typesstring[]Mutable list of transition type names.
skip()() => voidSkip this transition.
set(key, value) / get(key)methodPer-event scratch shared across callbacks.

The four callbacks (onBeforeTransition, onBeforeSwap, onAfterSwap, onAfterTransition) each have the signature (event) => void | Promise<void>.

A hash-only URL change is not a route change (see Opting out below), so it never runs a transition and none of these four callbacks fire for it, including afterSwap. Effects that react to a URL fragment, scrolling to a heading, for example, should key on location (useLocation().url from preact-iso) rather than on a lifecycle callback, since a same-path fragment link is exactly the case where the lifecycle stays silent.

Direction-driven CSS via types#

The framework adds three types to every transition:

  • nav-initial on the first navigation after hydrate, otherwise one of nav-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.

Opting out of the transition#

The transition is on by default for every route change (a path or query change). A hash-only URL change is not a route change: writing #section to the URL, whether via navigate('#section'), a plain anchor click, or a raw history.pushState, never animates and needs no opt-out. Three opt-outs update the URL without animating (the render still commits):

  • navigate(href, { transition: false }) from useNavigate, for programmatic navigations (reflecting a tab, filter, or sort into the query string).

  • <NavLink href="..." transition={false}>: the declarative form of the same.

  • skipNextNavTransition(target?): a one-shot escape hatch for code that drives a navigation outside useNavigate and <NavLink>. Call it immediately before the navigation. Pass the destination URL to key the skip to it: the skip applies only when the next navigation commits at that path and query, and expires without applying when the next navigation goes anywhere else. With no argument the skip applies to the next navigation whatever its destination.

    import { skipNextNavTransition } from 'hono-preact';
    
    skipNextNavTransition('/inbox?folder=archive');
    history.pushState(null, '', '/inbox?folder=archive'); // URL updates, no transition

Each opt-out applies to the next navigation only.

See also#

  • Optimistic UI: the transition option on useOptimistic and useOptimisticAction wraps mutations in a view transition.
  • Loading States: coordinating loading indicators with transitions.