segue
Documentation

Documentation

API reference

Look up Segue's exported functions, components, hooks, callback options, results, defaults, and errors.

Use @humaan/segue for serializable manifest functions and types. Use @humaan/segue/client only from Client Components or other browser code.

Server import

The root entry point doesn't import React client or browser APIs:

import {
  defineManifest,
  normalizePath,
  type ImageReference,
  type ImageRendition,
  type JsonValue,
  type ManifestInput,
  type ManifestRoute,
  type ManifestRouteInput,
  type SegueManifest,
} from "@humaan/segue";

Manifest types

type JsonValue =
  | null
  | boolean
  | number
  | string
  | readonly JsonValue[]
  | { readonly [key: string]: JsonValue };
 
interface ImageReference {
  readonly assetId: string;
  readonly profile: string;
}
 
interface ImageRendition<Data = JsonValue> extends ImageReference {
  readonly data: Data;
}
 
interface ManifestRoute<Preset extends string = string> {
  readonly path: string;
  readonly preset: Preset;
  readonly imagesByRole: Readonly<Record<string, ImageReference>>;
}
 
interface SegueManifest<Preset extends string = string, RenditionData = JsonValue> {
  readonly routes: readonly ManifestRoute<Preset>[];
  readonly renditions: readonly ImageRendition<RenditionData>[];
}
 
interface ManifestRouteInput<Preset extends string, RenditionData> {
  readonly path: string;
  readonly preset: Preset;
  readonly imagesByRole: Readonly<
    Record<string, ImageRendition<RenditionData>>
  >;
}
 
interface ManifestInput<Preset extends string, RenditionData> {
  readonly routes: readonly ManifestRouteInput<Preset, RenditionData>[];
}

ManifestRouteInput contains full Image Renditions in imagesByRole. defineManifest converts them to Image References and deduplicates full renditions into SegueManifest.renditions.

defineManifest

function defineManifest<Preset extends string, RenditionData = JsonValue>(
  input: ManifestInput<Preset, RenditionData>,
): SegueManifest<Preset, RenditionData>;

Each input route has this contract:

FieldTypeConstraint
pathstringStarts with exactly one /; contains no query or hash; unique after normalization.
presetPresetNon-empty string.
imagesByRoleReadonly<Record<string, ImageRendition<RenditionData>>>Plain object with non-empty role keys.

Each Image Rendition requires a non-empty string assetId, a non-empty string profile, and a data property. The input, routes, imagesByRole, and rendition values must be plain data. Rendition data supports JSON-compatible primitives, dense arrays, and plain objects; it rejects cycles, non-finite numbers, class instances, and other non-JSON values.

import { defineManifest } from "@humaan/segue";
 
const manifest = defineManifest({
  routes: [
    {
      path: "/projects/field-study/",
      preset: "project-cover",
      imagesByRole: {
        hero: {
          assetId: "field-study-hero",
          profile: "route-hero",
          data: { src: "/images/field-study.jpg", width: 1600, height: 900 },
        },
      },
    },
  ],
});

The returned manifest is a deep-frozen copy. Its route path is /projects/field-study; its imagesByRole.hero contains only the assetId and profile; and the full data appears once in renditions. Reusing an assetId and profile pair with unequal data throws.

normalizePath

function normalizePath(path: string): string;

normalizePath applies URL pathname normalization, including . and .. segment resolution, and removes all trailing slashes except the root slash.

normalizePath("/projects/./field-study/"); // "/projects/field-study"
normalizePath("/projects/field-study/../civic-centre"); // "/projects/civic-centre"
normalizePath("/"); // "/"

The input must start with one slash. Protocol-relative paths beginning with //, queries, and hashes throw. The function accepts a pathname, not a complete URL.

createSegue

function createSegue<
  Preset extends string,
  RenditionData,
  TransitionImageProps extends object = Record<string, never>,
>(): SegueApplication<Preset, RenditionData, TransitionImageProps>;
 
