Atelier UI®

DocsCatalogPricingGithub
Docs 1.0.02.0 in development.

tools

  • Browse Catalog
  • Collage
    new

Documentation

  • How it works
  • License
  • MCP

Page Transition (04)

  • Clip Transition
  • Stripe Transition
  • Pixel Transition
  • Band Transition

Components (36)

  • Orbit Gallery
  • Sphere Gallery
  • Spiral Gallery
  • Glowing Fog
  • Gradient Flow
  • Halftone Glow
  • Scattered Grid
  • Tag Cloud
  • Edge Bounce
  • Fluid Distortion
  • Image Trail
  • Lens Media
  • Liquid Media
  • Magnetic Dot Grid
  • Pixel Media
  • Pixel Trail
  • Dither Cursor
  • Hover Burst
  • Image Bloom
  • Curve Media
  • Infinite Gallery
  • Infinite Parallax
  • Infinite Zoom
  • Pixel Scroll
  • Scattered Scroll
  • Elastic Stick
  • Letter Swarm
  • Magnify Trail
  • Stacking Grid
  • Wavy Scroll
  • Pixelated Text
  • Text Bounce
  • Text Fluid
  • Text Scramble
  • Falling Text
  • Text Roll

Foundation Blocks (07)

  • Smooth Scroll
  • Text Split
  • WebGL Image
  • WebGL Provider
  • WebGL Scene
  • WebGL Text
  • WebGL Video
Atelier UI 1.0.0 ©2026
Star on githubBuy me a coffee
  1. Docs
  2. /
  3. Page Transition
  4. /
  5. Pixel Transition

Pixel Transition

A covered route transition that pixelates the screen with a fixed grid of squares.

https://atelier-ui.com/pixel-transition
color
121212
hint
303030
pixels
10
duration
0.50
color
121212
hint
303030
pixels
10
duration
0.50
Prompt
Add Atelier's Pixel Transition to my app.

If there is no components.json, run: npx shadcn@latest init -d
Then run: npx shadcn@latest add @atelier/pixel-transition
That writes the atelier-ui skill under .agents/skills and .claude/skills. Follow it.

Props: color="#121212" hint="#303030" pixels={10} duration={0.5}

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/pixel-transition

Install the dependencies first, then feel free to copy the files into your project as you see fit.

npm install motion
pixel-transition.tsx
"use client"

import { motion } from "motion/react"
import { type ReactNode, useRef, useState } from "react"
import { type CoverAnimation, TransitionProvider } from "../page-transition/page-transition"

const ROWS_RATIO = 0.6
const HINT_EVERY = 5
const FLASH = 0.2
const REDUCED_DURATION = 0.15

type Phase = "idle" | "in" | "out" | "reduced-in" | "reduced-out"

type PixelTransitionProps = {
    children: ReactNode
    color?: string
    hint?: string
    pixels?: number
    duration?: number
}

const sequence = (cells: number) => Array.from({ length: cells }, (_, index) => index)

const shuffle = (cells: number) => {
    const order = sequence(cells)
    for (let i = order.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1))
        ;[order[i], order[j]] = [order[j], order[i]]
    }
    return order
}

