← Case study

Detail 08 · Transitions

A state machine for transitions

Five phases, one string: how every page change here is choreographed, and why loose booleans would break it. 7 minutes, 2 demos.

Try it: change the page
sujoykh.vercel.app/
Sujoy Kr. Haldar
  1. idle
  2. covering
  3. covered
  4. drawing
  5. revealing

01 · The idea

A page change is a little film

On most sites a link swaps one page for the next. Here it plays out in order: the page you’re on sinks away, the screen stays empty while the next page loads, a pencil sketch of that page draws itself in, and the real page builds inside the sketch.

Each step can only start once the one before it has finished. Some take a fixed time; one, loading, takes as long as the network does. That’s choreography, and choreography needs one place that always knows what’s happening right now.

So the whole thing runs on a single value, phase, which is always exactly one of five words: idle, covering, covered, drawing or revealing.

02 · Watch it

One click, five phases

Below is a miniature of the site, running the same five phases at the real timings. Click the link inside the little page and follow the strip underneath; slow it down to watch each phase on its own.

One click, five phases
sujoykh.vercel.app/
Sujoy Kr. Haldar
  1. idle
  2. covering
  3. covered
  4. drawing
  5. revealing

Waiting for a click. Nothing is animating.

The address bar gives the trick away. The route changes in the middle, while nothing is on screen, and that unseen moment is what the cover is for: by the time anything appears, the new page is already there to be measured and sketched.

03 · How it’s built

Five phases, one string

Each phase owns one job, and each knows how it ends: a timer runs out, the new page arrives, or an animation calls back to say it’s finished.

  1. 1.idle

    Nothing is animating. A click on any link calls navigate(), the only way in.

    setPhase("covering")
  2. 2.covering

    The page you're on sinks away: it scales down and blurs, then fades. It ends on a timer.

    setTimeout(handleCovered, …)
  3. 3.covered

    Unseen: jump to the top, change the route, then wait for the new page to arrive.

    router.push(href)
  4. 4.drawing

    Measure the new page's marked blocks and sketch them in pencil. The sketch says when it's done.

    onDrawn → revealing
  5. 5.revealing

    Each block blurs in where its box was drawn. When the last sketch has faded, it's over.

    onGone → finish()
↺ Back to idle, ready for the next click

Two branches keep it honest. A page with no wireframe marked skips drawing and goes straight from covered to revealing. And the résumé island comes in its own way: instead of the page sinking, its thumbnail flies to where the sheet sits on the next page, with the sketch riding on it. Same machine, a different way of covering.

04 · Why not booleans

Impossible states, made impossible

The obvious way to build this is a handful of flags: isLeaving, isLoading, isDrawing, isRevealing. Four booleans allow sixteen combinations, and only five of them make sense. Nothing stops the other eleven from happening.

Double-click the link below with booleans. Each click starts its own transition, they run on top of each other, the page flips twice, and you land back where you started. Switch back to one phase and the second click is simply ignored.

Double-click the link
sujoykh.vercel.app/
Sujoy Kr. Haldar
  1. idle
  2. covering
  3. covered
  4. drawing
  5. revealing

Extra clicks ignored: 0

One line does the guarding: navigate() returns early unless the phase is idle. A phase can’t be two things at once, so there’s no combination left to get wrong.

05 · The code

Small pieces, each with one job

navigate() is the only way in. Clicking the page you’re already on just glides back to the top, visitors who prefer reduced motion get a plain page change, and anything that isn’t idle is turned away.