interface SegueApplication<Preset extends string, RenditionData, TransitionImageProps extends object> {
  readonly Provider: ComponentType<
    SegueProviderProps<Preset, RenditionData, TransitionImageProps>
  >;
  readonly Link: ForwardRefExoticComponent<
    SegueLinkProps & RefAttributes<HTMLAnchorElement>
  >;
  readonly TransitionImage: ComponentType<
    TransitionImageComponentProps<TransitionImageProps>
  >;
  readonly definePreset: <const ImageRole extends string>(
    definition: Omit<
      PresetDefinition<Preset, RenditionData, TransitionImageProps>,
      "imageRoles" | "render"
    > & {
      readonly imageRoles: readonly ImageRole[];
      readonly render: ComponentType<
        PresetRenderProps<TransitionImageProps, ImageRole>
      >;
    },
  ) => PresetDefinition<Preset, RenditionData, TransitionImageProps>;
  readonly useNavigation: () => {
    readonly navigate: (options: NavigateOptions) => Promise<NavigationResult>;
  };
  readonly usePendingDestination: () => string | null;
  readonly useRouteCommitPending: () => boolean;
  readonly useRouteTransitionEntry: () => RouteTransitionEntry<Preset> | null;
}

Call createSegue once for one application contract. It returns Provider, Link, TransitionImage, definePreset, useNavigation, usePendingDestination, useRouteCommitPending, and useRouteTransitionEntry.

"use client";
 
import { createSegue } from "@humaan/segue/client";
 
type PresetName = "project-cover" | "section-wipe";
type RenditionData = { src: string; width: number; height: number };
type TransitionImageProps = { alt: string; className?: string; sizes?: string };
 
export const Segue = createSegue<
  PresetName,
  RenditionData,
  TransitionImageProps
>();

Every component and hook on that object uses private contexts created by the same call. Don't mix a Provider from one createSegue result with a component or hook from another.

definePreset and createPreset

Segue.definePreset preserves literal image-role names so TypeScript restricts the role-based TransitionImage passed to render.

function definePreset<const ImageRole extends string>(
  definition: Omit<
    PresetDefinition<Preset, RenditionData, TransitionImageProps>,
    "imageRoles" | "render"
  > & {
    readonly imageRoles: readonly ImageRole[];
    readonly render: ComponentType<
      PresetRenderProps<TransitionImageProps, ImageRole>
    >;
  },
): PresetDefinition<Preset, RenditionData, TransitionImageProps>;
 
interface PresetDefinition<Preset extends string, RenditionData, TransitionImageProps extends object> {
  readonly imageRoles: readonly string[];
  readonly reducedMotion: "run" | "skip";
  readonly render: ComponentType<PresetRenderProps<TransitionImageProps>>;
  readonly animate: (
    options: PresetAnimateOptions<Preset, RenditionData>,
  ) => void | Promise<void>;
}
 
interface PresetRenderProps<TransitionImageProps extends object, ImageRole extends string = string> {
  readonly TransitionImage: ComponentType<
    TransitionImageProps & { readonly role: ImageRole }
  >;
}

PresetAnimateOptions contains the full run options plus the mounted host:

interface PresetRunOptions<Preset extends string, RenditionData> {
  readonly navigation: RouteNavigation;
  readonly preset: Preset;
  readonly route: ManifestRoute<Preset>;
  readonly imagesByRole: Readonly<Record<string, ImageRendition<RenditionData>>>;
}
 
interface PresetAnimateOptions<Preset extends string, RenditionData>
  extends PresetRunOptions<Preset, RenditionData> {
  readonly root: HTMLElement;
}

definePreset returns the definition unchanged and performs no runtime validation itself. The Provider validates definitions referenced by the manifest. With reducedMotion: "skip", Segue doesn't render or animate the Preset and doesn't warm its declared roles when reduced motion is preferred. With "run", your callback must implement an appropriate reduced-motion result. The navigation decision is captured when Segue accepts navigation; warming checks the current preference when each warming event occurs.

createPreset creates only a typed definition helper:

function createPreset<
  RenditionData,
  TransitionImageProps extends object,
