SSGOI

Custom transitions

Define the preparation and animation for each direction. SSGOI keeps the types connected and handles route playback.

Pair preparation with its animation

This transition returns a named MultiAnimation going forward and a single WebAnimation going backward. Each direction infers its own prepared values and return type.

import { defineTransition, MultiAnimation, WebAnimation, spring } from "@ssgoi/core";

const arriving = spring({ stiffness: 300, damping: 30 });
const leaving = spring({ stiffness: 400, damping: 35 });

export const customFade = defineTransition({
  forward: {
    async prepare({ from, to }) {
      const [outgoing, incoming] = await Promise.all([from, to]);
      const saved = {
        fromOpacity: outgoing.style.opacity,
        toOpacity: incoming.style.opacity,
      };
      incoming.style.opacity = "0";
      return { saved };
    },
    animation({ from, to, saved }) {
      const outgoing = new WebAnimation({
        element: from,
        integrator: leaving,
        style: (_t, u) => ({ opacity: u }),
        onComplete() {
          from.style.opacity = saved.fromOpacity;
          outgoing.releaseFill();
        },
      });
      const incoming = new WebAnimation({
        element: to,
        integrator: arriving,
        style: (t) => ({ opacity: t }),
        onComplete() {
          to.style.opacity = saved.toOpacity;
          incoming.releaseFill();
        },
      });
      incoming.set({ startAt: { after: outgoing, at: 0.3 } });
      return new MultiAnimation({ out: outgoing, in: incoming });
    },
  },
  backward: {
    async prepare({ from }) {
      return { savedOpacity: (await from).style.opacity };
    },
    animation({ from, savedOpacity }) {
      const outgoing = new WebAnimation({
        element: from,
        integrator: leaving,
        style: (_t, u) => ({ opacity: u }),
        onComplete() {
          from.style.opacity = savedOpacity;
          outgoing.releaseFill();
        },
      });
      return outgoing;
    },
  },
});

// Register it exactly like a built-in preset.
export const config = {
  transitions: [{ from: "/gallery", to: "/photo/*", transition: customFade }],
};

Both factories return fresh animations. The core calls only the selected direction's preparation and factory. Shared helpers are useful when both directions use the same geometry or cleanup; they do not need duplicate implementations.

Keep concrete types in overrides

withOverride creates a retuned definition while leaving the original reusable. The selected animation type comes from that direction's factory.

import { withOverride, spring } from "@ssgoi/core";
import { customFade } from "./custom-transition";

export const fasterFade = withOverride(customFade, {
  forward({ animation }) {
    // Inferred: MultiAnimation<"out" | "in">
    animation.select("in").set({ integrator: spring({ stiffness: 420, damping: 36 }) });
  },
  backward({ animation }) {
    // Inferred: WebAnimation
    animation.set({ integrator: spring({ stiffness: 480, damping: 40 }) });
  },
});

defineTransition also accepts a second argument containing override. The core consumes the common Animation contract; it does not require your factory to return MultiAnimation. Avoid annotating a factory with a broad Animation return type when you want its specific methods and named children available in overrides.

Understand the lifecycle

StageResponsibility
Core directionRoute rules and history choose forward or backward once.
prepareStage initial styles; return per-run data. from and to are promises.
DOM insertionThe core inserts the outgoing page after preparation.
animationMeasure the inserted DOM and return a fresh Animation.
overrideRetune the chosen animation before it plays.
Playback and completionThe host owns playback; your callbacks restore styles and the core removes tracked temporary nodes.

Keep snapshots and DOM references in prepared data or per-animation closures. Preparations from different navigations may overlap, so reusable providers must not store a single current page or prepared result.

Use createElement in prepare for temporary nodes the core should remove. Restore changed inline styles on reused pages. After a WebAnimation completes, releaseFill removes its WAAPI fill so those restored styles take effect. Layout measurements that require the outgoing node to be inserted belong in animation.

Implement your own integrator

An integrator receives position, velocity, the current target, and a time step in seconds. This example uses an exact critically damped spring step.

import { WebAnimation, type Integrator, type IntegratorState } from "@ssgoi/core";

// Exact critically damped spring step with unit mass.
export class CriticalSpring implements Integrator {
  private readonly omega: number;

  constructor(stiffness: number) {
    if (!Number.isFinite(stiffness) || stiffness <= 0) {
      throw new Error("stiffness must be finite and positive");
    }
    this.omega = Math.sqrt(stiffness);
  }

  step(state: IntegratorState, target: number, dt: number): IntegratorState {
    const displacement = state.position - target;
    const c = state.velocity + this.omega * displacement;
    const decay = Math.exp(-this.omega * dt);
    return {
      position: target + (displacement + c * dt) * decay,
      velocity: (state.velocity - this.omega * c * dt) * decay,
    };
  }

  isSettled(state: IntegratorState, target: number): boolean {
    return Math.abs(target - state.position) < 0.01 && Math.abs(state.velocity) < 0.01;
  }
}

export function animateElement(element: HTMLElement) {
  return new WebAnimation({
    element,
    integrator: new CriticalSpring(300),
    style: (t) => ({ opacity: t }),
  });
}

Mutable simulation state belongs in the state argument. The animation engine decides when to simulate and apply styles. WebAnimation can adopt a pose for a new run; cross-composite pose handoff is not yet implemented by MultiAnimation.matchInto. A custom integrator alone does not add that capability.

Let the core own direction

Element roles do not change navigation direction.

An explicit gallery → photo/* rule makes detail → gallery backward even when a bottom navigation link performs a new push. For photo/* → photo/*, history resolves the otherwise equal patterns: a fresh push is forward and browser Back reverses the recorded transition.

Zoom and Hero keep their enter/exit keys. An enter marker identifies expanded media, while an exit marker identifies its thumbnail or card. A detail page can contain its own enter-marked photo and exit-marked related photos. Zoom uses the core direction to choose the pairing; it never replaces context.direction.

Read next