segue
Documentation

Documentation

Core concepts

Learn how destination paths, Presets, Transition Images, Overrides, and route state work together.

Segue runs a Cover on the current route and then asks Next.js to navigate. The Cover can remain visible while Segue waits for navigation to settle. Segue then removes it; animation inside the destination remains the responsibility of that route.

Select a Preset by destination

The destination pathname normally selects the Route-Transition Preset. The manifest assigns one Preset to each exact path:

import { defineManifest } from "@humaan/segue";
 
export const segueManifest = defineManifest({
  routes: [
    {
      path: "/projects/field-study",
      preset: "project-cover",
      imagesByRole: {},
    },
  ],
});

Any Segue.Link that targets /projects/field-study uses project-cover, regardless of which route contains the link. Queries and hashes don't affect the selection.

Route-Transition Presets

A Route-Transition Preset defines the reusable Cover for navigation to a destination. Each Preset has four properties:

PropertyPurpose
renderReturns the temporary elements displayed above the current route.
animateStarts the Cover after React adds those elements to the document.
reducedMotionChooses whether to run or skip the Preset when the visitor prefers reduced motion.
imageRolesLists any named Transition Images that the Cover needs.

Segue renders the temporary elements before it calls animate. The callback receives the Preset's root element, navigation details, route data, and Preset name. If animate returns a Promise, Segue waits for it before asking Next.js to navigate.

If the callback throws or its Promise rejects, Segue reports the problem and continues navigation. Segue doesn't require a specific animation library.

Outgoing Ownership

Outgoing Ownership starts immediately before Segue invokes an Override or starts a Preset run. It ends when navigation settles or Segue abandons the visual work.

In the normal path, Segue runs the Cover, starts Next.js navigation, keeps the Preset mounted while it waits, and removes the Preset when navigation settles. During this time, another coordinated navigation is treated as busy.

Segue also removes the Preset when a timeout, browser history navigation, or Provider unmount interrupts the transition. The navigation.signal passed to Presets and Overrides lets their animation code stop at the same time.

Outgoing Ownership doesn't decide how the rest of the page behaves. Your application decides whether to lock scrolling, prevent interaction with the old route, show loading feedback, or move focus after navigation.

Route Transition sequenceThe route trees do not animate into each other. Matching full-screen images create continuity across commit, then the destination animates its own content.

Route Transition sequence

A vertical sequence showing a hero image expanding over the current route as a Preset Cover, the destination mounting with the same full-screen image at route commit, and destination text animating upward over it.

Transition Images

Use Transition Images when a Cover reproduces an image that also appears on the destination route. If the Cover doesn't include a destination image, you don't need this feature.

For visual continuity, the Cover's final state and the destination's initial state should render the same Image Rendition. When both use the same image resource, the browser can reuse what it has already loaded instead of flashing an unloaded image after route commit.

An Image Rendition is one transformed version of an Image Asset. Each rendition contains:

  • assetId, which identifies the source Image Asset.
  • profile, which identifies a size, crop, format, or other transformation.
  • data, which contains everything your renderer needs, such as src, width, and height.

Together, assetId and profile identify one Image Rendition. Every use of that pair must provide the same rendition data.

The manifest assigns each destination rendition to an image role such as hero. This complete example uses the shared types from the getting-started tutorial:

// lib/segue-manifest.ts
import { defineManifest } from "@humaan/segue";
import type { PresetName, RenditionData } from "./segue-types";
 
export const segueManifest = defineManifest<PresetName, RenditionData>({
  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 },
        },
      },
    },
  ],
});

Define alt as a required Transition Image prop. A source or destination image can then provide useful alternative text, while decorative Preset UI can pass an empty string.

// lib/segue-types.ts
export type TransitionImageProps = {
  readonly alt: string;
  readonly className?: string;
  readonly sizes?: string;
};

The Provider's renderImage function controls how Segue renders the rendition. For Image Warming, Segue omits props and mounts the result in a hidden, accessibility-hidden container. The alt="" default satisfies next/image; visible Transition Image props override it with useful alternative text. Set loading="eager": a lazily loaded image inside the hidden warming container might never load.