>(): <const ImageRole extends string>(
  definition: Omit<
    PresetDefinition<string, RenditionData, TransitionImageProps>,
    "imageRoles" | "render"
  > & {
    readonly imageRoles: readonly ImageRole[];
    readonly render: ComponentType<
      PresetRenderProps<TransitionImageProps, ImageRole>
    >;
  },
) => PresetDefinition<string, RenditionData, TransitionImageProps>;

Use it for shared Preset modules that shouldn't depend on an application's complete Preset union.

Provider

interface SegueProviderProps<Preset extends string, RenditionData, TransitionImageProps extends object> {
  readonly children: ReactNode;
  readonly manifest: SegueManifest<Preset, RenditionData>;
  readonly presets: Record<Preset, PresetDefinition<Preset, RenditionData, TransitionImageProps>>;
  readonly presetLifecycle?: PresetLifecycle<Preset, RenditionData>;
  readonly renderImage?: ImageRenderer<RenditionData, TransitionImageProps>;
  readonly imageWarmingEnabled?: boolean;
  readonly transitionTimeoutMs?: number;
  readonly commitTimeoutMs?: number;
  readonly onIssue?: (issue: SegueIssue) => void;
}
PropDefaultContract
childrenRequiredApplication subtree that can use this Segue object's components and hooks.
manifestRequiredStatic manifest snapshot captured at first mount. Prop changes don't rebuild route or rendition indexes.
presetsRequiredDefinition for every Preset in the generic union; every manifest route's named definition must exist and be valid. Updated definitions apply to later navigation.
presetLifecycleundefinedOptional acquire and beforeCommit callbacks for Presets.
renderImageundefinedRequired if any supplied Preset declares at least one image role, even when no route currently uses that Preset. Also required by rendered Transition Images and Link-level warming images.
imageWarmingEnabledtrueEnables new passive and interaction Image Warming requests. It doesn't remove renditions already warmed or change renderImage validation.
transitionTimeoutMs3000Positive finite milliseconds for the asynchronous Override-to-Preset sequence to settle. Callbacks must not block synchronously.
commitTimeoutMs15000Positive finite milliseconds from Next.js navigation start until the tracked navigation settles.
onIssueLogs to console.errorReceives recoverable runtime issues. If it throws, Segue catches that error and logs Segue onIssue callback failed.

An accepted navigation snapshots its Preset definition, image roles, reduced-motion decision, timeout values, and image renderer. Updating those Provider props affects later navigation, not work already accepted. The Preset Lifecycle is captured when its Preset starts, and onIssue uses the current callback when an issue is reported.

Preset lifecycle callbacks

interface PresetLifecycle<Preset extends string, RenditionData> {
  readonly acquire?: (
    options: PresetRunOptions<Preset, RenditionData>,
  ) => void | (() => void);
  readonly beforeCommit?: (
    options: PresetRunOptions<Preset, RenditionData>,
  ) => void | Promise<void>;
}

acquire runs immediately before Segue mounts a selected Preset. Its optional cleanup must be synchronous, idempotent, and non-throwing. It runs when navigation settles or ownership is aborted by a timeout, history navigation, or Provider unmount. A rejected animate or beforeCommit continues navigation; the host and lifecycle remain acquired until navigation settles or is otherwise abandoned. beforeCommit runs after animate resolves and immediately before Segue starts Next.js navigation. If acquire, animate, or beforeCommit throws or rejects, Segue reports preset-failed.

Image renderer callback

interface ImageRenderOptions<
  RenditionData,
  TransitionImageProps extends object,
> {
  readonly image: ImageRendition<RenditionData>;
  readonly props?: TransitionImageProps;
}
 
type ImageRenderer<RenditionData, TransitionImageProps extends object> = (
  options: ImageRenderOptions<RenditionData, TransitionImageProps>,
) => ReactNode;

Image Warming omits props and mounts the renderer's output in a hidden, accessibility-hidden container. Warmed renditions remain mounted until Provider unmount. A Transition Image receives the props supplied to its component. With next/image, provide alt="" and loading="eager" as renderer defaults, then let Transition Image props override alt.