The way in: only idle can start a transition
const navigate = useCallback(
  (href, { grow = null, carry = null } = {}) => {
    const path = new URL(href, window.location.href).pathname;
    // Already here: just glide back to the top
    if (path === pathname) {
      if (lenis) lenis.scrollTo(0);
      else window.scrollTo({ top: 0, behavior: "smooth" });
      return false;
    }
    if (phase !== "idle") return false;
    if (reduceMotion) {
      router.push(href);
      return false;
    }
    // Within a shell only the content leaves: a frozen copy of it, so the new
    // content can load in its place straight away
    const content = !grow && !carry && sharesShell(pathname, path) ? document.querySelector("[data-transition-content]") : null;
    setTarget({
      href,
      path,
      grow,
      carry,
      contentOnly: Boolean(content),
      snapshot: content ? freeze(content) : null,
      // The page sinks toward the middle of what's on screen, not of the whole page
      leaveOrigin: `50% ${window.scrollY + window.innerHeight / 2}px`,
    });
    setLayout(null);
    setGrowSketched(false);
    setCarried(carry && { ...carry, to: null, id: performance.now() });
    setPhase("covering");
    return true;
  },
  [pathname, phase, reduceMotion, router, lenis]

Each phase’s ending lives in an effect keyed on the phase. The effect starts the timer and its cleanup clears it, so if the phase moves on for any other reason, a stale timer can never fire late.

Covering ends on a timer, cleared if the phase moves on
// The page sinking away covers the switch (a growing element calls handleCovered
// itself). A frozen copy needs no waiting: it sinks on its own over the new content
useEffect(() => {
  if (phase !== "covering" || target?.grow) return;
  const timer = setTimeout(handleCovered, target?.snapshot ? 0 : LEAVE_DURATION * 1000);
  return () => clearTimeout(timer);
}, [phase, target, handleCovered]);

Covered is the one phase that waits on the outside world. It holds until the new route has really arrived, gives the layout a moment to settle, then measures the page for its sketch. If the page never comes, it reveals anyway after ten seconds.

Covered: wait for the page, then measure it
// New page is in: sketch its wireframe if it has one, otherwise reveal it
useEffect(() => {
  if (phase !== "covered") return;
  if (pathname !== target?.path) {
    const timer = setTimeout(() => setPhase("revealing"), MAX_WAIT);
    return () => clearTimeout(timer);
  }

  let frame;
  const timer = setTimeout(() => {
    // A carried element sets off for its place on the new page
    if (target.carry) {
      const rect = target.carry.find()?.getBoundingClientRect();
      setCarried((carry) => carry && { ...carry, to: rect ? { left: rect.left, top: rect.top, width: rect.width, height: rect.height } : null });
    }
    // A growing element brings its own sketch: wait for that to finish
    if (target.grow) {
      setPhase("drawing");
      return;
    }
    if (!hasPageWireframe(target.path)) {
      setPhase("revealing");
      return;
    }
    frame = requestAnimationFrame(() => {
      const measured = measurePageWireframe(target.path, { contentOnly: target.contentOnly });
      if (measured) {
        setLayout(measured);
        setPhase("drawing");
      } else {
        setPhase("revealing");
      }
    });
  }, SETTLE);
  return () => {
    clearTimeout(timer);
    cancelAnimationFrame(frame);
  };
}, [phase, pathname, target]);

Everything else is worked out from the phase, never stored beside it: whether the page is covered, and how the page should look. Moving between two details is the same machine with a smaller stage and a quicker pace: only the content changes, while the list of details beside it stays put. Covering lasts a moment there, because a frozen copy of the old content does the leaving (the next detail shows why).

Everything else is derived from the phase
const isCovered = COVERED_PHASES.includes(phase);
const grow = target?.grow;
const leaving = phase === "covering" ? "leaving" : "hidden";
// The page's state (see PageStage); a growing element covers it instead, and
// within a shell only the content goes (see PageContent): hidden at once, its
// frozen copy does the leaving
const stage = grow || !isCovered || target?.contentOnly ? "shown" : leaving;
const contentStage = target?.contentOnly && isCovered ? "hidden" : "shown";

06 · Decisions

Why it’s built this way

One string, not four flags

Five legal states, and no way to be in two at once. Adding a phase means adding a word, not checking every combination of flags.

Timers live in effects

Each phase starts its timer in an effect keyed on the phase, and the cleanup clears it. A phase that ends early can’t be ended twice.

Pages don't know

A page only marks its parts for the sketch and the reveal. It never imports the transition: its content reads whether the page is covered and waits.

Always a way out

If the new page never arrives, it reveals anyway after ten seconds. A stuck cover is worse than a missing sketch.

One gap worth naming: the browser’s back and forward buttons skip all of this. The browser swaps the page itself, instantly, so there’s nothing to sink away. Playing the sketch and reveal on arrival there too is next on my list.

07 · Takeaways

What to keep

  • Choreography needs one source of truth: a single phase, not a set of flags.
  • Give each phase one way to end (a timer, an event or a callback) and clean it up when the phase changes.
  • Derive everything else from the phase, and guard the way in: only idle can start a transition.
See it live: this link runs it→

Watch this page sink away, the home page’s sketch draw in, and the page build inside it.