@k8ordo/router

Animate page changes

You can cross-fade from one page to the next, or slide the other way only when going back. It takes React’s <ViewTransition> and the types the router gives every page change.

On this page

Cross-fade between pages

Wrap the <Outlet /> the pages render into in a <ViewTransition>.

src/shell.tsx
import { Outlet } from '@k8ordo/router';
import { ViewTransition } from 'react';

export function Shell() {
  return (
    <>
      <header>…</header>
      <ViewTransition
        default="none"
        update={{ navigation: 'auto', default: 'none' }}
      >
        <Outlet />
      </ViewTransition>
    </>
  );
}

It goes in update because the <ViewTransition> itself stays and only its content changes. auto is the browser’s own cross-fade.

This site wraps its pages in this same <ViewTransition>. Open another page from the sidebar, and the content cross-fades.

Animate page changes and nothing else

The router gives every render that changes the page the type navigation. With navigation as a key of update and default set to none, only renders of that type animate.

The filter matters because page changes are not the only updates a <ViewTransition> animates. @k8ordo/ui’s Button, for one, runs its pending action as a transition, and without the filter every press would cross-fade the whole page.

An update that changes only the query string does not change the page, so it never animates.

Slide the other way on back

Besides navigation, the router adds one of navigation-push, navigation-replace and navigation-traverse, the kind of navigation it was.

A link click and navigateTo are push; with { history: 'replace' } it is replace, and the browser’s back and forward are traverse.

src/shell.tsx
<ViewTransition
  default="none"
  update={{
    'navigation-push': 'slide-forward',
    'navigation-replace': 'slide-forward',
    'navigation-traverse': 'slide-back',
    default: 'none',
  }}
>
  <Outlet />
</ViewTransition>

Each value in update becomes a view transition class. Style each class through ::view-transition-old() and ::view-transition-new().

src/app.css
@keyframes slide-from-right {
  from {
    opacity: 0;
    translate: 32px 0;
  }
}

@keyframes slide-from-left {
  from {
    opacity: 0;
    translate: -32px 0;
  }
}

::view-transition-new(.slide-forward) {
  animation: 200ms ease-out both slide-from-right;
}

::view-transition-new(.slide-back) {
  animation: 200ms ease-out both slide-from-left;
}

Respect reduced motion

@k8ordo/ui’s stylesheet turns view-transition animations off when prefers-reduced-motion is reduce. An app without @k8ordo/ui adds the same rule itself.

src/app.css
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none;
  }
}

Animate under the framework

Under @k8ordo/static and @k8ordo/server, wrap a layout’s children in the same <ViewTransition>.

src/routes/layout.tsx
import type { LayoutProps } from '@k8ordo/router';
import { ViewTransition } from 'react';

export default function RootLayout({ children }: LayoutProps<'/'>) {
  return (
    <html lang="en">
      <body>
        <ViewTransition
          default="none"
          update={{ navigation: 'auto', default: 'none' }}
        >
          {children}
        </ViewTransition>
      </body>
    </html>
  );
}
Information

Note

A Server Component layout can render the <ViewTransition> as it is, and page changes carry the same types under the framework.

k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2