// components/segue-image.tsx
"use client";
 
import type { ImageRenderer } from "@humaan/segue/client";
import Image from "next/image";
import type { RenditionData, TransitionImageProps } from "@/lib/segue-types";
 
export const renderSegueImage: ImageRenderer<
  RenditionData,
  TransitionImageProps
> = ({ image, props }) => (
  <Image
    alt=""
    loading="eager"
    {...props}
    src={image.data.src}
    width={image.data.width}
    height={image.data.height}
  />
);

The Preset declares the roles it requires. Inside render, its role-based TransitionImage resolves hero from the destination route. The Preset host has aria-hidden="true", so this decorative copy uses alt="".

// components/route-transitions.tsx
"use client";
 
import type { ComponentProps, ReactNode } from "react";
import { waitForAnimation } from "@humaan/segue/client";
import { segueManifest } from "@/lib/segue-manifest";
import { renderSegueImage } from "./segue-image";
import { Segue } from "./segue";
 
const projectCover = Segue.definePreset({
  imageRoles: ["hero"],
  reducedMotion: "skip",
  render: ({ TransitionImage }) => (
    <div className="project-cover">
      <TransitionImage
        role="hero"
        className="project-cover__image"
        sizes="100vw"
        alt=""
      />
    </div>
  ),
  animate: async ({ root, navigation }) => {
    const cover = root.querySelector<HTMLElement>(".project-cover");
    if (!cover) return;
 
    const animation = cover.animate(
      [{ opacity: 0 }, { opacity: 1 }],
      { duration: 450, easing: "ease-out", fill: "forwards" },
    );
    await waitForAnimation(animation, navigation.signal);
  },
});
 
const presets = {
  "project-cover": projectCover,
} satisfies ComponentProps<typeof Segue.Provider>["presets"];
 
export function RouteTransitions({ children }: { readonly children: ReactNode }) {
  return (
    <Segue.Provider
      manifest={segueManifest}
      presets={presets}
      renderImage={renderSegueImage}
    >
      {children}
    </Segue.Provider>
  );
}

The animation's fill: "forwards" retains the final full-screen image until route commit. Render the destination's initial image with the same dimensions and crop so removing the Preset at commit doesn't cause a visual jump.

Segue.TransitionImage accepts an image reference containing { assetId, profile }, resolves its canonical manifest data, and delegates to the same renderer:

// app/projects/field-study/page.tsx
"use client";
 
import { Segue } from "@/components/segue";
 
export default function FieldStudyPage() {
  return (
    <main className="project-hero">
      <Segue.TransitionImage
        image={{ assetId: "field-study-hero", profile: "route-hero" }}
        className="project-hero__image"
        sizes="100vw"
        alt="A field researcher recording observations beside a wetland"
      />
      <h1>Field study</h1>
    </main>
  );
}
.project-cover,
.project-hero {
  position: fixed;
  inset: 0;
}
 
.project-cover__image,
.project-hero__image {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

You can also use Segue.TransitionImage for source UI used by an Override. Inside a Preset, use the role-based component passed to render instead.

Image Warming gives the browser an earlier opportunity to load the rendition. Segue starts a hidden render when a link comes within 300 pixels of the viewport. It skips this passive warming when navigator.connection.saveData is true or the effective connection type is "slow-2g" or "2g".

Mouse enter, keyboard focus, and touch start trigger interaction warming even on those constrained connections. Set imageWarmingEnabled={false} if your own network or product policy should disable new warming requests. Each warmed rendition is deduplicated and remains mounted until the Provider unmounts, so keep warming renderers free of effects that assume a short-lived mount. Warming might not finish, and Segue never delays navigation for it.

Route-Transition Overrides

A Route-Transition Override runs source-specific Cover code instead of the destination's standard Preset. Use one when the animation must modify an element on the current route, such as the exact card a visitor selected.

Segue calls the Override before navigation. The callback receives the destination, navigation method, source element when available, and AbortSignal:

  • Return true when the Override handled the Cover.
  • Return false when Segue should use the destination Preset instead.
  • If the callback throws or rejects, Segue reports the problem and also tries the Preset.

This complete Override uses the Link anchor as navigation.source, declines when reduced motion is preferred or no project card exists, cancels its animation through navigation.signal, and returns true only after it handles the Cover:

"use client";
 
import {
  waitForAnimation,
  type RouteNavigation,
} from "@humaan/segue/client";
import { Segue } from "./segue";
 
async function coverSelectedCard(navigation: RouteNavigation): Promise<boolean> {
  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
    return false;
  }
 
  const card = navigation.source?.closest<HTMLElement>("[data-project-card]");
  if (!card) return false;
 
  const animation = card.animate(
    [{ transform: "scale(1)" }, { transform: "scale(1.08)" }],
    { duration: 400, easing: "ease-out", fill: "forwards" },
  );
  await waitForAnimation(animation, navigation.signal);
  return true;
}
 