export function PixelTransition({
    children,
    color = "black",
    hint = "#818181",
    pixels = 10,
    duration = 0.2,
}: PixelTransitionProps) {
    const [phase, setPhase] = useState<Phase>("idle")
    const pending = useRef(0)
    const resolveRef = useRef<(() => void) | null>(null)

    const columns = Math.round(pixels)
    const rows = Math.max(1, Math.round(pixels * ROWS_RATIO))
    const cells = columns * rows

    const [order, setOrder] = useState(() => sequence(cells))
    if (order.length !== cells) setOrder(sequence(cells))

    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 = cells
            setOrder(shuffle(cells))
            setPhase(reduced ? "reduced-in" : "in")
        })

    const coverOut: CoverAnimation = (reduced) =>
        new Promise((resolve) => {
            resolveRef.current = resolve
            pending.current = cells
            setPhase(reduced ? "reduced-out" : "out")
        })

    const covering = phase === "in" || phase === "reduced-in"
    const reduced = phase === "reduced-in" || phase === "reduced-out"

    const cellAnimation = (slot: number) => {
        const delay = (slot / cells) * duration
        const flash = slot % HINT_EVERY === 0 && (phase === "in" || phase === "out")

        if (reduced) {
            return {
                animate: { opacity: covering ? 1 : 0, backgroundColor: color },
                transition: { duration: REDUCED_DURATION },
            }
        }

        if (covering && flash) {
            return {
                animate: { opacity: 1, backgroundColor: [hint, hint, color] },
                transition: {
                    opacity: { duration: 0, delay },
                    backgroundColor: { duration: FLASH, delay, times: [0, 0.995, 1] },
                },
            }
        }

        if (covering) {
            return {
                animate: { opacity: 1, backgroundColor: color },
                transition: {
                    opacity: { duration: 0, delay },
                    backgroundColor: { duration: 0 },
                },
            }
        }

        if (flash) {
            return {
                animate: { opacity: 0, backgroundColor: hint },
                transition: {
                    opacity: { duration: 0, delay: delay + FLASH },
                    backgroundColor: { duration: 0, delay },
                },
            }
        }

        return {
            animate: { opacity: 0, backgroundColor: color },
            transition: {
                opacity: { duration: 0, delay },
                backgroundColor: { duration: 0 },
            },
        }
    }

    return (
        <TransitionProvider coverIn={coverIn} coverOut={coverOut}>
            {children}

            <div
                aria-hidden
                className={`pointer-events-none fixed inset-0 z-70 grid ${phase === "idle" ? "invisible" : ""}`}
                style={{
                    gridTemplateColumns: `repeat(${columns}, 1fr)`,
                    gridTemplateRows: `repeat(${rows}, 1fr)`,
                }}
            >
                {order.map((slot, index) => {
                    const { animate, transition } = cellAnimation(slot)

                    return (
                        <motion.div
                            key={index}
                            className="-m-px"
                            style={{ backgroundColor: color }}
                            animate={animate}
                            transition={transition}
                            onAnimationComplete={settle(
                                phase === "out" || phase === "reduced-out" ? "idle" : undefined,
                            )}
                        />
                    )
                })}
            </div>
        </TransitionProvider>
    )
}
page-transition.tsx
"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 PixelTransition 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 { PixelTransition } from "@/components/pixel-transition/pixel-transition"

export default function Layout({ children }: { children: React.ReactNode }) {
    return (
        <PixelTransition>
            <nav>
                <TransitionLink href="/">Home</TransitionLink>
                <TransitionLink href="/work">Work</TransitionLink>
                <TransitionLink href="/about">About</TransitionLink>
            </nav>

            <TransitionPage>{children}</TransitionPage>
        </PixelTransition>
    )
}

During a transition, the grid overlays 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 fixed grid of squares fills in shuffled order over the current page, the route commits behind the cover, and the squares clear in the same order to reveal the new page. The order reshuffles on every navigation. The grid count is fixed, so the effect looks the same at every viewport size. 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.


PixelTransition

NameTypeDefaultDescription
childrenReactNode—Content wrapped by the transition.
colorstring"black"Color of the squares.
hintstring"#818181"Accent color a random fifth of the squares flash while appearing and disappearing. At rest the cover is a single color.
pixelsnumber10Number of grid columns. The row count derives from it.
durationnumber0.2Time the grid takes to fill or clear, in seconds.

TransitionLink

NameTypeDefaultDescription
hrefstring—Destination route. Required.
preloadstring[]—Texture URLs of the destination page, downloaded on hover and on click.

Every other prop of the Next.js Link component is forwarded.

TransitionPage

NameTypeDefaultDescription
childrenReactNode—Route content animated by the active transition.

TransitionProvider

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

NameTypeDefaultDescription
childrenReactNode—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.
pageComponentType<{ children: ReactNode }>—Component rendered around route content by TransitionPage.

Motion
React animation library.

Star on githubBuy me a coffee