View Transitions
The web has always been bad at animation.
Not because animation is hard—but because it never matched how we build interfaces.
We describe UI as state. A component renders one way, then another. React made this natural. But animation never followed. The moment we wanted motion, we dropped down a level—timelines, keyframes, physics engines, libraries.
We stopped describing what changed and started describing how it should move.
That mismatch is the real problem.
View Transitions are the first serious attempt to fix it.
What are View Transitions?
View Transitions let the browser animate between two states of your UI.
You render one state. Then another. The browser figures out how to morph between them.
In React, this becomes even more interesting.
You don’t manually trigger animations. You don’t coordinate elements. You just update state—inside a transition—and React turns that change into motion.
Demos
The easiest way to understand this is to see it.
1. Cambio
Inspired by Cambio by Rapheal Salaja
Loading demo...
"use client";
import cambioImage from "@/public/images/cambio.jpg";
import Image from "next/image";
import { startTransition, useState, ViewTransition } from "react";
const Cambio = () => {
const [isOpen, setIsOpen] = useState(false);
const toggle = () => {
startTransition(() => {
setIsOpen(!isOpen);
});
};
return (
<div className="size-full lg:p-10 p-4">
{!isOpen && (
<div onClick={toggle} className="size-full">
<ViewTransition name="cambio-image">
<div className="size-full relative rounded-xl overflow-hidden">
<Image
src={cambioImage}
placeholder="blur"
alt="Cambio"
fill
className="object-cover"
/>
</div>
</ViewTransition>
</div>
)}
{isOpen && (
<div
onClick={toggle}
className="fixed inset-0 bg-background/90 flex items-center justify-center z-100000"
>
<ViewTransition name="cambio-image">
<div className="size-3/4 relative rounded-2xl overflow-hidden">
<Image
src={cambioImage}
placeholder="blur"
alt="Cambio"
fill
className="object-cover"
/>
</div>
</ViewTransition>
</div>
)}
</div>
);
};
export default Cambio;
If you click the image, it expands into a modal. The image doesn’t just appear—it continues into the new layout.
What’s happening here is surprisingly simple.
We wrap both states in a ViewTransition and give them the same name. That’s enough for React to understand they represent the same visual element across two states.
React handles the rest.
What’s interesting here isn’t the animation itself. Shared element transitions have existed for years in libraries.
What’s new is this:
React understands the relationship between states.
The animation isn’t something we orchestrate anymore. It’s something React infers by default.
2. Carousel
Loading demo...
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { startTransition, useState, ViewTransition } from "react";
const images = [
{ className: "bg-red-500" },
{ className: "bg-blue-500" },
{ className: "bg-green-500" },
];
export default function Carousel() {
const [index, setIndex] = useState(0);
const [direction, setDirection] = useState(1);
const next = () => {
startTransition(() => {
setDirection(1);
setIndex((i) => (i + 1) % images.length);
});
};
const prev = () => {
startTransition(() => {
setDirection(-1);
setIndex((i) => (i - 1 + images.length) % images.length);
});
};
return (
<div className="size-full flex flex-col items-center justify-center p-4 gap-4">
<div className="flex-1 min-h-0 w-full">
<ViewTransition
name={direction === 1 ? "carousel-next" : "carousel-prev"}
>
<div className={cn("size-full", images[index]?.className)} />
</ViewTransition>
</div>
<div className="flex items-center justify-center gap-4">
<Button onClick={prev}>Prev</Button>
<Button onClick={next}>Next</Button>
</div>
</div>
);
}
Carousels are usually more complicated than they look.
You track positions. You calculate offsets. You manage direction. You sync timing.
Here, it’s just state.
Step 1 — Give the image a transition identity (the name prop)
<ViewTransition name="carousel">
<img src={images[index]} />
</ViewTransition>Step 2 — Direction-aware transitions
If you want direction (next vs previous), you track it in state:
const [direction, setDirection] = useState(1);
const next = () => {
startTransition(() => {
setDirection(1);
setIndex((i) => (i + 1) % images.length);
});
};
const prev = () => {
startTransition(() => {
setDirection(-1);
setIndex((i) => (i - 1 + images.length) % images.length);
});
};Then switch transition names:
<ViewTransition name={direction === 1 ? "carousel-next" : "carousel-prev"}>
<img src={images[index]} />
</ViewTransition>Step 3 — CSS
/* NEXT */
::view-transition-old(carousel-next) {
animation: slide-out-left 300ms ease forwards;
}
::view-transition-new(carousel-next) {
animation: slide-in-right 300ms ease forwards;
}
/* PREV */
::view-transition-old(carousel-prev) {
animation: slide-out-right 300ms ease forwards;
}
::view-transition-new(carousel-prev) {
animation: slide-in-left 300ms ease forwards;
}
@keyframes slide-out-left {
to {
transform: translateX(-100%);
}
}
@keyframes slide-in-right {
from {
transform: translateX(100%);
}
}
@keyframes slide-out-right {
to {
transform: translateX(100%);
}
}
@keyframes slide-in-left {
from {
transform: translateX(-100%);
}
}Small polish
::view-transition-group(carousel-next),
::view-transition-group(carousel-prev) {
overflow: hidden;
}3. Exit animations
Loading demo...
"use client";
import { Button } from "@/components/ui/button";
import { ViewTransition, useState, startTransition } from "react";
import { motion, AnimatePresence } from "motion/react";
const Exit = () => {
const [show, setShow] = useState(false);
const [showMotionExit, setShowMotionExit] = useState(false);
const toggle = () => {
startTransition(() => setShow((prev) => !prev));
};
return (
<div className="size-full flex p-4 justify-center gap-10">
<div className="relative flex">
<Button onClick={toggle}>
{show ? "Hide View Transition" : "Show View Transition"}
</Button>
{show && (
<ViewTransition enter="auto" exit="auto" default="none">
<div className="absolute size-40 top-10 left-1/2 -translate-x-1/2 border flex flex-col gap-4 bg-background p-4 rounded-lg" />
</ViewTransition>
)}
</div>
<div className="relative">
<Button onClick={() => setShowMotionExit((prev) => !prev)}>
{showMotionExit ? "Hide Motion" : "Show Motion"}
</Button>
<AnimatePresence>
{showMotionExit && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute size-40 top-10 left-1/2 -translate-x-1/2 border flex flex-col gap-4 bg-background p-4 rounded-lg"
/>
)}
</AnimatePresence>
</div>
</div>
);
};
export default Exit;
Exit animations used to require careful orchestration. Libraries solved this by tracking when components leave the tree.
With View Transitions, React already knows.
You can define:
enterexit
And React applies them when components mount or unmount.
The key constraint:
setState(...) ❌ no animation
startTransition(() => setState(...)) ✅ animationAnimation only happens inside a transition.
Where this breaks down
View Transitions aren’t a replacement for animation libraries.
They struggle with:
- complex choreography
- physics-based motion
- fine-grained control
They also rely on snapshots. What you’re seeing is often a visual interpolation, not a true layout animation.
But that’s also why they work so well.
They simplify the problem.
Page transitions
Page transitions are where this becomes most obvious and where it'll shine the most in my opinion.
Navigating between pages used to be abrupt. Or heavily engineered to make a continuous transition. With View Transitions, navigation becomes just another state change. Layouts persist. Elements morph. The experience feels continuous.
Here are my favorite examples:
Conclusion
React made UI declarative.
View Transitions extend that idea to animation.
Instead of describing motion step by step, we describe state changes—and let the system handle the rest.
That’s a small shift in how we write code.
But it’s a big shift in how the web feels.
If you made it this far, I'd love to hear your thoughts. Let me know if it's something that resonates with you on X(Twitter)
