A covered route transition that wipes a staggered grid of stripes across the page.
Add Atelier's Stripe Transition to my app.
If there is no components.json, run: npx shadcn@latest init -d
Then run: npx shadcn@latest add @atelier/stripe-transition
That writes the atelier-ui skill under .agents/skills and .claude/skills. Follow it.
Props: color="#151515" stripes={8} segments={6} duration={0.5} stagger={0.025}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/stripe-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 { Easing } from "motion"
import { motion } from "motion/react"
import { type ReactNode, useRef, useState } from "react"
import { type CoverAnimation, TransitionProvider } from "../page-transition/page-transition"
const EASE: Easing = [0.85, 0, 0.2, 1]
const REDUCED_DURATION = 0.15
type Phase = "idle" | "in" | "out" | "reduced-in" | "reduced-out"
type StripeTransitionProps = {
children: ReactNode
color?: string
stripes?: number
segments?: number
duration?: number
stagger?: number
}
export function StripeTransition({
children,
color = "#151515",
stripes = 8,
segments = 6,
duration = 0.5,
stagger = 0.025,
}: StripeTransitionProps) {
const [phase, setPhase] = useState<Phase>("idle")
const pending = useRef(0)
const resolveRef = useRef<(() => void) | null>(null)
const settle = (next?: Phase) => () => {
pending.current -= 1
if (pending.current !== 0) return
resolveRef.current?.()
if (next) setPhase(next)
}
const coverIn: CoverAnimation = (reduced) =>
new Promise((resolve) => {
resolveRef.current = resolve
pending.current = stripes * segments
setPhase(reduced ? "reduced-in" : "in")
})
const coverOut: CoverAnimation = (reduced) =>
new Promise((resolve) => {
resolveRef.current = resolve
pending.current = stripes * segments
setPhase(reduced ? "reduced-out" : "out")
})
const covering = phase === "in" || phase === "reduced-in"
const reduced = phase === "reduced-in" || phase === "reduced-out"
return (
<TransitionProvider coverIn={coverIn} coverOut={coverOut}>
{children}
<div
aria-hidden
className={`pointer-events-none fixed inset-0 z-70 flex ${phase === "idle" ? "invisible" : ""}`}
>
{Array.from({ length: stripes }, (_, stripeIndex) => (
<div
key={stripeIndex}
className="not-last:-mr-px flex flex-1 flex-col overflow-hidden"
>
{Array.from({ length: segments }, (_, segmentIndex) => {
const reversedDelay =
(stripes - 1 - stripeIndex + (segments - 1 - segmentIndex)) *
stagger
const forwardDelay = (stripeIndex + segmentIndex) * stagger
const delay = covering ? reversedDelay : forwardDelay
return (
<motion.div
key={segmentIndex}
className="not-last:-mb-px flex-1"
style={{ backgroundColor: color }}
animate={
reduced
? { opacity: covering ? 1 : 0, x: 0 }
: { opacity: 1, x: covering ? ["101%", "0%"] : "-101%" }
}
transition={
reduced
? { duration: REDUCED_DURATION }
: { duration, ease: EASE, delay }
}
onAnimationComplete={settle(
phase === "out" || phase === "reduced-out"
? "idle"
: undefined,
)}
/>
)
})}
</div>
))}
</div>
</TransitionProvider>
)
}
"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 trimSlash = (path: string) => path.replace(/(.)\/+$/, "$1")
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 locationPath = useRef("")
useEffect(() => {
locationPath.current = window.location.pathname
}, [pathname])
const navigate = useCallback(
async (href: string) => {
if (busy.current) return
const target = new URL(href, `http://n${pathname}`).pathname
if (trimSlash(target) === trimSlash(pathname)) return
busy.current = true
router.prefetch(href)
document.documentElement.setAttribute("data-atelier-transitioning", "")
reduced.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches
await animations.current.coverIn(reduced.current)
startTransition(() => router.push(href))
setCovered(true)
},
[router, pathname],
)
useEffect(() => {
const onPopState = (event: PopStateEvent) => {
if (busy.current) return
if (window.location.pathname === locationPath.current) return
event.stopImmediatePropagation()
busy.current = true
document.documentElement.setAttribute("data-atelier-transitioning", "")
const href = window.location.href
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, { capture: true })
return () => {
window.removeEventListener("popstate", onPopState, { capture: true })
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 StripeTransition 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 { TransitionLink, TransitionPage } from "@/components/page-transition/page-transition"
import { StripeTransition } from "@/components/stripe-transition/stripe-transition"
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<StripeTransition>
<nav>
<TransitionLink href="/">Home</TransitionLink>
<TransitionLink href="/work">Work</TransitionLink>
<TransitionLink href="/about">About</TransitionLink>
</nav>
<TransitionPage>{children}</TransitionPage>
</StripeTransition>
)
}During a transition, the stripes overlay the page with z-index: 70, above everything else, including fixed navigation.
A fixed element with a z-index above 70, such as a persistent header, stays visible instead.
While a transition runs, data-atelier-transitioning is set on <html>.
Click a TransitionLink and the URL updates at once.
A grid of vertical stripes, each split into segments, slides in over the current page with delays cascading across the grid.
The route commits behind the cover, and the segments keep traveling in the same direction to reveal the new page, with the cascade running in reverse order.
Back and forward play the same transition, with the previous scroll position restored behind the cover.
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. |
color | string | "#151515" | Color of the stripes. |
stripes | number | 8 | Number of vertical stripes. |
segments | number | 6 | Number of segments per stripe. |
duration | number | 0.5 | Duration of each segment's slide, in seconds. |
stagger | number | 0.025 | Delay between consecutive cells of the cascade, in seconds. |
| 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. |
StripeTransition 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.