Mount one Provider per browser window, including across duplicate package copies. Mounting another throws from an effect. On unmount, Segue aborts active work, clears subscribers, and releases the browser-window registration.

TransitionImage

type TransitionImageComponentProps<TransitionImageProps extends object> =
  TransitionImageProps & { readonly image: ImageReference };

Segue.TransitionImage resolves the exact assetId and profile pair from the Provider's static rendition index, then calls renderImage with the component's typed props.

<Segue.TransitionImage
  image={{ assetId: "field-study-hero", profile: "route-hero" }}
  alt="A field researcher beside a wetland"
  sizes="100vw"
/>

Use this form in source or destination UI. Inside a Preset, use the role-based TransitionImage passed to render. The role-based form looks up route.imagesByRole[role] and then delegates to Segue.TransitionImage.

type SegueLinkProps =
  & Omit<NextLinkProps, "href">
  & Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof NextLinkProps>
  & {
      readonly href: NextLinkProps["href"];
      readonly children?: ReactNode;
      readonly routeTransition?: false | RouteTransitionOptions;
    };
 
interface RouteTransitionOptions {
  readonly override?: RouteTransitionOverride;
  readonly images?: readonly ImageReference[];
}
 
type RouteTransitionOverride = (
  navigation: RouteNavigation,
) => boolean | Promise<boolean>;

Segue.Link forwards its ref and inherits all non-conflicting Next.js Link and anchor props. These are the navigation-relevant props:

PropDefaultBehavior
hrefRequiredNext.js string or URL object. as is used as the coordinated browser URL when present.
childrenundefinedLink contents.
routeTransitionundefinedUses the destination Preset. false disables the Override, Preset, and Image Warming for this Link, but Segue still coordinates different-path pending state, history, and commit. An object can provide override and extra warming images.
replacefalseSelects push or replace.
scrolltrueForwarded to Next.js and exposed as optional navigation.scroll when explicitly supplied.
prefetchnull in the App RouterForwarded unchanged; accepts true, false, "auto", or null and remains separate from Segue Image Warming.
asundefinedOptional displayed URL; Segue resolves this value for coordination when supplied.
transitionTypesundefinedForwarded to Next.js and copied for programmatic router calls.
onNavigateundefinedRuns before Segue. Calling preventDefault() prevents all Segue work.
onClickundefinedForwarded to Next.js; onNavigate is the callback that gates Segue coordination.
onMouseEnter, onFocus, onTouchStartundefinedYour handler runs first, then Segue requests interaction warming.
Anchor propsBrowser defaultsIncludes className, style, target, download, ARIA attributes, data attributes, and other anchor events.

The navigation callback has this exact shape. Pointer, focus, touch, and click handlers use the corresponding React anchor event-handler types.

onNavigate?: (event: { preventDefault: () => void }) => void;

The Override receives:

interface RouteNavigation {
  readonly href: string;
  readonly method: "push" | "replace";
  readonly signal: AbortSignal;
  readonly scroll?: boolean;
  readonly source: HTMLElement | null;
}

For a Link, source is its anchor. Return true when the Override handled the visual work. Return false to use the destination Preset. If the callback throws or rejects, Segue reports override-failed and tries the Preset. The images array only adds Image References to warming; it isn't passed to the Override. A non-empty images array requires renderImage, even if warming is disabled.

Segue leaves external URLs and same-path query or hash changes to Next.js. While another coordinated navigation is active, a different-path, same-origin Link that requires coordination is ignored without changing the URL or exposing a result. The component doesn't set disabled, aria-disabled, aria-busy, or inert automatically.

useNavigation and navigate

const { navigate } = Segue.useNavigation();
 
interface NavigateOptions {
  readonly href: string;
  readonly method?: "push" | "replace";
  readonly routeTransition?: false | RouteTransitionOptions;
  readonly scroll?: boolean;
  readonly source?: HTMLElement | null;
  readonly transitionTypes?: readonly string[];
}
 
type NavigationResult =
  | { readonly status: "navigated" }
  | { readonly status: "ignored"; readonly reason: "busy" | "same-location" };
