Documentation
Production integration
Add scroll locking, interaction state, focus management, and Destination Entrance behavior around Segue.Segue coordinates the visual handoff and route commit. Your persistent application shell must control scrolling, outgoing interaction, status announcements, destination focus, and animation inside the new route.
Start with a persistent route shell
Continue in the components/route-transitions.tsx file from the tutorial. Keep the existing presets object in that file, and replace its exported RouteTransitions component with this version:
// components/route-transitions.tsx
"use client";
import type { ReactNode } from "react";
import { segueManifest } from "@/lib/segue-manifest";
import { Segue } from "./segue";
export function RouteTransitions({ children }: { readonly children: ReactNode }) {
return (
<Segue.Provider
manifest={segueManifest}
presets={presets}
presetLifecycle={presetLifecycle}
>
<RouteShell>{children}</RouteShell>
</Segue.Provider>
);
}The following sections define presetLifecycle and RouteShell.
Lock scroll for a Preset
Use presetLifecycle.acquire for page-wide state that should last for the Preset's Outgoing Ownership. Return a synchronous, idempotent cleanup function that does not throw. Segue calls it when navigation settles or ownership is aborted by a timeout, history navigation, or Provider unmount. If animate or beforeCommit fails, navigation continues and cleanup runs when that navigation settles or is otherwise abandoned.
import type { PresetLifecycle } from "@humaan/segue/client";
import type { PresetName, RenditionData } from "@/lib/segue-types";
const presetLifecycle = {
acquire: () => {
const root = document.documentElement;
const previousOverflow = root.style.overflow;
const previousPaddingRight = root.style.paddingRight;
const previousTransitionState = root.dataset.routeTransition;
const scrollbarWidth = window.innerWidth - root.clientWidth;
root.style.overflow = "hidden";
if (scrollbarWidth > 0) root.style.paddingRight = `${scrollbarWidth}px`;
let released = false;
return () => {
if (released) return;
released = true;
root.style.overflow = previousOverflow;
root.style.paddingRight = previousPaddingRight;
if (previousTransitionState === undefined) {
delete root.dataset.routeTransition;
} else {
root.dataset.routeTransition = previousTransitionState;
}
};
},
beforeCommit: ({ navigation }) => {
if (navigation.signal.aborted) return;
document.documentElement.dataset.routeTransition = "committing";
},
} satisfies PresetLifecycle<PresetName, RenditionData>;Keep beforeCommit fast. Segue awaits it after a successful Cover and before calling the Next.js router. The cleanup above restores both scroll styles and the previous data-route-transition value.
This lifecycle runs only for a Preset. An Override that locks scrolling must acquire and release its own lock through navigation.signal.
Mark outgoing content busy
Use the Pending Destination to make the outgoing route inert as soon as Segue accepts navigation. aria-busy exposes that state to assistive technology, while a live status message gives visitors useful feedback.
function RouteShell({ children }: { readonly children: ReactNode }) {
const pendingDestination = Segue.usePendingDestination();
const routeCommitPending = Segue.useRouteCommitPending();
const busy = pendingDestination !== null;
return (
<>
<div inert={busy} aria-busy={busy}>
<DestinationEffects>{children}</DestinationEffects>
</div>
<p role="status" aria-live="polite">
{routeCommitPending
? "Opening the destination..."
: busy
? "Preparing navigation..."
: ""}
</p>
</>
);
}Don't put the live region inside the inert subtree. Keep persistent controls outside the inert subtree only if visitors can safely use them during navigation. Another different-route Segue.Link activated while Segue is busy receives no automatic disabled styling, and its coordinated navigation is ignored, so inert or disable repeated navigation controls yourself.
Move focus after commit
The Pending Destination clears when navigation settles. Move focus in destination-owned code after settlement, not from the Cover. Give the destination heading tabIndex={-1} so code can focus it without adding it to normal tab order.
// In each destination page
<h1 tabIndex={-1} data-destination-heading data-enter>
Field study
</h1>The DestinationEffects component in the next section focuses this heading with preventScroll: true. Choose a different focus target when your route starts with a dialog or another more appropriate landmark.
Run the Destination Entrance
A Destination Entrance is animation owned by the new route after commit. Use the Route-Transition Entry to decide whether the destination should animate; it reports what Segue selected, not whether the Cover succeeded.
import { usePathname } from "next/navigation";
import { useLayoutEffect, useRef } from "react";
function DestinationEffects({ children }: { readonly children: ReactNode }) {
const pathname = usePathname();
const entry = Segue.useRouteTransitionEntry();
const pendingDestination = Segue.usePendingDestination();
const reducedMotion = useReducedMotion();
const root = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const node = root.current;
if (!node || entry === null || pendingDestination !== null) return;
node.querySelector<HTMLElement>("[data-destination-heading]")
?.focus({ preventScroll: true });
if (reducedMotion || entry.type === "none") return;
const animations = Array.from(
node.querySelectorAll<HTMLElement>("[data-enter]"),
(element, index) => element.animate(
[
{ opacity: 0, transform: "translateY(12px)" },
{ opacity: 1, transform: "translateY(0)" },
],
{
duration: 320,
delay: index * 50,
easing: "ease-out",
fill: "both",
},
),
);
return () => animations.forEach(animation => animation.cancel());
}, [entry, pathname, pendingDestination, reducedMotion]);
return <div ref={root}>{children}</div>;
}Add data-enter only to content that can safely begin hidden. The destination must remain complete and understandable when no Entrance runs.
Respect reduced motion
Every Preset declares either reducedMotion: "skip" or "run". Choose "skip" unless the Preset itself implements a reduced-motion variant. Segue captures the navigation decision when navigation is accepted; Image Warming checks the current preference separately when warming is requested.
Your Destination Entrance and Overrides remain application-owned, so read the same media query for them:
import { useSyncExternalStore } from "react";
const query = "(prefers-reduced-motion: reduce)";
function useReducedMotion() {
return useSyncExternalStore(
(notify) => {
const media = window.matchMedia(query);
media.addEventListener("change", notify);
return () => media.removeEventListener("change", notify);
},
() => window.matchMedia(query).matches,
() => false,
);
}Verify the integration
Test keyboard, pointer, history, reduced-motion, failure, and slow-route paths:
- Start navigation and confirm the outgoing route becomes inert and
aria-busy="true". - Confirm the status text changes from preparation to route commit without announcing decorative Cover content.
- Use Back during the Cover and confirm scroll and interaction state are restored.
- Delay the destination beyond
commitTimeoutMsand confirm cleanup still runs before full-page recovery. - Emulate
prefers-reduced-motion: reduceand confirm skipped Presets and Destination Entrances don't animate. - Confirm focus moves to the destination heading after push, replace, and back-forward navigation.
- Trigger animation rejection and verify
onIssuereports it while navigation continues.
Review Scope and limitations before deciding how to generate and expose your manifest.