Rejouice — loading0

Curtain route transition

A full-bleed panel sweeps across the viewport to cover the outgoing page and retracts to reveal the incoming one.

Grade: ARuntimes: css · gsapStatus: code-only

Documented, no live demo yet — the code below is complete.

Intent

Client-side route changes are instant and therefore disorienting — the page simply becomes a different page. A curtain occupies the gap with an authored gesture, and it doubles as cover for whatever loading actually needs to happen.

Implementation

Uses the View Transitions API where available and falls back to a manual curtain. The focus and live-region work is identical on both paths and is the part most implementations omit.

Dependencies: next@^16, react@^19

next-tailwind-motion
"use client";

import { useRouter } from "next/navigation";
import { useCallback, useRef } from "react";

export function useCurtainNavigation() {
  const router = useRouter();
  const curtain = useRef<HTMLDivElement>(null);

  const navigate = useCallback(
    async (href: string) => {
      const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
      const el = curtain.current;

      if (reduce || !el?.animate) {
        router.push(href);
        return;
      }

      router.prefetch(href);

      await el.animate(
        [{ transform: "translateY(100%)" }, { transform: "translateY(0%)" }],
        { duration: 560, easing: "cubic-bezier(0.76, 0, 0.24, 1)", fill: "forwards" },
      ).finished;

      router.push(href);

      await el.animate(
        [{ transform: "translateY(0%)" }, { transform: "translateY(-100%)" }],
        { duration: 560, easing: "cubic-bezier(0.76, 0, 0.24, 1)", fill: "forwards" },
      ).finished;

      el.getAnimations().forEach((a) => a.cancel());
    },
    [router],
  );

  return { navigate, curtain };
}

/* Render once in the root layout, alongside a polite live region that the
   route segment updates with the new page title. */
export function Curtain({ ref }: { ref: React.Ref<HTMLDivElement> }) {
  return (
    <div
      ref={ref}
      aria-hidden
      className="pointer-events-none fixed inset-0 z-[9000] translate-y-full bg-[--bg-inverse]"
    />
  );
}

Use it when

  • Single-page-app navigation between top-level views.
  • When entering and leaving a case study or project detail.

Avoid it when

  • On a site where navigation is frequent and task-oriented — every transition is a tax paid on every click.
  • When the incoming route can render in under ~150ms anyway.

Accessibility

  • Move focus to the new page's `<h1>` (or a `tabindex="-1"` main landmark) after the reveal, or keyboard users are left on a removed element.
  • Announce the route change through a polite live region — the visual transition tells sighted users, and nothing tells anyone else.
  • Mark the curtain `aria-hidden` and ensure it never receives focus.
  • Under reduced motion, skip the animation and swap directly, keeping the focus and announcement behaviour.

Performance

  • Animate `transform`, never `height` or `clip-path` with animated coordinates.
  • Prefetch the target route while the curtain covers, so the reveal shows a rendered page rather than a spinner.
  • Cap total transition time at about 900ms; beyond that it reads as a slow site rather than a considered one.

Knobs

direction
Axis and origin of the sweep. Default: bottom to top
cover duration
Time to cover the outgoing view. Default: 560ms

Composition

Pairs with

Conflicts with

None.

Instruction for Claude Code

Add a curtain route transition. Render one fixed inset-0 pointer-events-none aria-hidden panel at z-index 9000 in the root layout. On navigation: prefetch the target, animate the curtain translateY from 100% to 0 over 560ms with ease-in-out-quart, push the route, then animate 0 to -100%, then cancel the animations. After the reveal, move focus to the new page's h1 or a tabindex=-1 main landmark and write the new page title into a polite live region. Under prefers-reduced-motion, push the route immediately but keep the focus move and the announcement. Total transition must stay under 900ms.