OptionDefaultBehavior
hrefRequiredValid relative or absolute string resolved against window.location.href.
method"push"Uses router or document push/assign; "replace" uses router or document replace.
routeTransitionundefinedSelects the destination Preset. false disables visual work but keeps the coordinated navigation lifecycle. It also accepts the same override and images options as Link; programmatic images aren't warmed because navigate has no warming events.
scrollundefinedOmits the router option and preserves Next.js default behavior.
sourcenullElement exposed to the Override.
transitionTypesundefinedWhen set, copied into a mutable array and passed to the Next.js router.

URL handling follows this order:

  1. If another navigation is active, resolve { status: "ignored", reason: "busy" }.
  2. Reject javascript: URLs, including control-character-obfuscated spellings.
  3. Resolve href against the current URL.
  4. For a different origin, use location.assign or location.replace and resolve as navigated.
  5. For the same normalized pathname, query, and hash, resolve { status: "ignored", reason: "same-location" }.
  6. For the same normalized pathname with a different query or hash, call the Next.js router immediately without Route Transition state.
  7. For a different pathname, select the Override or exact manifest Preset, run outgoing work, and then call the Next.js router.

The Promise resolves { status: "navigated" } after Segue starts router or document navigation, not after navigation settles. A push may internally become replace after Segue creates its cancellable temporary history entry.

Route state hooks

type RouteTransitionEntry<Preset extends string> =
  | { readonly type: "preset"; readonly preset: Preset }
  | { readonly type: "override" }
  | { readonly type: "none" };
Lifecycle pointusePendingDestination()useRouteCommitPending()useRouteTransitionEntry() for current pathname
Initial loadnullfalsenull
Different-path navigation acceptedNormalized destination pathfalseExisting current-route value
Override or Preset runningDestination pathfalseExisting current-route value
Outgoing work settled; router startedDestination pathtrue{ type: "none" } for the still-current source pathname
Requested destination commitsnullfalse{ type: "preset", preset }, { type: "override" }, or { type: "none" }
Navigation settles without the requested destinationnullfalse{ type: "none" } for the actual current pathname
Uncoordinated pathname commitnullfalse{ type: "none" }
Outgoing timeout; router startedDestination pathtrue{ type: "none" } for the still-current source pathname
Cancellation during outgoing worknullfalsePrevious committed value
Commit-timeout cleanupnullfalse{ type: "none" } for the still-current source pathname

usePendingDestination() reports navigation pending state, not whether visual work exists. It clears when the tracked navigation settles or is canceled.

useRouteCommitPending() becomes true only after outgoing work settles and remains true until navigation settles or is canceled. Same-path query or hash navigation doesn't change it.

useRouteTransitionEntry() is keyed to the current normalized pathname. Its value describes the selected handoff, not animation success: a failed Preset still produces a Preset entry when navigation commits. It returns null only on initial load before any route entry has been recorded.

All hooks use useSyncExternalStore and throw if called outside the matching Segue Provider.

Reported issues

onIssue receives this shape:

interface SegueIssue {
  readonly code: SegueIssueCode;
  readonly message: string;
  readonly cause?: unknown;
}
SegueIssueCodeWhen it is reportedRecovery
transition-timeoutThe asynchronous Override-to-Preset sequence doesn't settle within transitionTimeoutMs.Abort outgoing work and start navigation.
commit-timeoutThe tracked Next.js navigation doesn't settle within commitTimeoutMs.Abort state and use document navigation.
navigation-failedThe Next.js router call throws.Use document navigation.
override-failedOverride throws or rejects.Try the destination Preset.
preset-failedPreset Lifecycle acquisition, Preset animation, or beforeCommit throws or rejects.Continue navigation.
missing-required-imageA selected Preset role has no resolvable route rendition.Skip that Preset's work and continue navigation.
history-prepare-failedSegue can't write the temporary cancellable push entry.Reset state and use document navigation.
history-cleanup-failedSegue can't clear temporary state after commit.Keep the committed route and report the stale state.
history-recovery-failedStale history recovery throws or replacement fails.Report; replacement is attempted where possible.

The default handler calls console.error with [Segue: code], the issue message, and cause.

