A covered route transition that clips and scales the page, WebGL compatible.
Add Atelier's Clip Transition to my app.
If there is no components.json, run: npx shadcn@latest init -d
Then run: npx shadcn@latest add @atelier/clip-transition
That writes the atelier-ui skill under .agents/skills and .claude/skills. Follow it.
Props: background="#ffffff" coverColor="#FFD23F" clipColor="#121212" clipX={20} clipY={10} scale={0.8} duration={1.3}Just into your favorite coding agent and let Atelier do its magic. Live props are included.
This command will install all the dependencies this component uses.
npx shadcn@latest add @atelier/clip-transitionInstall the dependencies first, then feel free to copy the files into your project as you see fit.
npm install motion"use client"
import type { AnimationPlaybackControls, Easing } from "motion"
import { useAnimate } from "motion/react"
import {
type ComponentRef,
createContext,
type ReactNode,
type RefObject,
useContext,
useRef,
useState,
} from "react"
import { flushSync } from "react-dom"
import { TransitionProvider } from "../page-transition/page-transition"
const CLIP_EASE: Easing = [0.7, 0.02, 0.12, 1]
const SWEEP_EASE: Easing = [0.833, 0.04, 0.14, 1]
const BASE_DURATION = 1.8
const REDUCED_DURATION = 0.15
type Inner = { left: number; width: number } | null
type Phase = { name: "idle" | "out" | "reduced" } | { name: "in"; scrollY: number; inner: Inner }
const ClipContext = createContext<{
phase: Phase
coverColor: string
background: string
pageRef: RefObject<ComponentRef<"div"> | null>
}>({
phase: { name: "idle" },
coverColor: "whitesmoke",
background: "white",
pageRef: { current: null },
})
function ClipPage({ children }: { children: ReactNode }) {
const { phase, coverColor, background, pageRef } = useContext(ClipContext)
const name = phase.name
return (
<div
ref={pageRef}
className={`clip-page ${name === "in" ? "fixed inset-0 overflow-clip will-change-transform" : ""}`}
style={{
backgroundColor: background,
...(name === "out" ? { clipPath: "none", transform: "none" } : undefined),
}}
>
<div
style={
phase.name === "in"
? {
position: "relative",
top: -phase.scrollY,
isolation: "isolate",
...(phase.inner
? { width: phase.inner.width, marginLeft: phase.inner.left }
: undefined),
}
: undefined
}
>
{children}
</div>
{name === "in" && (
<div
aria-hidden
className="clip-cover pointer-events-none absolute inset-0 z-60"
style={{ transform: "translateY(100%)", backgroundColor: coverColor }}
/>
)}
</div>
)
}
type ClipTransitionProps = {
children: ReactNode
coverColor?: string
background?: string
clipColor?: string
clipX?: number
clipY?: number
scale?: number
duration?: number
loadingSlot?: ReactNode
}
export function ClipTransition({
children,
coverColor = "whitesmoke",
background = "white",
clipColor = "black",
clipX = 20,
clipY = 10,
scale = 0.8,
duration = 1.3,
loadingSlot = null,
}: ClipTransitionProps) {
const [scope, animate] = useAnimate()
const pageRef = useRef<ComponentRef<"div">>(null)
const [phase, setPhase] = useState<Phase>({ name: "idle" })
const canvasAnimations = useRef<AnimationPlaybackControls[]>([])
const name = phase.name
const viewX = 50 - scale * (50 - clipX)
const viewY = 50 - scale * (50 - clipY)
const pageClip = `inset(${clipY}% ${clipX}% ${clipY}% ${clipX}%)`
const bandClip = `inset(${viewY}% ${viewX}% ${viewY}% ${viewX}%)`
const bandClipSwept = `inset(${viewY}% ${viewX}% ${100 - viewY}% ${viewX}%)`
const factor = duration / BASE_DURATION
const coverIn = async (reduced: boolean) => {
if (reduced) {
flushSync(() => setPhase({ name: "reduced" }))
await animate(".clip-band", { opacity: [0, 1] }, { duration: REDUCED_DURATION })
return
}
const canvas = document.querySelector<HTMLCanvasElement>("[data-atelier-webgl] canvas")
const rect = pageRef.current?.getBoundingClientRect()
const inner: Inner = rect ? { left: rect.left, width: rect.width } : null
flushSync(() => setPhase({ name: "in", scrollY: window.scrollY, inner }))
if (canvas) {
const value = {
clip: 0,
zoom: 1,
cover: 100,
}
const apply = () => {
const localY = clipY * value.clip
const localX = clipX * value.clip
const localBottom = 100 - Math.min(100 - localY, value.cover)
const top = 50 - value.zoom * (50 - localY)
const x = 50 - value.zoom * (50 - localX)
const bottom = 50 - value.zoom * (50 - localBottom)
canvas.style.clipPath = `inset(${top}% ${x}% ${bottom}% ${x}%)`
}
canvasAnimations.current = [
animate(0, 1, {
duration: 1.5 * factor,
ease: CLIP_EASE,
onUpdate: (v) => {
value.clip = v
apply()
},
}),
animate(1, scale, {
duration: 1.8 * factor,
ease: CLIP_EASE,
onUpdate: (v) => {
value.zoom = v
apply()
},
}),
animate(100, clipY, {
duration: 1.4 * factor,
ease: SWEEP_EASE,
delay: 0.35 * factor,
onUpdate: (v) => {
value.cover = v
apply()
},
}),
]
}
await animate([
[
".clip-page",
{ clipPath: ["inset(0% 0% 0%)", pageClip] },
{ duration: 1.5 * factor, ease: CLIP_EASE, at: 0 },
],
[
".clip-page",
{
scale: [1, scale],
},
{ duration: 1.8 * factor, ease: CLIP_EASE, at: 0 },
],
[
".clip-cover",
{ y: ["100%", `${clipY}%`] },
{ duration: 1.4 * factor, ease: SWEEP_EASE, at: 0.35 * factor },
],
])
}
const coverOut = async (reduced: boolean) => {
if (reduced) {
await animate(".clip-band", { opacity: [1, 0] }, { duration: REDUCED_DURATION })
setPhase({ name: "idle" })
return
}
await Promise.resolve()
flushSync(() => setPhase({ name: "out" }))
for (const animation of canvasAnimations.current) animation.stop()
canvasAnimations.current = []
const canvas = document.querySelector<ComponentRef<"canvas">>("[data-atelier-webgl] canvas")
if (canvas) canvas.style.clipPath = ""
await animate([
[
".clip-band",
{ clipPath: [bandClip, bandClipSwept] },
{ duration: 1 * factor, ease: SWEEP_EASE },
],
[
".clip-backdrop",
{ clipPath: ["inset(0% 0% 0%)", "inset(0% 0% 100% 0%)"] },
{ duration: 1.3 * factor, ease: SWEEP_EASE, at: 0.1 * factor },
],
])
setPhase({ name: "idle" })
}
return (
<TransitionProvider coverIn={coverIn} coverOut={coverOut} page={ClipPage}>
<ClipContext.Provider value={{ phase, coverColor, background, pageRef }}>
<div ref={scope} className="contents">
<div
aria-hidden
className={`clip-backdrop pointer-events-none fixed inset-0 ${name === "out" ? "z-60" : "z-0"} ${name === "in" || name === "out" ? "" : "invisible"}`}
style={
name === "in" || name === "out"
? { clipPath: "inset(0% 0% 0%)", background: clipColor }
: undefined
}
/>
{children}
{loadingSlot && (
<div
aria-hidden
className={`clip-loading pointer-events-none fixed inset-0 z-75 flex items-center justify-center transition-opacity duration-300 ${name === "in" ? "opacity-100 delay-1000" : "opacity-0 delay-0"} ${name === "idle" ? "invisible" : ""}`}
>
{loadingSlot}
</div>
)}
<div
aria-hidden
className={`clip-band pointer-events-none fixed inset-0 z-70 ${name === "out" || name === "reduced" ? "" : "invisible"}`}
style={{ backgroundColor: coverColor, ...bandStyle(name, bandClip) }}
/>
</div>
</ClipContext.Provider>
</TransitionProvider>
)
}
function bandStyle(name: Phase["name"], bandClip: string) {
if (name === "out") return { clipPath: bandClip }
if (name === "reduced") return { opacity: 0 }
return undefined
}
"use client"
import { useProgress, useTexture } from "@react-three/drei"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import {
type ComponentProps,
type ComponentType,
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
useTransition,
} from "react"
export type CoverAnimation = (reduced: boolean) => Promise<void>
type PageComponent = ComponentType<{ children: ReactNode }>
type TransitionLinkProps = Omit<ComponentProps<typeof Link>, "href"> & {
href: string
preload?: string[]
}
const NavigateContext = createContext<((href: string) => void) | null>(null)
const PageContext = createContext<PageComponent | null>(null)
type TransitionProviderProps = {
children: ReactNode
coverIn: CoverAnimation
coverOut: CoverAnimation
page?: PageComponent
}
export function TransitionProvider({ children, coverIn, coverOut, page }: TransitionProviderProps) {
const router = useRouter()
const pathname = usePathname()
const busy = useRef(false)
const reduced = useRef(false)
const animations = useRef({ coverIn, coverOut })
animations.current = { coverIn, coverOut }
const [covered, setCovered] = useState(false)
const [isPending, startTransition] = useTransition()
const loaderActive = useProgress((state) => state.active)
const pathnameRef = useRef(pathname)
pathnameRef.current = pathname
const navigate = useCallback(
async (href: string) => {
if (busy.current) return
if (new URL(href, window.location.href).pathname === pathname) return
busy.current = true
router.prefetch(href)
window.history.pushState(null, "", href)
document.documentElement.setAttribute("data-atelier-transitioning", "")
reduced.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches
await animations.current.coverIn(reduced.current)
startTransition(() => router.replace(href))
setCovered(true)
},
[router, pathname],
)
useEffect(() => {
const onPopState = (event: PopStateEvent) => {
if (busy.current) return
if (window.location.pathname === pathnameRef.current) return
event.stopImmediatePropagation()
busy.current = true
document.documentElement.setAttribute("data-atelier-transitioning", "")
const href = window.location.pathname + window.location.search + window.location.hash
reduced.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches
animations.current.coverIn(reduced.current).then(() => {
startTransition(() => router.replace(href, { scroll: false }))
setCovered(true)
})
}
window.addEventListener("popstate", onPopState)
return () => {
window.removeEventListener("popstate", onPopState)
document.documentElement.removeAttribute("data-atelier-transitioning")
}
}, [router])
/*
* After the route commits, WebGL planes mount into the canvas a frame
* later, so asset loading has not started yet when this effect first
* runs. The reveal waits two frames and re-checks the live store: if
* loading began, the effect re-runs when it ends.
*/
useEffect(() => {
if (!covered || isPending || loaderActive) return
let cancelled = false
const raf = requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (cancelled || useProgress.getState().active) return
setCovered(false)
animations.current.coverOut(reduced.current).then(() => {
document.documentElement.removeAttribute("data-atelier-transitioning")
busy.current = false
})
})
})
return () => {
cancelled = true
cancelAnimationFrame(raf)
}
}, [covered, isPending, loaderActive])
return (
<NavigateContext.Provider value={navigate}>
<PageContext.Provider value={page ?? null}>{children}</PageContext.Provider>
</NavigateContext.Provider>
)
}
export function TransitionPage({ children }: { children: ReactNode }) {
const Page = useContext(PageContext)
return Page ? <Page>{children}</Page> : children
}
export function TransitionLink({ href, preload, ...rest }: TransitionLinkProps) {
const navigate = useContext(NavigateContext)
const preloadAssets = () => {
for (const url of preload ?? []) useTexture.preload(url)
}
return (
<Link
{...rest}
href={href}
onPointerEnter={(event) => {
rest.onPointerEnter?.(event)
preloadAssets()
}}
onNavigate={(event) => {
if (!navigate) return
event.preventDefault()
preloadAssets()
navigate(href)
}}
/>
)
}
Place ClipTransition in the layout whose routes should transition, wrap the route content with TransitionPage, and use TransitionLink for the links that trigger it.
In the root layout, every route transitions.
In a route group layout, only the routes inside the group transition, with no change to the URL.
import { ClipTransition } from "@/components/clip-transition/clip-transition"
import { TransitionLink, TransitionPage } from "@/components/page-transition/page-transition"
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<ClipTransition>
<nav className="fixed z-10 transition-opacity duration-500 [[data-atelier-transitioning]_&]:opacity-0">
<TransitionLink href="/">Home</TransitionLink>
<TransitionLink href="/work">Work</TransitionLink>
<TransitionLink href="/about">About</TransitionLink>
</nav>
<TransitionPage>{children}</TransitionPage>
</ClipTransition>
)
}During the exit, a cover with z-index: 60 and a band with z-index: 70 overlay the page, above everything inside TransitionPage.
While a transition runs, data-atelier-transitioning is set on <html>, and the nav in the example uses it to fade out.
A fixed element with a z-index above 70, such as a persistent header, stays visible instead.
Click a TransitionLink and the URL updates at once.
The exit animation plays over the current page, the route commits behind the cover, and the enter animation reveals the new page.
Back and forward play the same transition, with the previous scroll position restored behind the cover.
When the WebGL provider is present, its canvas clip follows the page during the exit, and WebGL media tracks the scaling page without autoReflow.
Assets in a link's preload prop start downloading on hover and again on click.
They are usually in cache before the cover is down.
When they are not, the reveal waits.
With prefers-reduced-motion, the whole transition is a short fade.
| Name | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Content wrapped by the transition. |
background | string | "white" | Background color of the page while it clips and scales. |
coverColor | string | "whitesmoke" | Color of the band that sweeps over the page. |
clipColor | string | "black" | Color of the backdrop revealed around the clipped page. |
clipX | number | 20 | Horizontal inset of the clipped page, in percent per side. |
clipY | number | 10 | Vertical inset of the clipped page, in percent per side. |
scale | number | 0.8 | Scale the page shrinks to while clipped. |
duration | number | 1.3 | Duration of the main clip movement, in seconds. The other tracks scale with it. |
loadingSlot | ReactNode | — | Centered over the cover while the page is covered, fading in after the cover arrives. |
| Name | Type | Default | Description |
|---|---|---|---|
href | string | — | Destination route. Required. |
preload | string[] | — | Texture URLs of the destination page, downloaded on hover and on click. |
Every other prop of the Next.js Link component is forwarded.
| Name | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Route content animated by the active transition. |
ClipTransition is built on TransitionProvider, which runs the navigation sequence for a pair of cover animations.
Rendering it directly with custom coverIn and coverOut callbacks produces a custom transition.
| Name | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Content wrapped by the provider. |
coverIn | (reduced: boolean) => Promise<void> | — | Animation that covers the page before navigation. Required. |
coverOut | (reduced: boolean) => Promise<void> | — | Animation that reveals the page after navigation. Required. |
page | ComponentType<{ children: ReactNode }> | — | Component rendered around route content by TransitionPage. |
Motion
React animation library.