Building with Fancy
Page transitions
Animate Inertia navigation with fancy-inertia crossfades.
A hard content swap on every navigation feels like a website from 2010. A crossfade — the outgoing page easing out while the incoming page eases in — feels like an app. @particle-academy/fancy-inertia ships that as a controlled, zero-dependency primitive: no framer-motion, no CSS you hand-write, and it respects prefers-reduced-motion out of the box.
How it works
Inertia swaps page content client-side and hands you a key that changes on every navigation. That key is the hook. <FancyPageTransition> watches it and, on each change:
- Snapshots the outgoing page and renders two layers at once — the new page in normal flow (animating in) and the old page absolutely positioned on top (animating out).
- Runs both CSS animations for ~280ms.
- Drops the outgoing layer on
animationend.
Because both layers are keyed, React preserves the old page's DOM instead of re-mounting it — so there's no flash. The keyframes are injected by the package itself; you import nothing extra.
The quickest path
Wrap your page body in <FancyPageTransition>, keyed by the Inertia URL:
import { FancyPageTransition } from "@particle-academy/fancy-inertia";
import { usePage } from "@inertiajs/react";
function Layout({ children }) {
const { url } = usePage();
return (
<div>
<Navbar />
<main>
<FancyPageTransition pageKey={url} transition="fade">
{children}
</FancyPageTransition>
</main>
</div>
);
}
That's it — every navigation now crossfades. The transition prop takes fade (default), slide, scale, blur, or none.
Placement matters. Put
<FancyPageTransition>inside your persistent layout, around only the page body. The navbar and footer stay static; only the content animates. This only works if the layout itself persists across navigation — which is exactly what Inertia'sPage.layout = …gives you.
A live, app-wide transition picker
To let the whole app share one transition that a user can change at runtime, mount the engine once at the App root and back it with a provider. Use Inertia's children render-prop so the transition survives the page swap even on pages that render their layout inline:
import { createInertiaApp } from "@inertiajs/react";
import {
FancyAppRoot,
FancyTransitionProvider,
FancyPageTransition,
} from "@particle-academy/fancy-inertia";
createInertiaApp({
setup({ App, props, el }) {
createRoot(el).render(
<FancyAppRoot>
<FancyTransitionProvider defaultTransition="fade">
<App {...props}>
{({ Component, key, props }) => {
const child = <Component {...props} />;
const page = Component.layout ? Component.layout(child) : child;
return <FancyPageTransition pageKey={key}>{page}</FancyPageTransition>;
}}
</App>
</FancyTransitionProvider>
</FancyAppRoot>,
);
},
});
When a <FancyTransitionProvider> is present, <FancyPageTransition> reads the active transition from it automatically — omit the transition prop.
Building a switcher
useFancyTransition() exposes the active choice and a setter; the provider persists it to localStorage, so the preference sticks across navigations and reloads:
import {
useFancyTransition,
FANCY_TRANSITION_LABELS,
} from "@particle-academy/fancy-inertia";
function TransitionPicker() {
const { transition, setTransition, transitions } = useFancyTransition();
return (
<select value={transition} onChange={(e) => setTransition(e.target.value)}>
{transitions.map((t) => (
<option key={t} value={t}>{FANCY_TRANSITION_LABELS[t]}</option>
))}
</select>
);
}
This very showcase dogfoods it — the ✨ picker in the top nav drives every navigation you make on this site.
Whole-page vs. body-only
There's a real trade-off in where you mount the engine:
- App root (render-prop): works on every page regardless of how it attaches its layout. The catch: the whole page crossfades, navbar included. For
fade/scale/blurthat's invisible at the nav (it's nearly identical frame-to-frame); forslidethe whole page slides — a bolder, mobile-app feel. - Inside a persistent layout: only the body animates, the chrome stays rock-still. Cleaner, but it requires every page to use a persistent
Page.layout(no inline<Layout>).
Pick App-root for the least friction; pick in-layout when you want the nav perfectly fixed and you've standardized on persistent layouts.
Accessibility & SSR
- Reduced motion is automatic. Under
prefers-reduced-motion: reduce, the crossfade is skipped — pages swap instantly. You don't have to do anything. - SSR-safe. The first paint is static; the transition only engages on subsequent client-side navigations, so server-rendered markup is deterministic. The provider hydrates the stored preference after first paint.
animateInitial(defaultfalse) lets you opt the very first mount into the enter animation if you want a load-in flourish.
API reference
| Export | Purpose |
|---|---|
<FancyPageTransition pageKey transition? duration? animateInitial?> |
The engine. Wrap the page body. |
<FancyTransitionProvider defaultTransition? storageKey?> |
Holds + persists the active transition for a switcher. |
useFancyTransition() |
{ transition, setTransition, transitions }. |
FANCY_TRANSITIONS |
["fade","slide","scale","blur","none"]. |
FANCY_TRANSITION_LABELS |
Human labels keyed by id, for building a control. |
The Human+ takeaway: a transition is a visual signal that the surface changed. Agents perceive that change through state and the URL, not the animation — so transitions are pure polish for humans, and they never get in an agent's way. That separation is the subject of Designing for Human+.