Thrown errors

Recoverable navigation failures use onIssue. Invalid configuration, invalid usage, and blocked URLs throw:

APICondition
defineManifestInput isn't plain data with a routes array.
defineManifestA route or imagesByRole isn't a plain object.
defineManifest / normalizePathPath doesn't start with exactly one slash, or contains a query or hash.
defineManifestDuplicate normalized route path.
defineManifestEmpty Preset, role, assetId, or profile; invalid rendition; or missing data.
defineManifestRendition data uses an unsupported non-JSON value, non-finite number, cycle, sparse array, or non-plain object.
defineManifestOne assetId and profile pair has conflicting data.
ProvidertransitionTimeoutMs or commitTimeoutMs isn't a positive finite number.
ProviderA manifest Preset definition is missing or has invalid animate, render, imageRoles, or reducedMotion.
ProviderA manifest-referenced Preset contains an empty or non-string image role.
ProviderAny supplied Preset declares image roles without renderImage.
ProviderA second Provider, including one from a duplicate package copy, mounts in the browser window.
Segue.TransitionImageImage Reference isn't in the static rendition index, or renderImage is absent.
Preset role TransitionImageRequested role wasn't resolved for the selected route.
Segue.LinkrouteTransition.images is non-empty without renderImage.
Components and hooksUsed outside the Provider created by the same createSegue call.
navigatehref is a javascript: URL.
navigatehref can't be parsed relative to the current browser URL.
Preset or image renderingrender or renderImage throws during React rendering. These errors aren't reported through onIssue.

waitForAnimation

function waitForAnimation(
  animation:
    | {
        cancel(): void;
        readonly finished: PromiseLike<unknown>;
      }
    | {
        stop(): void;
        then(onResolve: VoidFunction, onReject?: VoidFunction): Promise<void>;
      },
  signal: AbortSignal,
): Promise<void>;

This helper supports native Web Animations API Animation objects, Motion controls, and other stoppable thenables. Motion isn't a Segue dependency; install motion separately before importing from it.

import { animate } from "motion";
import { waitForAnimation } from "@humaan/segue/client";
 
const projectCover = Segue.definePreset({
  imageRoles: [],
  reducedMotion: "skip",
  render: () => <div className="project-cover" />,
  animate: ({ root, navigation }) => {
    const cover = root.querySelector<HTMLElement>(".project-cover");
    if (!cover) return;
 
    return waitForAnimation(
      animate(cover, { x: ["-100%", "0%"] }),
      navigation.signal,
    );
  },
});

Guard the nullable DOM query before calling Motion's animate; passing null isn't part of this contract.

OutcomewaitForAnimation behavior
Signal already abortedCalls cancel() or stop() once, doesn't read finished or call then(), and resolves unless cancellation throws.
Signal aborts while waiting for a native animationCalls cancel() and resolves without waiting for finished.
Signal aborts while waiting for a stoppable thenableCalls stop() and remains pending until the animation thenable settles. Motion's stop() settles its controls.
Animation fulfillsResolves void and removes the abort listener.
Native animation rejects before abortRejects with the same failure and removes the abort listener.
Stoppable thenable rejectsRejects with the same failure and removes the abort listener.
stop() doesn't settle the thenableThe helper remains pending; stoppable implementations must settle after stop().

Public exports

Entry pointRuntime valuesTypes
@humaan/seguedefineManifest, normalizePathImageReference, ImageRendition, JsonValue, ManifestInput, ManifestRoute, ManifestRouteInput, SegueManifest
@humaan/segue/clientcreatePreset, createSegue, waitForAnimationImageRenderer, ImageRenderOptions, NavigateOptions, NavigationMethod, NavigationResult, PresetAnimateOptions, PresetDefinition, PresetLifecycle, PresetRenderProps, PresetRunOptions, ReducedMotionBehavior, RouteNavigation, RouteTransitionEntry, RouteTransitionOptions, RouteTransitionOverride, SegueApplication, SegueIssue, SegueIssueCode, SegueLinkProps, SegueProviderProps, TransitionImageComponentProps
initial