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 componentshadcn CLI
Terminal
npx shadcn@latest add https://mocoui.site/r/view-transitions.jsonManual
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.cssnpm 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;
}
}