Documentation menu

view-transitions

Cross-fade View Transitions for Next.js App Router route changes, plus a no-js flag remover.

navigationanimationnextbehavior

Preview

Installation

Ask Claude (MCP)

With the MCP server configured, just ask:

Claude
add the moco view-transitions component

shadcn CLI

Terminal
npx shadcn@latest add https://mocoui.site/r/view-transitions.json

Manual

Copy each file from the Source section into its target path:

registry/view-transitions/view-transitions.tsx  →  components/moco/view-transitions.tsx
registry/view-transitions/view-transitions.css  →  components/moco/view-transitions.css

npm dependencies: next. Needs moco items: tokens

Usage

usage.tsx
import { ViewTransitions, JsFlag } from "./view-transitions";

/* Mount once, near the root of a Next.js app layout. Both render null;
   internal <a> link clicks then animate via the View Transitions API. */
export default function ViewTransitionsDemo() {
  return (
    <>
      <ViewTransitions />
      <JsFlag />
      <nav>
        <a href="/">Home</a> <a href="/about">About</a>
      </nav>
    </>
  );
}

Source

components/moco/view-transitions.tsx
"use client";

import * as React from "react";
import { useRouter } from "next/navigation";
import "./view-transitions.css";

/**
 * View Transitions for client-side route changes (Next.js App Router only).
 * Feature-detected, and a complete no-op where unsupported or under
 * prefers-reduced-motion.
 *
 * ponytail: the transition is closed on a short timer rather than on the
 * router's commit, because App Router does not expose a navigation-complete
 * promise. Swap the timer for that promise if/when Next exposes one.
 */
export function ViewTransitions() {
  const router = useRouter();

  React.useEffect(() => {
    const doc = document as Document & {
      startViewTransition?: (cb: () => void | Promise<void>) => unknown;
    };
    if (typeof doc.startViewTransition !== "function") return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

    const onClick = (e: MouseEvent) => {
      if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
        return;
      const a = (e.target as HTMLElement | null)?.closest?.("a");
      if (!a || a.target === "_blank" || a.hasAttribute("download")) return;
      const url = new URL(a.href, location.href);
      if (url.origin !== location.origin) return;
      if (url.pathname === location.pathname) return;

      e.preventDefault();
      doc.startViewTransition!(async () => {
        router.push(url.pathname + url.search + url.hash);
        await new Promise((r) => setTimeout(r, 90));
      });
    };

    document.addEventListener("click", onClick);
    return () => document.removeEventListener("click", onClick);
  }, [router]);

  return null;
}

/** Removes the `no-js` class so JS-only affordances can hide their fallbacks. */
export function JsFlag() {
  React.useEffect(() => {
    document.documentElement.classList.remove("no-js");
  }, []);
  return null;
}
components/moco/view-transitions.css
/* View transitions + reduced motion. The pseudo-elements are page-global by
   spec, so there is nothing to class-prefix here. */

@media (prefers-reduced-motion: no-preference) {
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation-duration: 250ms;
  }
}

/* Belt-and-braces: the JS already no-ops under reduced motion, but this also
   kills transitions the browser starts itself (e.g. back/forward). */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}