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
"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.