SolutionsCase StudiesAI ToolsResourcesAboutContact
Get Your Free AI Automation Audit Book a Free 30-Minute Strategy Call
Playground~/frontend Open editor

~/css/page-transitions

7 CSS Page Transitions

0 Pure CSS7 with JSPublished 2026-10-05Free · no sign-up

Seven ways to move between pages or screens: a cross-fade, a slide that follows the direction of travel, a View Transitions API morph, a curtain wipe, a circle that grows from the clicked link, content that staggers in and a push transition like a phone app.

01. Cross-Fade Between Pages

TransitionFade

A mini app with two pages and a tab bar: the outgoing page fades out and the new one fades in. Pages are stacked in one grid cell, the hidden one is inert so it cannot be focused, and aria-current marks the active tab.

View code
<div class="pt1"><div class="pages"><section class="on"><h3>Home</h3><p>Recent orders and quick actions.</p></section><section inert><h3>Settings</h3><p>Account, billing and team.</p></section></div><nav aria-label="Demo"><button aria-current="page">Home</button><button>Settings</button></nav></div>
.pt1 { box-sizing: border-box; width: 260px; max-width: 100%; border: 1px solid #e4e7ec; border-radius: 18px; background: #fff; overflow: hidden; font-family: Inter, system-ui, sans-serif; }
.pt1 .pages { display: grid; height: 170px; }
.pt1 section { grid-area: 1 / 1; padding: 20px; opacity: 0; transition: opacity .35s ease; }
.pt1 section.on { opacity: 1; }
.pt1 h3 { margin: 0 0 6px; color: #101828; } .pt1 p { margin: 0; color: #475467; font-size: 14px; }
.pt1 nav { display: flex; border-top: 1px solid #e4e7ec; }
.pt1 nav button { flex: 1; padding: 12px; border: 0; background: transparent; color: #475467; font: 600 14px Inter, system-ui, sans-serif; cursor: pointer; }
.pt1 nav [aria-current] { color: #4f46e5; box-shadow: inset 0 2px #4f46e5; }
.pt1 button:focus-visible { outline: 3px solid #a5b4fc; outline-offset: -3px; }
@media (prefers-reduced-motion: reduce) { .pt1 section { transition: none; } }
var w = document.querySelector('.pt1'), ss = w.querySelectorAll('section'), bs = w.querySelectorAll('nav button');
bs.forEach(function (b, i) { b.onclick = function () {
  ss.forEach(function (s, j) { s.classList.toggle('on', i === j); s.inert = i !== j; });
  bs.forEach(function (x, j) { if (i === j) x.setAttribute('aria-current', 'page'); else x.removeAttribute('aria-current'); });
}; });

02. Direction-Aware Slide

TransitionSlide

Going forward slides the new page in from the right and going back slides it in from the left, which tells people where they are in a sequence. The direction is a data attribute on the container that the keyframes read through two animation names.

View code
<div class="pt2" data-dir="fwd"><div class="vp"><section aria-live="polite"><h3>Step 1</h3><p>Choose a template.</p></section></div><div class="ctl"><button class="b">Back</button><button class="n">Next</button></div></div>
.pt2 { width: 260px; max-width: 100%; font-family: Inter, system-ui, sans-serif; }
.pt2 .vp { height: 140px; overflow: hidden; border: 1px solid #2a3350; border-radius: 16px; background: #141a2c; }
.pt2 section { padding: 20px; color: #cbd5e1; }
.pt2 h3 { margin: 0 0 6px; color: #fff; } .pt2 p { margin: 0; font-size: 14px; }
.pt2 section.go { animation: pt2f .35s ease both; }
.pt2[data-dir="back"] section.go { animation-name: pt2b; }
@keyframes pt2f { from { transform: translateX(40%); opacity: 0; } }
@keyframes pt2b { from { transform: translateX(-40%); opacity: 0; } }
.pt2 .ctl { display: flex; justify-content: space-between; margin-top: 10px; }
.pt2 button { padding: 9px 16px; border: 1px solid #3a4670; border-radius: 10px; background: #1b2238; color: #e2e8f0; font: 600 14px Inter, system-ui, sans-serif; cursor: pointer; }
.pt2 button:focus-visible { outline: 2px solid #818cf8; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .pt2 section.go { animation: none; } }
var w = document.querySelector('.pt2'), s = w.querySelector('section'), n = 1;
var text = ['Choose a template.', 'Add your products.', 'Pick a domain.', 'Go live.'];
function go(d) {
  var k = Math.min(4, Math.max(1, n + d)); if (k === n) return; n = k;
  w.dataset.dir = d > 0 ? 'fwd' : 'back';
  s.classList.remove('go'); void s.offsetWidth; s.classList.add('go');
  s.querySelector('h3').textContent = 'Step ' + n; s.querySelector('p').textContent = text[n - 1];
}
w.querySelector('.n').onclick = function () { go(1); };
w.querySelector('.b').onclick = function () { go(-1); };

03. View Transitions API Swap

TransitionView Transitions

document.startViewTransition() snapshots the old state, runs your DOM update and cross-fades to the new state, and a shared view-transition-name morphs the thumbnail into the hero image. Browsers without the API simply update instantly, so nothing breaks.

View code
<div class="pt3"><div class="list"><button class="th" style="--g:linear-gradient(135deg,#f97316,#db2777)" aria-label="Open Sunset print"></button><button class="th" style="--g:linear-gradient(135deg,#22d3ee,#4f46e5)" aria-label="Open Ocean print"></button></div><div class="detail" hidden><div class="hero"></div><p></p><button class="back">Back</button></div></div>
.pt3 { width: 260px; max-width: 100%; font-family: Inter, system-ui, sans-serif; }
.pt3 .list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.pt3 .th { aspect-ratio: 1; border: 0; border-radius: 12px; background: var(--g); cursor: pointer; }
.pt3 .hero { aspect-ratio: 16 / 10; border-radius: 16px; view-transition-name: pt3hero; }
.pt3 .detail p { margin: 10px 0; color: #101828; font-weight: 700; }
.pt3 .back { padding: 8px 14px; border: 1px solid #d0d5dd; border-radius: 8px; background: #fff; font: 600 14px Inter, system-ui, sans-serif; cursor: pointer; }
.pt3 button:focus-visible { outline: 3px solid #84adff; outline-offset: 2px; }
::view-transition-group(pt3hero) { animation-duration: .4s; }
@media (prefers-reduced-motion: reduce) { ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; } }
var w = document.querySelector('.pt3'), list = w.querySelector('.list'), det = w.querySelector('.detail'), hero = w.querySelector('.hero');
function swap(fn) { if (document.startViewTransition) document.startViewTransition(fn); else fn(); }
w.querySelectorAll('.th').forEach(function (t) { t.onclick = function () {
  t.style.viewTransitionName = 'pt3hero';
  swap(function () { t.style.viewTransitionName = ''; hero.style.background = t.style.getPropertyValue('--g'); det.querySelector('p').textContent = t.getAttribute('aria-label').replace('Open ', ''); list.hidden = true; det.hidden = false; det.querySelector('.back').focus(); });
  w.last = t;
}; });
det.querySelector('.back').onclick = function () {
  var t = w.last; hero.style.viewTransitionName = 'pt3hero';
  swap(function () { hero.style.viewTransitionName = ''; det.hidden = true; list.hidden = false; t.style.viewTransitionName = 'pt3hero'; t.focus(); });
  setTimeout(function () { t.style.viewTransitionName = ''; hero.style.viewTransitionName = ''; }, 600);
};

04. Curtain Wipe Between Pages

TransitionWipe

A coloured panel sweeps across, covers the old page, the content swaps underneath, and the panel sweeps away. It is two keyframe halves on one overlay, and the swap happens on the animation's midpoint using a timer that matches the CSS duration.

View code
<div class="pt4"><div class="pg"><h3>Work</h3><p>Selected projects 2024-2026.</p></div><div class="cv" aria-hidden="true"></div><button>Go to About</button></div>
.pt4 { position: relative; overflow: hidden; width: 260px; max-width: 100%; height: 200px; padding: 20px; box-sizing: border-box; border-radius: 16px; background: #121828; font-family: Inter, system-ui, sans-serif; color: #cbd5e1; }
.pt4 h3 { margin: 0 0 6px; color: #fff; font-size: 22px; } .pt4 p { margin: 0; font-size: 14px; }
.pt4 .cv { position: absolute; inset: 0; background: #f59e0b; transform: translateX(-101%); }
.pt4 .cv.run { animation: pt4 .9s cubic-bezier(.7,0,.3,1) both; }
@keyframes pt4 { 0% { transform: translateX(-101%); } 45%, 55% { transform: translateX(0); } 100% { transform: translateX(101%); } }
.pt4 button { position: absolute; left: 20px; bottom: 20px; padding: 9px 14px; border: 0; border-radius: 8px; background: #fff; color: #0f172a; font: 700 13px Inter, system-ui, sans-serif; cursor: pointer; }
.pt4 button:focus-visible { outline: 3px solid #fcd34d; outline-offset: 2px; }
var w = document.querySelector('.pt4'), cv = w.querySelector('.cv'), b = w.querySelector('button'), about = false;
b.onclick = function () {
  var swap = function () { about = !about; w.querySelector('h3').textContent = about ? 'About' : 'Work'; w.querySelector('p').textContent = about ? 'A two-person studio in Bristol.' : 'Selected projects 2024-2026.'; b.textContent = about ? 'Go to Work' : 'Go to About'; };
  if (matchMedia('(prefers-reduced-motion: reduce)').matches) { swap(); return; }
  cv.classList.remove('run'); void cv.offsetWidth; cv.classList.add('run'); setTimeout(swap, 450);
};

05. Circle Reveal from the Clicked Link

TransitionClip-path

The next page grows out of the link you clicked as an expanding circle, made by animating clip-path: circle() from the pointer's coordinates. The position is passed in two custom properties so the same keyframes work from any link.

View code
<div class="pt5"><ul><li><button>Pricing</button></li><li><button>Contact</button></li></ul><section hidden><h3></h3><button class="x">Close</button></section></div>
.pt5 { position: relative; overflow: hidden; width: 260px; max-width: 100%; height: 200px; padding: 18px; box-sizing: border-box; border: 1px solid #e4e7ec; border-radius: 16px; background: #fff; font-family: Inter, system-ui, sans-serif; }
.pt5 ul { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.pt5 ul button { width: 100%; padding: 12px; border: 1px solid #d0d5dd; border-radius: 10px; background: #f9fafb; font: 600 15px Inter, system-ui, sans-serif; color: #101828; text-align: left; cursor: pointer; }
.pt5 section { position: absolute; inset: 0; padding: 18px; background: #4338ca; color: #fff; animation: pt5 .5s ease both; }
@keyframes pt5 { from { clip-path: circle(0 at var(--x) var(--y)); } to { clip-path: circle(150% at var(--x) var(--y)); } }
.pt5 h3 { margin: 0 0 12px; font-size: 24px; }
.pt5 .x { padding: 8px 14px; border: 0; border-radius: 8px; background: #fff; color: #312e81; font: 700 13px Inter, system-ui, sans-serif; cursor: pointer; }
.pt5 button:focus-visible { outline: 3px solid #a5b4fc; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .pt5 section { animation: none; } }
var w = document.querySelector('.pt5'), s = w.querySelector('section'), last;
w.querySelectorAll('ul button').forEach(function (b) { b.onclick = function (e) {
  var r = w.getBoundingClientRect(), br = b.getBoundingClientRect();
  s.style.setProperty('--x', (e.clientX ? e.clientX - r.left : br.left - r.left + 20) + 'px');
  s.style.setProperty('--y', (e.clientY ? e.clientY - r.top : br.top - r.top + 20) + 'px');
  s.querySelector('h3').textContent = b.textContent; s.hidden = false; last = b; s.querySelector('.x').focus();
}; });
s.querySelector('.x').onclick = function () { s.hidden = true; last.focus(); };

06. Staggered Content on Page Change

TransitionStagger

When the page changes, its heading, text and list items animate in one after another rather than all at once. Each element gets an index custom property, and a single keyframe uses it for its delay, so adding an item needs no new CSS.

View code
<div class="pt6"><button>Show next page</button><section><h3 style="--i:0">Features</h3><p style="--i:1">Everything included in every plan.</p><ul><li style="--i:2">Unlimited projects</li><li style="--i:3">Version history</li><li style="--i:4">Custom domains</li></ul></section></div>
.pt6 { width: 270px; max-width: 100%; font-family: Inter, system-ui, sans-serif; color: #cbd5e1; }
.pt6 button { margin-bottom: 14px; padding: 9px 14px; border: 0; border-radius: 8px; background: #22d3ee; color: #083344; font: 700 13px Inter, system-ui, sans-serif; cursor: pointer; }
.pt6 button:focus-visible { outline: 3px solid #a5f3fc; outline-offset: 2px; }
.pt6 h3 { margin: 0 0 6px; color: #fff; font-size: 20px; } .pt6 p { margin: 0 0 10px; font-size: 14px; }
.pt6 ul { margin: 0; padding-left: 18px; font-size: 14px; line-height: 1.8; }
.pt6 section.go [style*="--i"] { animation: pt6 .45s ease both; animation-delay: calc(var(--i) * 80ms); }
@keyframes pt6 { from { opacity: 0; transform: translateY(12px); } }
@media (prefers-reduced-motion: reduce) { .pt6 section.go [style*="--i"] { animation: none; } }
var s = document.querySelector('.pt6 section'), h = s.querySelector('h3'), pages = [['Features', 'Everything included in every plan.'], ['Security', 'How we keep your data safe.'], ['Support', 'Help when you need it.']], k = 0;
document.querySelector('.pt6 button').onclick = function () {
  k = (k + 1) % pages.length; h.textContent = pages[k][0]; s.querySelector('p').textContent = pages[k][1];
  s.classList.remove('go'); void s.offsetWidth; s.classList.add('go');
};

07. Push and Scale Like a Native App

TransitionMobile

Opening a detail screen pushes it in from the right while the list behind it scales down and dims, the way phone apps move between levels. Both screens live in the frame, the hidden one is inert, and Back reverses the same transitions.

View code
<div class="pt7"><section class="list"><h3>Inbox</h3><button>Lunch on Friday?</button><button>Invoice #2041</button></section><section class="det" inert><button class="back">&larr; Inbox</button><h3></h3><p>Message body goes here.</p></section></div>
.pt7 { box-sizing: border-box; position: relative; overflow: hidden; width: 240px; max-width: 100%; height: 280px; border: 1px solid #e4e7ec; border-radius: 22px; background: #101828; font-family: Inter, system-ui, sans-serif; }
.pt7 section { position: absolute; inset: 0; padding: 18px; background: #fff; transition: transform .4s cubic-bezier(.2,.8,.2,1), filter .4s; }
.pt7 h3 { margin: 0 0 12px; color: #101828; }
.pt7 .list button { display: block; width: 100%; margin-bottom: 8px; padding: 12px; border: 0; border-radius: 10px; background: #f2f4f7; color: #101828; font: 600 14px Inter, system-ui, sans-serif; text-align: left; cursor: pointer; }
.pt7 .det { transform: translateX(100%); box-shadow: -10px 0 20px -10px rgba(0,0,0,.4); }
.pt7.open .det { transform: none; }
.pt7.open .list { transform: scale(.92); filter: brightness(.6); }
.pt7 .back { margin-bottom: 10px; padding: 0; border: 0; background: none; color: #1d4ed8; font: 600 14px Inter, system-ui, sans-serif; cursor: pointer; }
.pt7 p { margin: 0; color: #475467; font-size: 14px; }
.pt7 button:focus-visible { outline: 3px solid #84adff; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .pt7 section { transition: none; } }
var w = document.querySelector('.pt7'), l = w.querySelector('.list'), d = w.querySelector('.det'), last;
l.querySelectorAll('button').forEach(function (b) { b.onclick = function () { last = b; d.querySelector('h3').textContent = b.textContent; w.classList.add('open'); d.inert = false; l.inert = true; d.querySelector('.back').focus(); }; });
d.querySelector('.back').onclick = function () { w.classList.remove('open'); d.inert = true; l.inert = false; last.focus(); };

About these CSS Page Transitions

Transitions explain navigation

A good transition shows where you went: forward slides from the right, back from the left, a detail view grows out of the item you tapped. Keep them short, around 300 to 400ms, so they never feel like waiting.

The View Transitions API

document.startViewTransition(update) takes a snapshot, runs your DOM change and animates between the two. Giving matching elements the same view-transition-name morphs one into the other. Browsers without it just update instantly.

Hidden pages must stay hidden

When two pages are stacked for a cross-fade, make the inactive one inert so keyboard focus and screen readers cannot wander into it, and move focus to the new page's heading or first control.

Turn them off on request

Large movements are the motion most likely to cause discomfort. Under prefers-reduced-motion: reduce swap the content instantly or use a quick fade.

Frequently asked questions

Do these work between real page loads?

These demos switch views inside one page. For full page loads, cross-document view transitions use @view-transition { navigation: auto; } in browsers that support it.

Will transitions hurt performance?

Not if they animate transform, opacity and clip-path, which the browser can run off the main thread.

Is a JavaScript framework needed?

No. Each demo is plain HTML, CSS and a few lines of script.