9 CSS Page Transitions — No Library, Pure CSS
css page transitions swap two views using only transform, opacity, and clip-path — no plugin, no library.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Default cross-fade
- 02 Left/right slide
- 03 Shared element hero morph
- 04 Circle reveal from click point
- 05 App-style stack push
- 06 Card to detail zoom
- 07 Tab content cross-fade
- 08 Animated list reorder
- 09 Theme dark-mode wipe
The order below follows how much of the screen the transition actually touches. 01 and 02 swap the whole page with one property each — opacity, then transform. 03 through 06 carry one element's identity across the swap, from a thumbnail growing into a hero image to a card filling the screen. 07 and 08 stay inside a single page, reordering or refreshing a region without ever leaving it. 09 closes the set with a page-wide mode switch. Every panel below runs its own two-second demo loop; the untrimmed files, including the vanilla and React versions, are in the zip.
01Default cross-fade
The old page's opacity drops to zero while the new page's opacity rises to one, both painted in the same spot so the swap reads as one image dissolving into another. It's the transition to reach for first — blog posts, general navigation, anywhere a jump-cut would feel abrupt but a slide would feel like too much.
.page--a { animation: cross-a $dur-base $ease-out; }
.page--b { animation: cross-b $dur-base $ease-out; opacity: 0; }
@keyframes cross-a {
0% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes cross-b {
0% { opacity: 0; }
100% { opacity: 1; }
}
02Left/right slide
The incoming page pushes in from the right on translateX while the outgoing page is shoved out to the left, so the whole screen reads as one strip of content sliding past a fixed viewport. It suits step forms and any mobile flow where back and forward need an obvious direction, since reversing the transform reverses the direction for free.
.page--a { animation: slide-a $dur-quick $ease-spring; }
.page--b { animation: slide-b $dur-quick $ease-spring; transform: translateX(100%); }
@keyframes slide-a {
0% { transform: translateX(0); }
100% { transform: translateX(-100%); }
}
@keyframes slide-b {
0% { transform: translateX(100%); }
100% { transform: translateX(0); }
}
03Shared element hero morph
One thumbnail in a list grows in place until it fills the frame as the next page's hero image, while a caption fades in only once the shape has finished expanding. It's built for product and portfolio detail pages, where jumping straight to a static hero loses the connection between the list item you picked and the page you landed on.
.hero {
position: absolute; left: 5vw; top: 46px; width: 30%; height: 44px;
transform-origin: top left;
animation: hero-grow $dur-loop $ease-spring infinite;
}
@keyframes hero-grow {
0%, 40% { width: 30%; height: 44px; top: 46px; }
70%, 90% { width: 88%; height: 108px; top: 12px; }
100% { width: 30%; height: 44px; top: 46px; }
}
04Circle reveal from click point
A circle expands outward from a fixed point using clip-path, uncovering the next page from that origin instead of wiping the whole screen at once. Anchor the circle's origin to a dark-mode toggle or a theme switch, and the reveal reads as if the click itself released the new page.
.page--circle {
clip-path: circle(0% at 88% 14%);
animation: circle-grow $dur-enter $ease-pop;
}
@keyframes circle-grow {
0% { clip-path: circle(0% at 88% 14%); }
100% { clip-path: circle(150% at 88% 14%); }
}
05App-style stack push
The new screen slides in from the right while the page behind it shrinks a few percent and darkens under a translucent overlay, borrowing the depth cue native app navigation stacks use to show one screen sitting on top of another. Reach for it in a mobile web app or any settings flow that should feel like drilling into a stack rather than jumping to an unrelated page.
.page--a { animation: push-back $dur-base $ease-out; transform-origin: left center; }
.page--b { animation: push-front $dur-base $ease-out; transform: translateX(100%); }
.dim { animation: push-dim $dur-base $ease-out; }
@keyframes push-back { 0% { transform: scale(1); } 100% { transform: scale(.94); } }
@keyframes push-front { 0% { transform: translateX(100%); } 100% { transform: translateX(0); } }
@keyframes push-dim { 0% { opacity: 0; } 100% { opacity: .18; } }
06Card to detail zoom
The clicked card scales up on transform alone until it covers the stage, and a detail panel fades in only near the end of that scale so the enlarging card and the arriving text never fight for attention. Galleries and news feeds use this to keep the clicked item as the visual anchor instead of cutting to an unrelated detail layout.
.cell--focus {
position: relative;
animation: zoom-card $dur-enter $ease-spring;
}
.detail {
opacity: 0;
animation: zoom-detail $dur-enter $ease-spring;
}
@keyframes zoom-card {
0%, 40% { transform: scale(1); }
70% { transform: scale(4); }
100% { transform: scale(1); }
}
@keyframes zoom-detail {
0%, 55% { opacity: 0; }
75%, 90% { opacity: 1; }
100% { opacity: 0; }
}
07Tab content cross-fade
Switching tabs fades the old panel out while nudging it up 8px, and fades the new panel in from 8px below, so the content changes without the tab bar or the surrounding page ever moving. It keeps settings screens and dashboard panels from flashing blank between clicks, since both panels briefly overlap instead of one disappearing before the other starts.
.page--a { animation: tab-a $dur-quick $ease-out; }
.page--b { animation: tab-b $dur-quick $ease-out; opacity: 0; transform: translateY(8px); }
@keyframes tab-a {
0% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-8px); }
}
@keyframes tab-b {
0% { opacity: 0; transform: translateY(8px); }
100% { opacity: 1; transform: translateY(0); }
}
08Animated list reorder
When the sort order changes, the two rows that swapped places glide to their new row on translateY instead of the list re-rendering in its final order instantly. The distance each row travels is measured first, then played back as one transform — the same FLIP idea a JS library would run, done here with two keyframe rules. It reads clearly on price sorts and ranking tables, where a row jumping straight to its new spot hides which items actually moved.
.row--1 {
animation: swap-down $dur-base $ease-spring;
}
.row--2 {
animation: swap-up $dur-base $ease-spring;
}
@keyframes swap-down {
0% { transform: translateY(0); }
100% { transform: translateY(34px); }
}
@keyframes swap-up {
0% { transform: translateY(0); }
100% { transform: translateY(-34px); }
}
09Theme dark-mode wipe
A clip-path polygon grows from a single point in the corner into a triangle big enough to cover the screen, so the page reads as being swept into dark mode on a diagonal edge rather than snapping or fading all at once. Use it on the same toggle or settings screen where a circle reveal would feel too playful for a mode switch.
.page--wipe {
clip-path: polygon(0 0, 0 0, 0 0);
animation: dark-wipe $dur-enter $ease-out;
}
@keyframes dark-wipe {
0% { clip-path: polygon(0 0, 0 0, 0 0); }
100% { clip-path: polygon(0 0, 200% 0, 0 200%); }
}
Where it breaks — the trap
Every panel in the grid above is two flat "pages" swapping automatically on a two-second loop, sitting inside a fixed, non-scrollable iframe — watch it run rather than reaching for a click, since nothing in that box is wired to respond to one. That loop stands in for a real navigation event; a router or a button click is what should actually flip these classes on a live page.
The trap that catches people moving past CSS page-transition examples toward the newer API: the browser's native View Transitions API (document.startViewTransition) is not what any of the nine above use — they're all manual transform and clip-path animations that work everywhere CSS animations do. The native API needs feature detection, since same-document support only landed in Chrome 111+, Safari 18+, and Firefox 144+ per MDN's View Transition API browser compatibility table — call it without a support check and older browsers get no transition at all rather than the manual fallback. The nine builds in this post sidestep that gap entirely, at the cost of writing two keyframe blocks per transition instead of naming a view-transition-name and letting the browser interpolate. Zip password, if the manual route is what you came for: amqzkrw2.
Accessibility
All nine answer prefers-reduced-motion, but "off" means something different for each. 01, 05, and 07 drop straight to their end opacity and position — no fade, no slide, just the new content already in place. 02's slide and 08's row swap skip the translate entirely and place both elements at their resting coordinates. 03, 04, 06, and 09 — the shape-changing ones — jump straight to their finished clip-path or scale instead of animating through it, since a hero that silently teleports into place beats one frozen mid-morph.
@media (prefers-reduced-motion: reduce) {
.page--a, .page--b, .cell--focus, .detail, .hero, .hero__caption,
.page--circle, .page--wipe, .row--1, .row--2, .dim { animation: none !important; }
}
More CSS transitions and layout patterns live in the CSS category, and what this site actually gives you is explained on the about page.
FAQ
Are these CSS page transitions or the View Transitions API?
They're CSS page transitions in the older, framework-agnostic sense — two stacked elements animated with transform, opacity, and clip-path. None of the nine call document.startViewTransition, which is why they run in any browser that supports CSS animations rather than only the versions listed for the native API.
Why does the demo grid loop instead of waiting for a click?
The grid sits in a small fixed iframe with nothing to navigate to, so each panel is set to swap on a two-second loop automatically for preview purposes. On a real page, the same keyframes would be triggered by a route change or a button, not by a timer.
Can I combine these with React Router or Next.js?
Yes. Every SCSS file here only names classes and keyframes — none of it touches routing — so the router's job stays limited to swapping which component renders, while these classes handle how the outgoing and incoming DOM nodes look for a couple hundred milliseconds. The zip's react/ folder wraps each one in a component that takes the same duration and color as props.