export function ProjectCardLink() {
  return (
    <article data-project-card>
      <h2>Field study</h2>
      <Segue.Link
        href="/projects/field-study"
        routeTransition={{ override: coverSelectedCard }}
      >
        View project
      </Segue.Link>
    </article>
  );
}

An Override doesn't receive temporary UI from Segue. It normally animates elements from the current route, which can unmount at route commit. If that UI needs a manifest image, render it with Segue.TransitionImage before navigation.

Route state

Segue publishes state as a navigation moves through the current lifecycle. Route-Transition Entry records whether Segue selected a Preset, an Override, or no visual handoff.

Point in the lifecyclePublished state
Navigation acceptedPending Destination contains the target pathname.
Cover runningPending Destination remains set.
Waiting for Next.jsRoute Commit Pending becomes true.
Navigation settlesBoth pending values clear. When the destination commits, its Route-Transition Entry becomes available.

useNavigation starts programmatic navigation. The other three hooks read the state described above:

HookMeaning
usePendingDestinationReturns the target pathname until navigation settles or is canceled.
useRouteCommitPendingReturns true after outgoing work finishes and until navigation settles or is canceled.
useRouteTransitionEntryReports whether Segue selected a Preset, an Override, or no visual handoff for the current route.

Use pending state for concrete navigation feedback rather than inferring loading from the visual phase:

"use client";
 
import { Segue } from "./segue";
 
export function RouteStatus() {
  const pendingDestination = Segue.usePendingDestination();
  const routeCommitPending = Segue.useRouteCommitPending();
  const entry = Segue.useRouteTransitionEntry();
 
  const message = routeCommitPending
    ? "Opening the destination..."
    : pendingDestination
      ? "Preparing the transition..."
      : "";
 
  return (
    <p
      role="status"
      aria-live="polite"
      data-route-entry={entry?.type ?? "initial"}
    >
      {message}
    </p>
  );
}

Route-Transition Entry doesn't guarantee that the selected animation completed. For example, it can report a Preset even if that Preset failed and Segue continued navigation. The value is null on initial load.

Failure and reduced motion

Animation errors don't permanently block navigation. If an Override declines or fails, Segue tries the destination Preset. If a Preset fails, Segue calls the Provider's onIssue callback and asks Next.js to navigate anyway. By default, onIssue logs to console.error.

If the asynchronous Override-to-Preset sequence doesn't settle within transitionTimeoutMs, Segue cancels it and starts navigation. Callbacks must still avoid blocking synchronous work. If Next.js navigation doesn't settle within commitTimeoutMs, Segue falls back to a full-page navigation.

Every Preset must choose a reduced-motion behavior. With "skip", Segue doesn't render the Preset, run the Cover, or warm its declared Transition Images. With "run", your animation code must provide suitable reduced-motion behavior.

Segue captures the Preset's reduced-motion decision when it accepts navigation. Preference changes during active navigation apply to later navigation.

Use waitForAnimation with navigation.signal to stop native Web Animations API animations, Motion controls, and similar animation objects when the transition ends.

initial