CSS Page Transitions: 16 Examples + the View Transition API

The CSS transition property can't animate a page change. The View Transition API can, inside a single-page app and between pages of one site.

Same-document transitions have been Baseline since October 2025, but cross-document ones still don't run in Firefox. Below are 16 CSS page transition examples you can try and copy, followed by the guide.

By Bogdan Sandu · Updated October 28, 2026

App page switches with the View Transition API

Each phone below is one document with several views, switched by document.startViewTransition(). The view-transition-name only exists while a transition runs, so the demos never collide on one page, and browsers without the API swap the view instantly.

Crossfade between app pages

  • startViewTransition()
  • view-transition-name
  • animation-duration

Larkspur

3 plants need water today

Today

  • MonsteraWater 400 ml
  • Fiddle leaf figRotate a quarter turn
  • CalatheaMist the leaves
  • PileaCheck soil moisture

The default view transition is a crossfade, and it already looks better than a hard cut. Only the page area carries a name, so the header and the tab bar stay sharp while the content fades over 350ms. The fade changes opacity only, so it is left on for reduced-motion users.

View code
.pt-xf {
  display: grid;
  grid-template-rows: auto 1fr auto;
  height: 100%;
  background: #f7f4ec;
  color: #1d2a22;
}
.pt-xf__top {
  padding: 14px 20px 12px;
}
.pt-xf__brand {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 24px;
  font-weight: 600;
  color: #1f4d3a;
}
.pt-xf__sub {
  margin: 0;
  font-size: 13px;
  color: #56635b;
}
.pt-xf__page {
  min-height: 0;
  overflow: hidden;
  padding: 0 16px;
  background: #f7f4ec;
}
.pt-xf--vt .pt-xf__page {
  view-transition-name: pt-xf-page;
}
::view-transition-old(pt-xf-page),
::view-transition-new(pt-xf-page) {
  animation-duration: .35s;
}
.pt-xf__h {
  font-family: inherit;
  color: inherit;
  letter-spacing: normal;
  margin: 4px 4px 12px;
  font-size: 17px;
}
.pt-xf__tasks {
  display: grid;
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.pt-xf__tasks li {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: 12px;
  padding: 12px 14px;
  border-radius: 14px;
  background: #fff;
  font-size: 13px;
  color: #56635b;
}
.pt-xf__tasks b {
  display: block;
  font-size: 15px;
  color: #1d2a22;
}
.pt-xf__tasks time {
  font-weight: 700;
  color: #1f4d3a;
}
.pt-xf__dot {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: #2f7a55;
}
.pt-xf__dot--b {
  background: #c27b1d;
}
.pt-xf__dot--c {
  background: #4a6fa5;
}
.pt-xf__grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.pt-xf__tile {
  display: grid;
  gap: 2px;
  min-height: 118px;
  align-content: end;
  padding: 12px;
  border-radius: 16px;
  background: #1f4d3a;
  color: #e9f1ec;
  font-size: 12px;
}
.pt-xf__tile b {
  font-size: 15px;
  color: #fff;
}
.pt-xf__tile--b {
  background: #7a4b12;
}
.pt-xf__tile--c {
  background: #2f4a72;
}
.pt-xf__tile--d {
  background: #3b5a2a;
}
.pt-xf__entry {
  margin-bottom: 10px;
  padding: 14px;
  border-left: 3px solid #1f4d3a;
  border-radius: 4px 14px 14px 4px;
  background: #fff;
}
.pt-xf__entry time {
  font-size: 12px;
  font-weight: 700;
  color: #1f4d3a;
}
.pt-xf__entry p {
  margin: 4px 0 0;
  font-size: 14px;
}
.pt-xf__tabs {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 6px;
  padding: 8px 12px 18px;
  border-top: 1px solid #e3ddcc;
  background: #fffdf8;
}
.pt-xf__tabs button {
  min-height: 44px;
  border: 0;
  border-radius: 12px;
  background: transparent;
  color: #56635b;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .2s ease, color .2s ease;
}
.pt-xf__tabs [aria-current="page"] {
  background: #1f4d3a;
  color: #fff;
}
.pt-xf__tabs button:focus-visible {
  outline: 2px solid #1f4d3a;
  outline-offset: 2px;
}

Drill in and back with transition types

  • types option
  • :active-view-transition-type()
  • @supports selector()

Settings

Signed in as Ines Moreau

Going deeper passes types: ['pt-forward'] to startViewTransition, and Back passes pt-back, so the same pair of snapshots slides in opposite directions. The rules sit inside @supports selector(:active-view-transition-type()), and browsers with the API but without types fall back to the crossfade. The group gets overflow: clip, which keeps the sliding snapshots inside the screen.

View code
.pt-dr {
  display: grid;
  grid-template-rows: auto 1fr;
  height: 100%;
  border-radius: 0 0 33px 33px;
  background: #f2f2f7;
  color: #111318;
}
.pt-dr__bar {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  min-height: 52px;
  padding: 0 8px;
  border-bottom: 1px solid #dcdce3;
  background: #f9f9fb;
}
.pt-dr__title {
  font-family: inherit;
  color: inherit;
  letter-spacing: normal;
  grid-column: 2;
  margin: 0;
  font-size: 16px;
  font-weight: 700;
}
.pt-dr__title:focus {
  outline: none;
}
.pt-dr__back {
  grid-column: 1;
  justify-self: start;
  min-height: 44px;
  padding: 0 10px 0 22px;
  border: 0;
  border-radius: 10px;
  background: transparent;
  color: #0a5bc4;
  font: inherit;
  font-size: 15px;
  cursor: pointer;
  position: relative;
}
.pt-dr__back::before {
  content: "";
  position: absolute;
  left: 8px;
  top: 50%;
  width: 9px;
  height: 9px;
  border-left: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: translateY(-50%) rotate(45deg);
}
.pt-dr__back[hidden] {
  display: none;
}
.pt-dr__body {
  position: relative;
  min-height: 0;
  overflow: hidden;
  padding: 18px 16px;
  border-radius: 0 0 33px 33px;
  background: #f2f2f7;
}
.pt-dr--vt .pt-dr__body {
  view-transition-name: pt-dr-page;
}
::view-transition-group(pt-dr-page) {
  overflow: clip;
  border-radius: 0 0 33px 33px;
}
.pt-dr__list {
  margin: 0;
  padding: 0;
  list-style: none;
  border-radius: 14px;
  background: #fff;
  overflow: hidden;
}
.pt-dr__list li + li {
  border-top: 1px solid #ececf1;
}
.pt-dr__row,
.pt-dr__toggle {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  min-height: 50px;
  padding: 0 14px;
  border: 0;
  background: #fff;
  color: inherit;
  font: inherit;
  font-size: 15px;
  text-align: left;
}
.pt-dr__row {
  cursor: pointer;
  position: relative;
}
.pt-dr__row::after {
  content: "";
  margin-left: auto;
  width: 8px;
  height: 8px;
  border-right: 2px solid #8a8a95;
  border-top: 2px solid #8a8a95;
  transform: rotate(45deg);
}
.pt-dr__toggle {
  justify-content: space-between;
}
.pt-dr__toggle input {
  width: 22px;
  height: 22px;
  accent-color: #1c7c3c;
}
.pt-dr__icon {
  width: 28px;
  height: 28px;
  border-radius: 8px;
  background: #d93a2b;
}
.pt-dr__icon--blue {
  background: #0a5bc4;
}
.pt-dr__icon--green {
  background: #1c7c3c;
}
.pt-dr__foot {
  margin: 10px 14px 0;
  font-size: 13px;
  color: #5d5d68;
}
.pt-dr__row:focus-visible,
.pt-dr__back:focus-visible,
.pt-dr__toggle input:focus-visible {
  outline: 2px solid #0a5bc4;
  outline-offset: -2px;
}
@keyframes pt-dr-push-out {
  to { translate: -30% 0; opacity: .5; }
}
@keyframes pt-dr-push-in {
  from { translate: 100% 0; }
}
@keyframes pt-dr-pop-out {
  to { translate: 100% 0; }
}
@keyframes pt-dr-pop-in {
  from { translate: -30% 0; opacity: .5; }
}
@media (prefers-reduced-motion: no-preference) {
  @supports selector(:active-view-transition-type(pt-forward)) {
    :active-view-transition-type(pt-forward)::view-transition-old(pt-dr-page) {
      animation: pt-dr-push-out .4s cubic-bezier(.3, 0, 0, 1) both;
      mix-blend-mode: normal;
    }
    :active-view-transition-type(pt-forward)::view-transition-new(pt-dr-page) {
      animation: pt-dr-push-in .4s cubic-bezier(.3, 0, 0, 1) both;
      mix-blend-mode: normal;
    }
    :active-view-transition-type(pt-back)::view-transition-old(pt-dr-page) {
      z-index: 1;
      animation: pt-dr-pop-out .4s cubic-bezier(.3, 0, 0, 1) both;
      mix-blend-mode: normal;
    }
    :active-view-transition-type(pt-back)::view-transition-new(pt-dr-page) {
      animation: pt-dr-pop-in .4s cubic-bezier(.3, 0, 0, 1) both;
      mix-blend-mode: normal;
    }
  }
}

Product card morphs into the detail page

  • shared element
  • img.decode()
  • per-card naming

Northlight Prints

Bag 0

Landscape prints

Only the tapped card image gets view-transition-name: pt-sm-photo, and the hero on the detail page gets the same name, so the browser animates one box into the other. The update callback returns img.decode(), so the new snapshot waits for the hero image. Back runs the same morph in reverse because the card keeps its active class.

View code
.pt-sm {
  height: 100%;
  overflow: hidden;
  background: #fff;
  color: #17140f;
}
.pt-sm__list,
.pt-sm__detail {
  height: 100%;
  padding: 10px 16px 16px;
}
.pt-sm__list[hidden],
.pt-sm__detail[hidden] {
  display: none;
}
.pt-sm__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12px;
}
.pt-sm__brand {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 20px;
  font-weight: 600;
}
.pt-sm__bag {
  padding: 4px 10px;
  border: 1px solid #17140f;
  border-radius: 99px;
  font-size: 12px;
  font-weight: 600;
}
.pt-sm__h {
  font-family: inherit;
  color: inherit;
  letter-spacing: normal;
  margin: 0 0 10px;
  font-size: 13px;
  font-weight: 600;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: #5c564c;
}
.pt-sm__grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 14px 10px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.pt-sm__card {
  display: grid;
  gap: 2px;
  width: 100%;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.pt-sm__card img,
.pt-sm__hero {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: 10px;
}
.pt-sm__card img {
  margin-bottom: 6px;
}
.pt-sm--vt .pt-sm__card--active img,
.pt-sm--vt .pt-sm__hero {
  view-transition-name: pt-sm-photo;
}
.pt-sm__name {
  font-size: 14px;
  font-weight: 600;
}
.pt-sm__price {
  font-size: 13px;
  color: #5c564c;
}
.pt-sm__back {
  min-height: 44px;
  margin: 0 0 6px -8px;
  padding: 0 10px;
  border: 0;
  border-radius: 10px;
  background: none;
  color: #a33a17;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
}
.pt-sm__back::before {
  content: "\2190\00a0";
}
.pt-sm__title {
  color: inherit;
  letter-spacing: normal;
  margin: 14px 0 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 26px;
  font-weight: 600;
  line-height: 1.15;
}
.pt-sm__cost {
  margin: 2px 0 10px;
  font-size: 15px;
  color: #5c564c;
}
.pt-sm__sizes {
  display: flex;
  gap: 8px;
  margin: 0 0 14px;
  padding: 0;
  border: 0;
}
.pt-sm__sizes legend {
  float: left;
  width: 100%;
  margin-bottom: 6px;
  font-size: 13px;
  color: #5c564c;
}
.pt-sm__sizes label {
  position: relative;
  display: grid;
  place-items: center;
  min-width: 56px;
  min-height: 40px;
  border: 1px solid #cfc8bc;
  border-radius: 10px;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.pt-sm__sizes input {
  position: absolute;
  opacity: 0;
}
.pt-sm__sizes label:has(:checked) {
  border-color: #17140f;
  background: #17140f;
  color: #fff;
}
.pt-sm__sizes label:has(:focus-visible) {
  outline: 2px solid #a33a17;
  outline-offset: 2px;
}
.pt-sm__add {
  width: 100%;
  min-height: 48px;
  border: 0;
  border-radius: 12px;
  background: #a33a17;
  color: #fff;
  font: inherit;
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
}
.pt-sm__card:focus-visible,
.pt-sm__back:focus-visible,
.pt-sm__add:focus-visible {
  outline: 2px solid #a33a17;
  outline-offset: 3px;
  border-radius: 10px;
}
@media (prefers-reduced-motion: no-preference) {
  ::view-transition-group(pt-sm-photo) {
    animation-duration: .45s;
    animation-timing-function: cubic-bezier(.2, 0, 0, 1);
  }
}

Inbox row opens into a message

  • two shared names
  • width: fit-content
  • dark UI

Inbox

3 unread

Two parts of the tapped row travel to the message view: the avatar and the sender name. The name gets width: fit-content, so the snapshot is only as wide as the text and scales without stretching. The rest of the inbox crossfades through the root snapshot.

View code
.pt-ld {
  height: 100%;
  overflow: hidden;
  background: #11141b;
  color: #e8ebf2;
}
.pt-ld__inbox,
.pt-ld__message {
  height: 100%;
  padding: 10px 14px;
}
.pt-ld__inbox[hidden],
.pt-ld__message[hidden] {
  display: none;
}
.pt-ld__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  padding: 4px 6px 10px;
}
.pt-ld__head h2 {
  font-family: inherit;
  color: inherit;
  letter-spacing: normal;
  margin: 0;
  font-size: 24px;
}
.pt-ld__head span {
  font-size: 13px;
  color: #9fb4ff;
}
.pt-ld__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.pt-ld__row {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 12px;
  width: 100%;
  padding: 12px 8px;
  border: 0;
  border-bottom: 1px solid #232835;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.pt-ld__avatar {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: #7aa2ff;
  color: #0b1020;
  font-size: 14px;
  font-weight: 700;
}
.pt-ld__avatar--b {
  background: #ffb86b;
}
.pt-ld__avatar--c {
  background: #7ee0b5;
}
.pt-ld__avatar--d {
  background: #f598c6;
}
.pt-ld__text {
  display: grid;
  min-width: 0;
}
.pt-ld__from {
  display: flex;
  justify-content: space-between;
  gap: 8px;
}
.pt-ld__name {
  width: fit-content;
  font-weight: 700;
}
.pt-ld__from time {
  font-size: 12px;
  color: #a3abbd;
}
.pt-ld__subject {
  font-size: 14px;
}
.pt-ld__preview {
  overflow: hidden;
  font-size: 13px;
  color: #a3abbd;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.pt-ld__row--unread .pt-ld__subject {
  font-weight: 700;
}
.pt-ld__row--unread .pt-ld__name::after {
  content: "";
  display: inline-block;
  width: 8px;
  height: 8px;
  margin-left: 6px;
  border-radius: 50%;
  background: #7aa2ff;
  vertical-align: 2px;
}
.pt-ld--vt .pt-ld__row--active .pt-ld__avatar,
.pt-ld--vt .pt-ld__big {
  view-transition-name: pt-ld-avatar;
}
.pt-ld--vt .pt-ld__row--active .pt-ld__name,
.pt-ld--vt .pt-ld__big-name {
  view-transition-name: pt-ld-name;
}
.pt-ld__back {
  min-height: 44px;
  margin-left: -6px;
  padding: 0 10px 0 24px;
  border: 0;
  border-radius: 10px;
  background: none;
  color: #9fb4ff;
  font: inherit;
  font-size: 15px;
  cursor: pointer;
  position: relative;
}
.pt-ld__back::before {
  content: "";
  position: absolute;
  left: 10px;
  top: 50%;
  width: 9px;
  height: 9px;
  border-left: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: translateY(-50%) rotate(45deg);
}
.pt-ld__meta {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 12px 4px 18px;
}
.pt-ld__meta .pt-ld__avatar {
  width: 52px;
  height: 52px;
  font-size: 17px;
}
.pt-ld__big-name {
  display: block;
  font-size: 18px;
}
.pt-ld__to {
  font-size: 13px;
  color: #a3abbd;
}
.pt-ld__msg-subject {
  font-family: inherit;
  color: inherit;
  letter-spacing: normal;
  margin: 0 4px 10px;
  font-size: 22px;
  line-height: 1.2;
}
.pt-ld__body {
  margin: 0 4px;
  font-size: 15px;
  line-height: 1.6;
  color: #cfd5e3;
}
.pt-ld__row:focus-visible,
.pt-ld__back:focus-visible {
  outline: 2px solid #9fb4ff;
  outline-offset: -2px;
}
@media (prefers-reduced-motion: no-preference) {
  ::view-transition-group(pt-ld-avatar),
  ::view-transition-group(pt-ld-name) {
    animation-duration: .4s;
    animation-timing-function: cubic-bezier(.2, 0, 0, 1);
  }
}

Circle reveal from the tapped button

  • transition.ready
  • clip-path: circle()
  • view-transition-class

Library

  • Low Tide StaticHarbor Lights
  • Paper SatellitesMira Vale
  • Night FerryThe Quiet Coast
  • GlasshouseOren Field
UndertowHarbor Lights

CSS cannot know where the tap happened, so once transition.ready resolves, the script animates clip-path on ::view-transition-new() with the Web Animations API, from a zero circle at the button to one that covers the farthest corner. Closing shrinks the old view back to the same point, and view-transition-class lifts the old snapshot above the new one for that direction.

View code
.pt-cr {
  height: 100%;
}
.pt-cr__screen {
  height: 100%;
  overflow: hidden;
  border-radius: 0 0 33px 33px;
  background: #faf6f1;
  color: #24152f;
}
.pt-cr--vt .pt-cr__screen {
  view-transition-name: pt-cr-screen;
}
.pt-cr--closing .pt-cr__screen {
  view-transition-class: pt-cr-closing;
}
::view-transition-old(pt-cr-screen),
::view-transition-new(pt-cr-screen) {
  animation: none;
  mix-blend-mode: normal;
}
::view-transition-old(*.pt-cr-closing) {
  z-index: 1;
}
.pt-cr__library,
.pt-cr__player {
  position: relative;
  height: 100%;
}
.pt-cr__library[hidden],
.pt-cr__player[hidden] {
  display: none;
}
.pt-cr__library {
  padding: 10px 16px 0;
}
.pt-cr__h {
  font-family: inherit;
  color: inherit;
  letter-spacing: normal;
  margin: 0 0 12px;
  font-size: 26px;
}
.pt-cr__albums {
  display: grid;
  gap: 12px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.pt-cr__albums li,
.pt-cr__mini {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: 12px;
  font-size: 13px;
  color: #5e4f68;
}
.pt-cr__albums b,
.pt-cr__mini b {
  display: block;
  font-size: 15px;
  color: #24152f;
}
.pt-cr__art {
  width: 52px;
  height: 52px;
  border-radius: 10px;
  background: #ff7a59;
  box-shadow: inset -14px -14px 0 #2a1838;
}
.pt-cr__art--b {
  background: #2e7d8c;
  box-shadow: inset 0 -18px 0 #f2c14e;
}
.pt-cr__art--c {
  background: #2a1838;
  box-shadow: inset 18px 0 0 #6c5ce7;
}
.pt-cr__art--d {
  background: #8bbf9f;
  box-shadow: inset 0 0 0 12px #f4ede4;
}
.pt-cr__mini {
  position: absolute;
  left: 10px;
  right: 10px;
  bottom: 16px;
  padding: 8px 8px 8px 8px;
  border-radius: 18px;
  background: #2a1838;
  color: #d9cfe0;
}
.pt-cr__mini b {
  color: #fff;
}
.pt-cr__mini .pt-cr__art {
  width: 44px;
  height: 44px;
}
.pt-cr__open,
.pt-cr__close,
.pt-cr__controls button {
  position: relative;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: #ff7a59;
  color: #24152f;
  cursor: pointer;
}
.pt-cr__open::before {
  content: "";
  width: 10px;
  height: 10px;
  margin-top: 5px;
  border-left: 2.5px solid currentColor;
  border-top: 2.5px solid currentColor;
  transform: rotate(45deg);
}
.pt-cr__player {
  display: grid;
  align-content: start;
  justify-items: center;
  padding: 8px 24px 0;
  background: #2a1838;
  color: #fff;
  text-align: center;
}
.pt-cr__close {
  justify-self: start;
  margin-left: -10px;
  background: transparent;
  color: #fff;
}
.pt-cr__close::before {
  content: "";
  width: 11px;
  height: 11px;
  margin-top: -6px;
  border-right: 2.5px solid currentColor;
  border-bottom: 2.5px solid currentColor;
  transform: rotate(45deg);
}
.pt-cr__cover {
  position: relative;
  width: min(220px, 100%);
  aspect-ratio: 1;
  margin-top: 4px;
  overflow: hidden;
  border-radius: 20px;
  background: #ff7a59;
}
.pt-cr__cover span {
  position: absolute;
  border-radius: 50%;
  background: #2a1838;
}
.pt-cr__cover span:nth-child(1) {
  right: -30%;
  bottom: -30%;
  width: 90%;
  height: 90%;
}
.pt-cr__cover span:nth-child(2) {
  left: 14%;
  top: 14%;
  width: 22%;
  height: 22%;
  background: #faf6f1;
}
.pt-cr__cover span:nth-child(3) {
  right: 8%;
  bottom: 8%;
  width: 36%;
  height: 36%;
  background: #6c5ce7;
}
.pt-cr__song {
  font-family: inherit;
  color: inherit;
  letter-spacing: normal;
  margin: 18px 0 0;
  font-size: 22px;
}
.pt-cr__artist {
  margin: 0 0 14px;
  color: #d9cfe0;
}
.pt-cr__progress {
  width: 100%;
  height: 4px;
  border-radius: 4px;
  background: #4a3657;
}
.pt-cr__progress span {
  display: block;
  width: 32%;
  height: 100%;
  border-radius: inherit;
  background: #ff7a59;
}
.pt-cr__times {
  display: flex;
  justify-content: space-between;
  width: 100%;
  margin: 6px 0 8px;
  font-size: 12px;
  color: #d9cfe0;
}
.pt-cr__controls {
  display: flex;
  align-items: center;
  gap: 22px;
}
.pt-cr__controls .pt-cr__prev,
.pt-cr__controls .pt-cr__next {
  background: transparent;
  color: #fff;
}
.pt-cr__prev::before,
.pt-cr__next::before {
  content: "";
  border-top: 8px solid transparent;
  border-bottom: 8px solid transparent;
}
.pt-cr__prev::before {
  border-right: 12px solid currentColor;
}
.pt-cr__next::before {
  border-left: 12px solid currentColor;
}
.pt-cr__controls .pt-cr__pause {
  width: 60px;
  height: 60px;
}
.pt-cr__pause::before {
  content: "";
  width: 14px;
  height: 18px;
  border-left: 5px solid currentColor;
  border-right: 5px solid currentColor;
  box-sizing: border-box;
}
.pt-cr__pause--off::before {
  width: 0;
  height: 0;
  margin-left: 4px;
  border-top: 10px solid transparent;
  border-bottom: 10px solid transparent;
  border-left: 16px solid currentColor;
  border-right: 0;
}
.pt-cr button:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 2px;
  box-shadow: 0 0 0 4px #24152f;
}

Staggered content after the switch

  • live ::view-transition-new
  • animation-delay
  • custom properties

Saltbox Kitchen

  • Brown butter oat pancakes20 min, serves 2
  • Shakshuka with feta30 min, serves 4
  • Overnight chia with plums10 min, serves 2
  • Rye toast, cottage cheese, herbs8 min, serves 1

The old list fades out fast through its snapshot, while ::view-transition-new() gets animation: none and simply shows the live page underneath. That live page runs ordinary CSS keyframes on each row, delayed by its --pt-st-i index, so the rows arrive one after another while the transition is still running.

View code
.pt-st {
  display: grid;
  grid-template-rows: auto 1fr;
  height: 100%;
  background: #fff8f2;
  color: #2b1a14;
}
.pt-st__head {
  padding: 10px 16px 14px;
}
.pt-st__brand {
  margin: 0 0 10px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 24px;
  font-weight: 700;
  color: #a8321c;
}
.pt-st__chips {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.pt-st__chips button {
  min-height: 40px;
  padding: 0 16px;
  border: 1.5px solid #e2c9bb;
  border-radius: 99px;
  background: #fff;
  color: #2b1a14;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .2s ease, color .2s ease, border-color .2s ease;
}
.pt-st__chips [aria-pressed="true"] {
  border-color: #a8321c;
  background: #a8321c;
  color: #fff;
}
.pt-st__chips button:focus-visible {
  outline: 2px solid #a8321c;
  outline-offset: 2px;
}
.pt-st__page {
  min-height: 0;
  overflow: hidden;
  padding: 0 16px;
}
.pt-st--vt .pt-st__page {
  view-transition-name: pt-st-page;
}
::view-transition-old(pt-st-page) {
  animation-duration: .15s;
}
::view-transition-new(pt-st-page) {
  animation: none;
}
.pt-st__list[hidden] {
  display: none;
}
.pt-st__list {
  display: grid;
  gap: 10px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.pt-st__list li {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: center;
  gap: 14px;
  padding: 10px;
  border-radius: 16px;
  background: #fff;
  box-shadow: 0 1px 0 #f0e2d8, 0 8px 18px rgba(80, 30, 10, .06);
  font-size: 13px;
  color: #6b5248;
}
.pt-st__list b {
  display: block;
  font-size: 15px;
  color: #2b1a14;
}
.pt-st__list li:nth-child(1) { --pt-st-i: 0; }
.pt-st__list li:nth-child(2) { --pt-st-i: 1; }
.pt-st__list li:nth-child(3) { --pt-st-i: 2; }
.pt-st__list li:nth-child(4) { --pt-st-i: 3; }
.pt-st__swatch {
  width: 64px;
  height: 64px;
  border-radius: 12px;
  background: #e9a03b;
  box-shadow: inset 0 -20px 0 #c46a1b;
}
.pt-st__swatch--b {
  background: #c8442c;
  box-shadow: inset 20px 0 0 #8e2a17;
}
.pt-st__swatch--c {
  background: #6f8f3a;
  box-shadow: inset 0 0 0 10px #a7bf6b;
}
.pt-st__swatch--d {
  background: #e6d3b3;
  box-shadow: inset -22px -22px 0 #9c7a4f;
}
@keyframes pt-st-rise {
  from { opacity: 0; translate: 0 18px; }
}
@media (prefers-reduced-motion: no-preference) {
  .pt-st__list li {
    animation: pt-st-rise .4s cubic-bezier(.2, .7, .2, 1) both;
    animation-delay: calc(var(--pt-st-i) * 55ms);
  }
}

Site-sized transitions: headers, tabs, themes and loading states

Wider frames for the transitions a marketing site or dashboard needs. The first one simulates a cross-document navigation without leaving this page, and the note shows the one CSS rule that does it for real between two URLs.

Article page morph, simulated cross-document

  • @view-transition
  • navigation: auto
  • same names on two pages
harbor-journal.example/

Harbor Journal

Stories from small ports

On a real site this needs no script: add @view-transition { navigation: auto; } to the CSS of both pages and give the card image and the article hero the same view-transition-name, and a same-origin link click morphs one into the other. This frame fakes the second document with a same-document transition, so the demo never leaves the page. Cross-document transitions run in Chrome and Edge 126+ and Safari 18.2+, and Firefox loads the page normally.

View code
.pt-mp {
  width: min(100%, 640px);
  overflow: hidden;
  border: 1px solid #d9d4c7;
  border-radius: 14px;
  background: #fbfaf7;
  color: #1c1b17;
  box-shadow: 0 18px 40px rgba(28, 27, 23, .1);
}
.pt-mp__chrome {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 12px;
  border-bottom: 1px solid #e4dfd2;
  background: #efebe1;
}
.pt-mp__nav {
  position: relative;
  flex: none;
  width: 32px;
  height: 32px;
  border: 0;
  border-radius: 8px;
  background: #fff;
  color: #1c1b17;
  cursor: pointer;
}
.pt-mp__nav::before {
  content: "";
  position: absolute;
  left: 13px;
  top: 11px;
  width: 8px;
  height: 8px;
  border-left: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
}
.pt-mp__nav:disabled {
  color: #9c968a;
  cursor: default;
}
.pt-mp__url {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  padding: 6px 12px;
  border-radius: 99px;
  background: #fff;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12px;
  color: #4d4a42;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.pt-mp__doc {
  height: 360px;
  overflow: hidden;
  padding: 18px clamp(14px, 4vw, 28px);
}
.pt-mp__home[hidden],
.pt-mp__article[hidden] {
  display: none;
}
.pt-mp__mast {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 4px 16px;
  margin-bottom: 16px;
  padding-bottom: 10px;
  border-bottom: 2px solid #1c1b17;
}
.pt-mp__mast p {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 26px;
  font-weight: 700;
}
.pt-mp__mast span {
  font-size: 13px;
  color: #5c584f;
}
.pt-mp__card {
  display: grid;
  grid-template-columns: minmax(0, 220px) 1fr;
  gap: 16px;
  align-items: center;
  width: 100%;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.pt-mp__photo {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: 8px;
}
.pt-mp__hero {
  aspect-ratio: 3 / 1;
  margin-bottom: 14px;
}
.pt-mp__text {
  display: grid;
  gap: 6px;
}
.pt-mp__title {
  color: inherit;
  letter-spacing: normal;
  width: fit-content;
  font-family: "Fraunces", Georgia, serif;
  font-size: 20px;
  font-weight: 600;
  line-height: 1.2;
}
.pt-mp__headline {
  margin: 0;
  font-size: clamp(22px, 4vw, 30px);
}
.pt-mp__meta,
.pt-mp__byline {
  font-size: 13px;
  color: #5c584f;
}
.pt-mp__byline {
  margin: 6px 0 10px;
}
.pt-mp__article p:last-child {
  margin: 0;
  font-size: 15px;
  line-height: 1.6;
}
.pt-mp--vt .pt-mp__photo {
  view-transition-name: pt-mp-photo;
}
.pt-mp--vt .pt-mp__title {
  view-transition-name: pt-mp-title;
}
.pt-mp__card:hover .pt-mp__title {
  text-decoration: underline;
}
.pt-mp__card:focus-visible,
.pt-mp__nav:focus-visible {
  outline: 2px solid #1c1b17;
  outline-offset: 3px;
  border-radius: 8px;
}
@media (max-width: 520px) {
  .pt-mp__card {
    grid-template-columns: 1fr;
  }
  .pt-mp__doc {
    height: 400px;
  }
}
@media (prefers-reduced-motion: no-preference) {
  ::view-transition-group(pt-mp-photo),
  ::view-transition-group(pt-mp-title) {
    animation-duration: .5s;
    animation-timing-function: cubic-bezier(.2, 0, 0, 1);
  }
}

Hero header shrinks into a compact bar

  • nested names
  • height: 100% on snapshots
  • exit-only group

Deploy previews for every branch

12 smedian preview build

1 linkper pull request

The header, the logo and the nav each carry their own name, so the logo glides into the corner while the header box shrinks around it. The bar snapshots get height: 100% so the colored box stretches with its group instead of keeping the old aspect ratio. The tagline only exists in the old state, so its group simply fades out.

View code
.pt-mh {
  width: min(100%, 640px);
  min-height: 330px;
  overflow: hidden;
  border-radius: 14px;
  background: #f4f6fb;
  color: #0e1a33;
  box-shadow: 0 18px 40px rgba(14, 26, 51, .12);
}
.pt-mh__bar {
  display: grid;
  justify-items: center;
  gap: 6px;
  padding: 34px 20px 22px;
  background: #0e1a33;
  color: #fff;
}
.pt-mh__logo {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 48px;
  font-weight: 700;
  line-height: 1;
  color: #d4f53c;
}
.pt-mh__tag {
  margin: 0 0 8px;
  font-size: 15px;
  color: #c3cbe0;
  text-align: center;
}
.pt-mh__nav {
  display: flex;
  gap: 6px;
}
.pt-mh__nav button {
  min-height: 40px;
  padding: 0 16px;
  border: 1px solid #3a4970;
  border-radius: 99px;
  background: transparent;
  color: #fff;
  font: inherit;
  font-size: 14px;
  cursor: pointer;
  transition: background-color .2s ease, color .2s ease;
}
.pt-mh__nav [aria-current="page"] {
  border-color: #d4f53c;
  background: #d4f53c;
  color: #0e1a33;
  font-weight: 700;
}
.pt-mh__nav button:focus-visible {
  outline: 2px solid #d4f53c;
  outline-offset: 2px;
}
.pt-mh--compact .pt-mh__bar {
  grid-template-columns: auto 1fr;
  align-items: center;
  justify-items: start;
  padding: 10px 14px;
}
.pt-mh--compact .pt-mh__logo {
  font-size: 22px;
}
.pt-mh--compact .pt-mh__tag {
  display: none;
}
.pt-mh--compact .pt-mh__nav {
  justify-self: end;
}
.pt-mh--vt .pt-mh__bar {
  view-transition-name: pt-mh-bar;
}
.pt-mh--vt .pt-mh__logo {
  view-transition-name: pt-mh-logo;
}
.pt-mh--vt .pt-mh__tag {
  view-transition-name: pt-mh-tag;
}
.pt-mh--vt .pt-mh__nav {
  view-transition-name: pt-mh-nav;
}
::view-transition-old(pt-mh-bar),
::view-transition-new(pt-mh-bar) {
  height: 100%;
}
.pt-mh__home,
.pt-mh__docs {
  padding: 20px;
}
.pt-mh__home[hidden],
.pt-mh__docs[hidden] {
  display: none;
}
.pt-mh__cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 12px;
}
.pt-mh__cards p {
  margin: 0;
  padding: 16px;
  border-radius: 12px;
  background: #fff;
  font-size: 14px;
  color: #45506a;
}
.pt-mh__cards b {
  display: block;
  font-size: 24px;
  color: #0e1a33;
}
.pt-mh__docs h2 {
  font-family: inherit;
  color: inherit;
  letter-spacing: normal;
  margin: 0 0 6px;
  font-size: 22px;
}
.pt-mh__docs p {
  margin: 0 0 12px;
  font-size: 15px;
  line-height: 1.55;
  color: #2c3650;
}
.pt-mh__code {
  margin: 0;
  padding: 12px 14px;
  overflow-x: auto;
  border-radius: 10px;
  background: #0e1a33;
  color: #d4f53c;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 13px;
}
@media (prefers-reduced-motion: no-preference) {
  ::view-transition-group(pt-mh-bar),
  ::view-transition-group(pt-mh-logo),
  ::view-transition-group(pt-mh-nav) {
    animation-duration: .45s;
    animation-timing-function: cubic-bezier(.3, 0, 0, 1);
  }
}

Tab panels that slide by direction

  • view-transition-class
  • ARIA tabs
  • moving indicator
Brook S2Portable speaker, 189 EUR

A palm-sized speaker with a fabric shell that survives sand, rain and a drop from the picnic table. Two Brook S2 units pair into stereo with one press.

The script compares the old and new tab index and sets view-transition-class to pt-tb-next or pt-tb-prev on the panel, and ::view-transition-old(*.pt-tb-next) picks the matching slide. The underline is a real element inside each tab, named only on the selected one, so it glides between tabs. Arrow keys, Home and End follow the ARIA tabs pattern.

View code
.pt-tb {
  width: min(100%, 460px);
  padding: 20px;
  border-radius: 18px;
  background: #fff;
  color: #14181d;
  box-shadow: 0 16px 36px rgba(20, 24, 29, .1);
}
.pt-tb__product {
  display: flex;
  align-items: center;
  gap: 14px;
  margin-bottom: 14px;
  font-size: 13px;
  color: #56606b;
}
.pt-tb__product b {
  display: block;
  font-size: 18px;
  color: #14181d;
}
.pt-tb__thumb {
  width: 56px;
  height: 56px;
  border-radius: 16px;
  background: #0f766e;
  box-shadow: inset 0 0 0 8px #134e4a, inset 0 0 0 16px #0f766e, inset 0 0 0 22px #5eead4;
}
.pt-tb__list {
  display: flex;
  border-bottom: 1px solid #e3e7ec;
}
.pt-tb__list [role="tab"] {
  position: relative;
  flex: 1;
  min-height: 44px;
  padding: 0 8px;
  border: 0;
  background: none;
  color: #56606b;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
}
.pt-tb__list [aria-selected="true"] {
  color: #0f766e;
}
.pt-tb__ink {
  position: absolute;
  left: 12%;
  right: 12%;
  bottom: -1px;
  height: 3px;
  border-radius: 3px 3px 0 0;
  background: #0f766e;
  visibility: hidden;
}
.pt-tb__list [aria-selected="true"] .pt-tb__ink {
  visibility: visible;
}
.pt-tb--vt [aria-selected="true"] .pt-tb__ink {
  view-transition-name: pt-tb-ink;
}
.pt-tb__list [role="tab"]:focus-visible {
  outline: 2px solid #0f766e;
  outline-offset: -2px;
  border-radius: 8px;
}
.pt-tb__panels {
  min-height: 132px;
  padding-top: 16px;
  overflow: hidden;
}
.pt-tb--vt .pt-tb__panels {
  view-transition-name: pt-tb-panels;
}
.pt-tb--vt[data-dir="next"] .pt-tb__panels {
  view-transition-class: pt-tb-next;
}
.pt-tb--vt[data-dir="prev"] .pt-tb__panels {
  view-transition-class: pt-tb-prev;
}
::view-transition-group(pt-tb-panels) {
  overflow: clip;
}
.pt-tb__panel {
  font-size: 15px;
  line-height: 1.6;
}
.pt-tb__panel p {
  margin: 0;
}
.pt-tb__panel:focus-visible {
  outline: 2px solid #0f766e;
  outline-offset: 4px;
  border-radius: 6px;
}
.pt-tb__specs {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 6px 18px;
  margin: 0;
}
.pt-tb__specs dt {
  color: #56606b;
}
.pt-tb__specs dd {
  margin: 0;
  font-weight: 600;
}
.pt-tb__stars b {
  font-size: 22px;
  color: #0f766e;
}
.pt-tb__panel blockquote {
  margin: 8px 0 0;
  padding-left: 12px;
  border-left: 3px solid #5eead4;
  color: #39424c;
}
@keyframes pt-tb-out-left {
  to { translate: -48px 0; opacity: 0; }
}
@keyframes pt-tb-in-right {
  from { translate: 48px 0; opacity: 0; }
}
@keyframes pt-tb-out-right {
  to { translate: 48px 0; opacity: 0; }
}
@keyframes pt-tb-in-left {
  from { translate: -48px 0; opacity: 0; }
}
@media (prefers-reduced-motion: no-preference) {
  ::view-transition-old(*.pt-tb-next) {
    animation: pt-tb-out-left .28s ease-in both;
  }
  ::view-transition-new(*.pt-tb-next) {
    animation: pt-tb-in-right .32s .08s ease-out both;
  }
  ::view-transition-old(*.pt-tb-prev) {
    animation: pt-tb-out-right .28s ease-in both;
  }
  ::view-transition-new(*.pt-tb-prev) {
    animation: pt-tb-in-left .32s .08s ease-out both;
  }
  ::view-transition-group(pt-tb-ink) {
    animation-duration: .35s;
    animation-timing-function: cubic-bezier(.3, 0, 0, 1);
  }
}

Light to dark theme wipe

  • clip-path: inset()
  • aria-pressed
  • custom properties

Visitors this week

48,210

Up 12.4% on last week

  • Mon
  • Tue
  • Wed
  • Thu
  • Fri
  • Sat
  • Sun

The new theme is revealed by animating clip-path: inset() on ::view-transition-new(), while the old snapshot stays still underneath, so the theme sweeps across like a wipe. On a real site you would leave the root name in place and run the same keyframes on ::view-transition-new(root). Reduced-motion users get the plain crossfade.

View code
.pt-th {
  --pt-th-bg: #ffffff;
  --pt-th-ink: #15172b;
  --pt-th-soft: #5b5f7a;
  --pt-th-bar: #4f46e5;
  --pt-th-track: #eceefe;
  width: min(100%, 420px);
}
.pt-th--dark {
  --pt-th-bg: #14162a;
  --pt-th-ink: #f1f2fb;
  --pt-th-soft: #a9adc9;
  --pt-th-bar: #a5b4fc;
  --pt-th-track: #24274a;
}
.pt-th__card {
  padding: 22px;
  border-radius: 20px;
  background: var(--pt-th-bg);
  color: var(--pt-th-ink);
  box-shadow: 0 18px 40px rgba(21, 23, 43, .14);
}
.pt-th--vt .pt-th__card {
  view-transition-name: pt-th-card;
}
.pt-th__top {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
}
.pt-th__label {
  margin: 0;
  font-size: 14px;
  color: var(--pt-th-soft);
}
.pt-th__toggle {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 40px;
  padding: 0 14px 0 10px;
  border: 1px solid var(--pt-th-track);
  border-radius: 99px;
  background: var(--pt-th-track);
  color: var(--pt-th-ink);
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.pt-th__toggle::before {
  content: "";
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: #f59e0b;
  box-shadow: 0 0 0 3px #fde68a;
}
.pt-th__toggle[aria-pressed="true"]::before {
  background: transparent;
  box-shadow: inset -6px -3px 0 0 #e0e7ff;
}
.pt-th__toggle:focus-visible {
  outline: 2px solid var(--pt-th-bar);
  outline-offset: 2px;
}
.pt-th__big {
  margin: 14px 0 0;
  font-size: 40px;
  font-weight: 700;
  line-height: 1.1;
  letter-spacing: -.02em;
}
.pt-th__delta {
  margin: 4px 0 18px;
  font-size: 14px;
  font-weight: 600;
  color: var(--pt-th-soft);
}
.pt-th__bars {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  align-items: end;
  gap: 8px;
  height: 120px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.pt-th__bars li {
  position: relative;
  height: 100%;
  border-radius: 8px;
  background: var(--pt-th-track);
}
.pt-th__bars li::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 22px;
  height: calc(var(--pt-th-h) - 22px);
  border-radius: 8px;
  background: var(--pt-th-bar);
}
.pt-th__bars span {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 2px;
  font-size: 11px;
  color: var(--pt-th-soft);
  text-align: center;
}
.pt-th__b1 { --pt-th-h: 52%; }
.pt-th__b2 { --pt-th-h: 64%; }
.pt-th__b3 { --pt-th-h: 58%; }
.pt-th__b4 { --pt-th-h: 80%; }
.pt-th__b5 { --pt-th-h: 92%; }
.pt-th__b6 { --pt-th-h: 70%; }
.pt-th__b7 { --pt-th-h: 46%; }
@keyframes pt-th-wipe {
  from { clip-path: inset(0 100% 0 0); }
  to { clip-path: inset(0 0 0 0); }
}
@media (prefers-reduced-motion: no-preference) {
  ::view-transition-old(pt-th-card) {
    animation: none;
    mix-blend-mode: normal;
  }
  ::view-transition-new(pt-th-card) {
    animation: pt-th-wipe .6s cubic-bezier(.65, 0, .35, 1) both;
    mix-blend-mode: normal;
  }
}

Skeleton screen to loaded content

  • match-element
  • aria-busy
  • role="status"

Design team

  • IMInes MoreauDesign leadIn review
  • KDKofi DansoProduct designerAvailable
  • YTYuki TanakaDesign systemsIn a call

3 people loaded

The skeleton and the loaded cards are the same elements, so view-transition-name: match-element gives each card its own automatic name and every card morphs from its placeholder size to its real size. The rule sits in @supports, and older engines crossfade the whole list. The list carries aria-busy while loading and a status line announces the result.

View code
.pt-sk {
  width: min(100%, 420px);
  padding: 20px;
  border-radius: 18px;
  background: #fff;
  color: #1a1d24;
  box-shadow: 0 16px 36px rgba(26, 29, 36, .1);
}
.pt-sk__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 14px;
}
.pt-sk__h {
  font-family: inherit;
  color: inherit;
  letter-spacing: normal;
  margin: 0;
  font-size: 18px;
}
.pt-sk__reload {
  min-height: 40px;
  padding: 0 16px;
  border: 1px solid #d5d9e2;
  border-radius: 10px;
  background: #fff;
  color: #1a1d24;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.pt-sk__reload:disabled {
  color: #6b7280;
  cursor: progress;
}
.pt-sk__reload:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}
.pt-sk__list {
  display: grid;
  gap: 10px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.pt-sk__card {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: center;
  gap: 14px;
  padding: 12px;
  border: 1px solid #eceef3;
  border-radius: 14px;
  background: #fff;
}
.pt-sk__avatar {
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;
  border-radius: 50%;
  background: #dbeafe;
  color: #1e3a8a;
  font-weight: 700;
}
.pt-sk__avatar--b {
  background: #dcfce7;
  color: #14532d;
}
.pt-sk__avatar--c {
  background: #fce7f3;
  color: #831843;
}
.pt-sk__text {
  display: grid;
  justify-items: start;
  gap: 2px;
  font-size: 13px;
  color: #4b5563;
}
.pt-sk__text b {
  font-size: 15px;
  color: #1a1d24;
}
.pt-sk__chip {
  margin-top: 4px;
  padding: 2px 10px;
  border-radius: 99px;
  background: #fef3c7;
  color: #78350f;
  font-size: 12px;
  font-weight: 600;
}
.pt-sk__chip--free {
  background: #dcfce7;
  color: #14532d;
}
.pt-sk__list--loading .pt-sk__card {
  padding: 10px 12px;
  border-color: #f0f2f6;
}
.pt-sk__list--loading .pt-sk__avatar {
  width: 36px;
  height: 36px;
  background: #e9ecf2;
  color: transparent;
}
.pt-sk__list--loading .pt-sk__text > * {
  border-radius: 6px;
  background: #e9ecf2;
  color: transparent;
}
.pt-sk__list--loading .pt-sk__text b {
  width: 60%;
}
.pt-sk__list--loading .pt-sk__text span {
  width: 40%;
}
.pt-sk__list--loading .pt-sk__chip {
  display: none;
}
.pt-sk__status {
  margin: 12px 0 0;
  font-size: 13px;
  color: #4b5563;
}
.pt-sk--vt .pt-sk__list {
  view-transition-name: pt-sk-list;
}
@supports (view-transition-name: match-element) {
  .pt-sk--vt .pt-sk__list {
    view-transition-name: none;
  }
  .pt-sk--vt .pt-sk__card {
    view-transition-name: match-element;
    view-transition-class: pt-sk-card;
  }
}
@keyframes pt-sk-pulse {
  50% { opacity: .55; }
}
@media (prefers-reduced-motion: no-preference) {
  .pt-sk__list--loading .pt-sk__card {
    animation: pt-sk-pulse 1.2s ease-in-out infinite;
  }
  ::view-transition-group(*.pt-sk-card) {
    animation-duration: .45s;
    animation-timing-function: cubic-bezier(.2, 0, 0, 1);
  }
}

Reduced motion makes the switch instant

  • prefers-reduced-motion
  • animation: none
  • role="switch"

Step 1 of 3

Pick your topics

Choose three subjects and the morning digest will lead with them.

Under @media (prefers-reduced-motion: reduce) the group, old and new pseudo-elements get animation: none, so the transition finishes in the same frame and the step simply appears. The switch lets you try it without changing your OS setting: it adds view-transition-class: pt-rm-still, which the same animation: none rule targets.

View code
.pt-rm {
  display: grid;
  gap: 14px;
  width: min(100%, 380px);
}
.pt-rm__card {
  padding: 24px;
  border-radius: 20px;
  background: #1b1b1f;
  color: #f3f1ea;
  border: 1px solid #2e2e35;
}
.pt-rm--vt .pt-rm__card {
  view-transition-name: pt-rm-card;
}
.pt-rm--vt.pt-rm--still .pt-rm__card {
  view-transition-class: pt-rm-still;
}
.pt-rm__step {
  margin: 0 0 6px;
  font-size: 13px;
  font-weight: 600;
  color: #f5c63c;
}
.pt-rm__title {
  color: inherit;
  letter-spacing: normal;
  margin: 0 0 8px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 26px;
  font-weight: 600;
}
.pt-rm__copy {
  margin: 0 0 18px;
  font-size: 15px;
  line-height: 1.55;
  color: #c9c6bc;
}
.pt-rm__dots {
  display: flex;
  gap: 6px;
}
.pt-rm__dots span {
  width: 8px;
  height: 8px;
  border-radius: 8px;
  background: #4a4a52;
}
.pt-rm__dots .pt-rm__dot--on {
  width: 24px;
  background: #f5c63c;
}
.pt-rm__actions {
  display: flex;
  gap: 10px;
}
.pt-rm__actions button {
  flex: 1;
  min-height: 46px;
  border: 1px solid #45454d;
  border-radius: 12px;
  background: transparent;
  color: #f3f1ea;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
}
.pt-rm__actions .pt-rm__next {
  border-color: #f5c63c;
  background: #f5c63c;
  color: #1b1b1f;
}
.pt-rm__actions button:disabled {
  opacity: .45;
  cursor: default;
}
.pt-rm__switch {
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 44px;
  padding: 0;
  border: 0;
  background: none;
  color: #f3f1ea;
  font: inherit;
  font-size: 14px;
  cursor: pointer;
}
.pt-rm__switch::before {
  content: "";
  width: 40px;
  height: 24px;
  border-radius: 24px;
  background: #3a3a42;
  box-shadow: inset 3px 0 0 0 #3a3a42, inset 19px 0 0 0 #c9c6bc, inset 21px 0 0 0 #3a3a42;
}
.pt-rm__switch[aria-checked="true"]::before {
  background: #f5c63c;
  box-shadow: inset 19px 0 0 0 #f5c63c, inset 37px 0 0 0 #1b1b1f, inset 40px 0 0 0 #f5c63c;
}
.pt-rm button:focus-visible {
  outline: 2px solid #f5c63c;
  outline-offset: 3px;
}
@keyframes pt-rm-out {
  to { translate: -40px 0; opacity: 0; }
}
@keyframes pt-rm-in {
  from { translate: 40px 0; opacity: 0; }
}
@media (prefers-reduced-motion: no-preference) {
  ::view-transition-old(pt-rm-card) {
    animation: pt-rm-out .25s ease-in both;
  }
  ::view-transition-new(pt-rm-card) {
    animation: pt-rm-in .3s .1s ease-out both;
  }
}
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(pt-rm-card),
  ::view-transition-old(pt-rm-card),
  ::view-transition-new(pt-rm-card) {
    animation: none;
  }
}
::view-transition-group(pt-rm-card.pt-rm-still),
::view-transition-old(pt-rm-card.pt-rm-still),
::view-transition-new(pt-rm-card.pt-rm-still) {
  animation: none;
}

Page transitions without the API

Fallbacks that animate in every current browser: radio buttons with :has(), the hidden attribute with @starting-style, a class-driven curtain, and scroll snapping with a scroll-driven animation.

Shared axis pages with radio buttons

  • :has()
  • visibility delay
  • no JavaScript

Upcoming trips

Lisbon12 to 16 MayFlights booked
Kyoto3 to 10 SepHotel needed

Saved places

  • Tile museum, Lisbon
  • Philosopher path, Kyoto
  • Night market, Taipei
  • Harbor baths, Copenhagen

Rosa Lind

14countries38trips6this year

Section

Three radio buttons hold the current page, and :has() on the app wrapper shows the matching section. All pages share one grid cell; the outgoing one fades in 90ms while the incoming one waits, then slides 30px and fades in over 300ms, the shared axis pattern. visibility switches with a delay, so hidden pages leave the tab order.

View code
.pt-rd {
  display: grid;
  grid-template-rows: 1fr auto;
  height: 100%;
  background: #f4efe6;
  color: #182433;
}
.pt-rd__pages {
  display: grid;
  min-height: 0;
  overflow: hidden;
  padding: 12px 18px;
}
.pt-rd__page {
  grid-area: 1 / 1;
  visibility: hidden;
  opacity: 0;
}
.pt-rd:has(#pt-rd-1:checked) .pt-rd__page--1,
.pt-rd:has(#pt-rd-2:checked) .pt-rd__page--2,
.pt-rd:has(#pt-rd-3:checked) .pt-rd__page--3 {
  visibility: visible;
  opacity: 1;
}
.pt-rd__page h2 {
  letter-spacing: normal;
  margin: 0 0 14px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 26px;
  font-weight: 600;
  color: #1d4e89;
}
.pt-rd__trip {
  display: grid;
  gap: 2px;
  margin-bottom: 12px;
  padding: 16px;
  border-radius: 16px;
  background: #fff;
  font-size: 14px;
  color: #4a5566;
}
.pt-rd__trip b {
  font-size: 20px;
  color: #182433;
}
.pt-rd__tag {
  justify-self: start;
  margin-top: 6px;
  padding: 2px 10px;
  border-radius: 99px;
  background: #d9e8f7;
  color: #163e6e;
  font-size: 12px;
  font-weight: 600;
}
.pt-rd__tag--todo {
  background: #fde2c8;
  color: #7a3a07;
}
.pt-rd__saved {
  display: grid;
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.pt-rd__saved li {
  padding: 14px 16px;
  border-radius: 12px;
  background: #fff;
  font-size: 15px;
}
.pt-rd__stats {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px;
  margin: 0;
}
.pt-rd__stats span {
  padding: 14px 8px;
  border-radius: 14px;
  background: #fff;
  font-size: 13px;
  color: #4a5566;
  text-align: center;
}
.pt-rd__stats b {
  display: block;
  font-size: 24px;
  color: #1d4e89;
}
.pt-rd__nav {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 6px;
  margin: 0;
  padding: 8px 12px 18px;
  border: 0;
  border-top: 1px solid #e2d9c8;
  background: #fbf8f2;
}
.pt-rd__nav legend {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.pt-rd__nav input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}
.pt-rd__nav label {
  display: grid;
  place-items: center;
  min-height: 44px;
  border-radius: 12px;
  color: #4a5566;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .2s ease, color .2s ease;
}
.pt-rd__nav input:checked + label {
  background: #1d4e89;
  color: #fff;
}
.pt-rd__nav input:focus-visible + label {
  outline: 2px solid #1d4e89;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  .pt-rd__page {
    translate: 30px 0;
    transition: opacity 90ms ease-in, translate 0s 90ms, visibility 0s 90ms;
  }
  .pt-rd:has(#pt-rd-1:checked) .pt-rd__page--1,
  .pt-rd:has(#pt-rd-2:checked) .pt-rd__page--2,
  .pt-rd:has(#pt-rd-3:checked) .pt-rd__page--3 {
    translate: 0 0;
    transition: opacity .3s 90ms cubic-bezier(0, 0, .2, 1), translate .3s 90ms cubic-bezier(.2, 0, 0, 1), visibility 0s 90ms;
  }
}

Detail page with hidden and @starting-style

  • @starting-style
  • allow-discrete
  • inert

This week

24.6 km across 3 sessions

The script only flips the hidden attribute; CSS does both directions. @starting-style gives the opening detail page a start position off to the right, and transition-behavior: allow-discrete keeps display: block alive until the slide out finishes. The list underneath becomes inert while the detail page is open.

View code
.pt-hs {
  position: relative;
  height: 100%;
  overflow: hidden;
  background: #f6f6f3;
  color: #1a1a17;
}
.pt-hs__list {
  height: 100%;
  padding: 10px 16px;
}
.pt-hs__h {
  font-family: inherit;
  color: inherit;
  letter-spacing: normal;
  margin: 0;
  font-size: 26px;
}
.pt-hs__total {
  margin: 2px 0 16px;
  font-size: 14px;
  color: #55554f;
}
.pt-hs__total b {
  color: #b8321a;
}
.pt-hs__row {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: center;
  gap: 14px;
  width: 100%;
  margin-bottom: 10px;
  padding: 14px;
  border: 0;
  border-radius: 16px;
  background: #fff;
  color: #55554f;
  font: inherit;
  font-size: 13px;
  text-align: left;
  cursor: pointer;
}
.pt-hs__row b {
  display: block;
  font-size: 16px;
  color: #1a1a17;
}
.pt-hs__badge {
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;
  border-radius: 14px;
  background: #ffd9cf;
  color: #8a240f;
  font-size: 12px;
  font-weight: 700;
}
.pt-hs__badge--b {
  background: #d6ecff;
  color: #0c4a7a;
}
.pt-hs__detail {
  position: absolute;
  inset: 0;
  display: block;
  padding: 8px 16px;
  background: #1a1a17;
  color: #f6f6f3;
  box-shadow: -20px 0 40px rgba(0, 0, 0, .2);
}
.pt-hs__detail[hidden] {
  display: none;
}
.pt-hs__back {
  min-height: 44px;
  margin-left: -8px;
  padding: 0 10px 0 26px;
  border: 0;
  border-radius: 10px;
  background: none;
  color: #ff9a80;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  position: relative;
}
.pt-hs__back::before {
  content: "";
  position: absolute;
  left: 12px;
  top: 50%;
  width: 9px;
  height: 9px;
  border-left: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: translateY(-50%) rotate(45deg);
}
.pt-hs__title {
  font-family: inherit;
  color: inherit;
  letter-spacing: normal;
  margin: 6px 0 14px;
  font-size: 28px;
}
.pt-hs__stats {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
  margin: 0 0 16px;
}
.pt-hs__stats div {
  padding: 12px 14px;
  border-radius: 14px;
  background: #2a2a26;
}
.pt-hs__stats dt {
  font-size: 12px;
  color: #bdbdb4;
}
.pt-hs__stats dd {
  margin: 0;
  font-size: 22px;
  font-weight: 700;
}
.pt-hs__splits {
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: 14px;
}
.pt-hs__splits li {
  display: flex;
  justify-content: space-between;
  padding: 9px 2px;
  border-bottom: 1px solid #34342f;
}
.pt-hs__splits span {
  font-weight: 700;
  color: #ff9a80;
}
.pt-hs__row:focus-visible,
.pt-hs__back:focus-visible {
  outline: 2px solid #b8321a;
  outline-offset: 2px;
}
.pt-hs__back:focus-visible {
  outline-color: #ff9a80;
}
@media (prefers-reduced-motion: no-preference) {
  .pt-hs__detail {
    transition: translate .38s cubic-bezier(.3, 0, 0, 1), display .38s allow-discrete;
  }
  .pt-hs__detail[hidden] {
    translate: 100% 0;
  }
  @starting-style {
    .pt-hs__detail {
      translate: 100% 0;
    }
  }
}

Curtain wipe with classes and transitionend

  • scale transition
  • transitionend
  • any browser

New this week

A debut novel about a lighthouse keeper who writes letters to the ships she never sees, and four more staff picks.

The classic fallback that libraries such as Barba.js and Swup automate. A class drops a curtain over the page with a scale transition, transitionend swaps the content behind it, and removing the class lifts the curtain from the top. With reduced motion the script swaps at once, because no transition would ever end.

View code
.pt-cu {
  width: min(100%, 560px);
  overflow: hidden;
  border-radius: 16px;
  background: #fffaf0;
  color: #1f1b16;
  box-shadow: 0 16px 36px rgba(31, 27, 22, .12);
}
.pt-cu__nav {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 8px 16px;
  padding: 12px 16px;
  border-bottom: 1px solid #eadfca;
}
.pt-cu__logo {
  font-family: "Fraunces", Georgia, serif;
  font-size: 20px;
  font-weight: 700;
}
.pt-cu__links {
  display: flex;
  gap: 4px;
}
.pt-cu__links button {
  min-height: 40px;
  padding: 0 12px;
  border: 0;
  border-radius: 8px;
  background: none;
  color: #4d4438;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.pt-cu__links [aria-current="page"] {
  background: #1f1b16;
  color: #fffaf0;
}
.pt-cu__links button:focus-visible {
  outline: 2px solid #1f1b16;
  outline-offset: 2px;
}
.pt-cu__stage {
  position: relative;
  min-height: 190px;
  padding: 24px 20px;
}
.pt-cu__h {
  color: inherit;
  letter-spacing: normal;
  margin: 0 0 8px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 28px;
  font-weight: 600;
}
.pt-cu__p {
  max-width: 44ch;
  margin: 0;
  font-size: 15px;
  line-height: 1.6;
  color: #4d4438;
}
.pt-cu__curtain {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  background: #e9b949;
  color: #1f1b16;
  font-family: "Fraunces", Georgia, serif;
  font-size: 34px;
  font-weight: 700;
  scale: 1 0;
  transform-origin: 50% 0;
}
.pt-cu--cover .pt-cu__curtain {
  scale: 1 1;
  transform-origin: 50% 100%;
}
@media (prefers-reduced-motion: no-preference) {
  .pt-cu__curtain {
    transition: scale .42s cubic-bezier(.7, 0, .3, 1);
  }
}

Swipe pages with scroll snap

  • scroll-snap-type
  • animation-timeline: view()
  • @supports

Atlas field guide

Yellow tents pitched in snow below jagged peaks

Winter camping

Pitch with the door away from the wind and bank snow on the windward side.

Red rock canyon rim glowing in low evening sun

Desert hiking

Carry four liters a day and start at first light, before the rock heats up.

Waterfall dropping into a mossy green gorge

River crossings

Unbuckle the hip belt and face upstream, moving one foot at a time.

Swipe for more

Each page is a snap point in a horizontal scroller, and a scroll-driven animation on view(inline) scales and fades every page by how far it is from the center. Swipe, scroll or use the arrow keys after focusing the strip. Without scroll-driven animation support the pages still snap, just without the depth effect.

View code
.pt-sw {
  display: grid;
  grid-template-rows: auto 1fr auto;
  height: 100%;
  background: #0f1d1a;
  color: #eef4f1;
}
.pt-sw__brand {
  margin: 0;
  padding: 12px 20px 8px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 20px;
  font-weight: 600;
  color: #9fe0c4;
}
.pt-sw__track {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 84%;
  gap: 12px;
  min-height: 0;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  padding: 6px 8%;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
}
.pt-sw__track:focus-visible {
  outline: 2px solid #9fe0c4;
  outline-offset: -4px;
}
.pt-sw__page {
  display: flex;
  flex-direction: column;
  min-height: 0;
  overflow: hidden;
  border-radius: 22px;
  background: #18302a;
  scroll-snap-align: center;
}
.pt-sw__page img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
.pt-sw__page h2 {
  font-family: inherit;
  color: inherit;
  letter-spacing: normal;
  margin: 14px 16px 4px;
  font-size: 20px;
}
.pt-sw__page p {
  margin: 0 16px 16px;
  font-size: 14px;
  line-height: 1.5;
  color: #c4d6cf;
}
.pt-sw__hint {
  margin: 0;
  padding: 10px 0 20px;
  font-size: 13px;
  color: #9fb7ae;
  text-align: center;
}
@keyframes pt-sw-depth {
  0%, 100% { opacity: .45; scale: .88; }
  50% { opacity: 1; scale: 1; }
}
@media (prefers-reduced-motion: no-preference) {
  .pt-sw__track {
    scroll-behavior: smooth;
  }
  @supports (animation-timeline: view()) {
    .pt-sw__page {
      animation: pt-sw-depth linear both;
      animation-timeline: view(inline);
    }
  }
}

What Are CSS Page Transitions?

Under the hood it’s a snapshot trick. The browser grabs the old view and the new view, then animates between them with CSS. MDN Web Docs describes the View Transition API as covering DOM changes inside a single-page app and navigation between separate documents in a multi-page app.

That gives you same-document transitions, which run inside one page, and cross-document transitions, which run between two pages of the same site.

It helps to keep the older property in perspective. The transition property moves one element between two style states, like a color change on hover. The View Transition API animates a whole view, or named parts of it, between two DOM states or two documents. And because the old document is gone before the new one paints, the transition property has no way to cover a full page change.

Most demo galleries under this keyword fake the effect. They stack panels, toggle classes and rely on a plain CSS animation to slide one block over another.

Before the API, a single-page app needed a pile of JavaScript just to position the old content, animate it out and remove it afterward, as MDN points out. Cross-document transitions weren’t possible at all.

How Does the View Transition API Animate a Page Change?

Capture, update, capture again. After that the browser plays a CSS animation on a tree of pseudo-elements laid over the page, and the default animation is a cross-fade.

The Snapshot Sequence

Chrome for Developers documents the order in a guide written by Jake Archibald and Bramus Van Damme.

The old state comes first, with snapshots of the root and every element that has a view-transition-name. Your callback then runs while rendering is suppressed. Once it finishes, the browser captures the result and builds the pseudo-element tree, and CSS animations play over the page until the live DOM becomes visible and interactive again.

Only one view transition runs at a time. If a second one starts mid-animation, the first skips to its end.

The Pseudo-Element Tree

The tree sits in an overlay above the page, so styling it never touches your real elements.

Pseudo-elementWhat it holds
::view-transitionThe overlay root that fills the viewport
::view-transition-group()One named transition, animating size and position
::view-transition-image-pair()Container for the old and new views
::view-transition-old()Static screenshot of the old view
::view-transition-new()Live render of the new view

This split is why video behaves oddly. The old view stays frozen while the new one keeps playing.

By default the old view fades from opacity 1 to 0 while the new view goes from 0 to 1.

Specification Levels

Two drafts from the W3C CSS Working Group define the feature, and MDN links both to the working group’s editor drafts as of its September 28, 2026 update.

Level 1 is the same-document side: document.startViewTransition(), view-transition-name and the pseudo-elements. Cross-document transitions live in Level 2, along with the @view-transition at-rule, view transition types and view-transition-class.

The pageswap and pagereveal events belong to neither CSS module. Chrome for Developers places them in the HTML specification.

How to Build a Same-Document Page Transition

Chrome has supported document.startViewTransition() since version 111, per Chrome for Developers. Wrap your DOM update in it and the browser animates the change with a default cross-fade.

  1. Check that document.startViewTransition exists. If it doesn’t, run the DOM update directly and skip the animation.
  2. Pass the function that changes the DOM as the callback. Add or remove elements, switch classes or change styles inside it.
  3. Keep the callback short, since the page freezes while it runs. Do network fetches before the call. If you wait for fonts or images, use an aggressive timeout (Chrome’s guide caps the font wait at 100 ms).
  4. Style ::view-transition-old(root) and ::view-transition-new(root) with animation-duration or your own keyframes.
  5. Await updateCallbackDone, not ready, when app logic depends on the result. A skipped transition then can’t break the app.
function spaNavigate(data) {
  if (!document.startViewTransition) {
    updateTheDOMSomehow(data);
    return;
  }
  document.startViewTransition(() => updateTheDOMSomehow(data));
}

Two failure modes are documented. Duplicate view-transition-name values on rendered elements skip the transition, and a callback that returns a rejected promise abandons it.

Chrome’s case study on redBus says view transitions made page-to-page navigation smoother and raised conversion rate in one of its user journeys.

Cross-Document Page Transitions With CSS Only

Chrome has supported this since version 126 (Chrome for Developers). Add a @view-transition rule with navigation: auto to both pages and a same-origin link click animates with no JavaScript.

@view-transition {
  navigation: auto;
}

Both documents need to share an origin, and both need the rule. No API call is involved, since the link click itself starts the transition.

The @view-transition At-Rule

A navigation that takes more than four seconds skips the transition in Chrome and throws a TimeoutError, according to Chrome for Developers.

Transitions run on traverse navigations, plus push or replace navigations that weren’t started through browser UI. Typing in the address bar, clicking a bookmark or reloading skips them.

Early experiments used a meta tag to opt in. Chrome calls that a temporary measure, and the CSS at-rule replaced it.

Customizing With pageswap and pagereveal

Both events fire for every same-origin cross-document navigation, whether or not a transition runs. The viewTransition property on the event exists only when one is about to play.

pageswap fires on the outgoing page before its last frame renders, so last-minute changes go there, before the old snapshots are taken. On the incoming side, pagereveal fires before the first render. That’s where you set names or transition types ahead of the new snapshots.

People use these events to name only the clicked list item just in time, or to pass data between pages through sessionStorage. skipTransition() is there for the cases where a transition makes no sense.

Register the pagereveal listener in a classic script in the head. Scripts loaded as modules, or with async or defer, can miss the first render unless marked blocking=render.

Google used cross-document view transitions to connect standard search with AI Mode, according to its August 2025 case study on Chrome for Developers.

Customizing the Animation: Names, Classes, Duration and Easing

An element with its own view-transition-name animates on its own. view-transition-class groups similar ones, and the default fade can be swapped for keyframes, duration and easing on the old and new pseudo-elements.

Shared Element Transitions

A shared element transition links two different elements through one name. A product thumbnail grows into the hero image on the detail page because both carry the same name during the transition.

Names have to be unique within a rendered state, or the transition is skipped. The two elements don’t need to be the same DOM node. In a single-page app, set the name on the thumbnail just before the call and clear it inside the callback. If the aspect ratios differ, use object-fit: contain on the old view and cover on the new one.

Naming the header works the same way. It stays in place and cross-fades while the rest of the page slides, as in Chrome’s fixed-header demo.

Duration, Easing and Slide Effects

Chrome’s shared axis example pairs a 90 ms fade-out with a 300 ms slide, each with its own cubic-bezier easing (Chrome for Developers).

Both are defined with CSS keyframes and attached through the animation shorthand on the old and new root pseudo-elements.

view-transition-class applies one style rule to many named elements, so a card grid needs one selector instead of one per card. For direction, view transition types such as :active-view-transition-type(forwards) and backwards pick the slide. And when CSS can’t do something, like a circle that grows from the click position, the Web Animations API covers it.

Policybazaar raised the default animation duration to 0.4 s on one call-to-action transition, per Chrome’s e-commerce case study. Short durations suit page changes better than long, showy ones. Past half a second it starts to feel like waiting.

Which Browsers Support CSS Page Transitions?

Same-document transitions work in all three major engines (Chromium, WebKit and Gecko). Cross-document transitions are the uneven part, and MDN still rates the @view-transition at-rule as limited availability, not Baseline, in its April 20, 2026 revision.

Support by Browser and Version

BrowserSame-documentCross-document
Chrome and EdgeSupportedSupported
Safari18.018.2 (December 11, 2024)
Firefox144 (October 14, 2025)Not supported

Firefox 144 moved same-document transitions into Baseline as newly available in October 2025, according to MDN. Mozilla’s release notes for that version list the API and its CSS pieces for single-page apps, and nothing for cross-document navigation.

One cell in that table is contested. Some secondary guides claim Firefox supports cross-document transitions from version 132 or 144, while MDN and Mozilla’s own notes show no such entry. Treat those claims as unverified until MDN’s compatibility table changes. View transition types came later, in Firefox 147 (January 13, 2026), which made them Baseline as well.

A second conflict sits between two primary sources. Chrome’s cross-document guide, last updated April 14, 2024, lists Safari as unsupported for render blocking, while WebKit’s Safari 18.2 post says the release added blocking=render and link rel=expect.

The WebKit post is newer, so Safari counts as supported. Either way, check the update date on any support box before trusting it.

Detecting Support and Falling Back

A browser without support skips the animation and loads the page as usual.

With same-document transitions, the DOM update still runs and only the animation is missing. For cross-document, you get a normal page load, because the browser ignores an @view-transition rule it doesn’t recognize, like any unknown at-rule. Custom pseudo-element CSS never matches, so it does no harm.

That makes the animation an extra layer on top of working navigation. Test it as part of your cross-browser compatibility pass, with the animation absent, and confirm every link and DOM update still works.

Where CSS Page Transitions Hurt Performance and Accessibility

A transition hurts when the page stays frozen too long, and it hurts when motion plays for visitors who asked for less of it. Blocked rendering can also delay the first paint.

Performance Costs

The update step is the first cost. While your callback runs, the page is frozen, and web.dev rates an INP of 200 milliseconds or less as good and anything above 500 as poor, measured at the 75th percentile.

INP runs until the next painted frame after a click, tap or key press. A callback that holds the page frozen is therefore time the metric can count.

Then there’s layout. The default group animation changes width and height, which Chrome’s guide (updated September 25, 2024) says triggers layout on every frame. At that update, the planned optimization hadn’t shipped.

Render blocking is the last one. Chrome advises against blocking=”render” by default unless you can measure the Core Web Vitals impact. MDN adds a useful lever here: a media attribute on link rel=expect lets narrow screens wait for less content than wide ones.

Prerendering With the Speculation Rules API

Prerendering gives a cross-document transition a loaded page to animate into.

Chrome’s prerender guide (updated January 23, 2026) reports near-zero LCP, lower CLS and better INP when a prerendered page activates. Prerenders stay same-origin by default, which matches the transition requirement, and a prerendered document fires pagereveal on activation (MDN).

The costs are real, though. Chrome caps immediate rules at 10 prerenders, and the browser treats each rule as a hint it can ignore. Single-page apps are out too, since in-app route changes can’t be prerendered.

Reduced Motion and WCAG

W3C classes Success Criterion 2.3.3, Animation from Interactions, at Level AAA. Sites aiming for AA are not required to meet it, but the criterion describes the right behavior for web accessibility.

Its definition of motion animation leaves out color and opacity changes that do not alter perceived size, shape or position. So the default cross-fade doesn’t count, but slides and growing images do.

The preference reaches CSS through a media query, and Chrome’s guide shows how to switch transitions off with it.

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

Chrome also notes that a reduced-motion preference does not always mean no motion. A subtler animation that still shows how elements relate is a valid choice.

Frameworks handle this differently, and the differences matter. Astro’s client router disables all view transition animations when the preference is detected. React doesn’t disable animations automatically, and its docs tell you to add the media query yourself. With Nuxt, experimental.viewTransition set to true skips transitions for reduced-motion users, while the value always leaves that decision to you.

Why a Page Transition Does Not Run

Most skipped transitions fail quietly. Nothing shows up on screen, and a short list of causes covers nearly all of them.

A hidden tab is the simplest. If the page visibility state is hidden when startViewTransition is called, the transition is skipped entirely (MDN).

Cross-document transitions need the navigation to be same-origin with no cross-origin redirect in the chain. If there is one, the activation property returns null.

Names left behind cause stranger bugs. A view-transition-name set in pageswap and never cleared returns from the back/forward cache, and the next pagereveal then creates duplicates. MDN’s fix is to reset names after the snapshots are taken.

Pseudo-element CSS is needed in the destination document only. A one-directional animation therefore needs the rules in both pages to cover the return trip.

Some browsers draw their own transition on swipe-back, and Chrome’s guide says to check hasUAVisualTransition before starting another. An operating system reduced-motion setting can also switch the animation off while everything else works.

Chrome DevTools covers most debugging. The Animations panel pauses the next animation so you can scrub through it, and the transition pseudo-elements appear in the Elements panel.

React has its own pitfalls, per its ViewTransition reference. A DOM node above the component stops enter and exit animations. Updates outside a Transition, Suspense or useDeferredValue never activate it. And animations from the legacy popstate event are skipped, so the back button stays still until the router uses the Navigation API.

CSS Page Transitions vs JavaScript Libraries and Framework Routers

Start with the native feature. Add a library only when you need an element to persist across pages or an animation in browsers that lack support.

ApproachJavaScript neededAnimates without native supportBest fit
Native cross-documentNoNo, plain loadSame-origin multi-page sites
Native same-documentOne callNo, update onlySingle-page apps
Barba.js or SwupYesYes, library animatesSites that need guaranteed motion
Framework routersYesVaries by frameworkApps already on that framework

Native View Transitions

Native transitions add no library weight and no script re-initialization, and the fallback is simply a normal page load. The downsides are uneven cross-document support and no way to keep a playing video or audio element alive across two separate documents.

Barba.js, Swup and GSAP

Barba.js uses PJAX. It blocks the normal link behavior, updates the URL with the history API, fetches the next page in the background, injects the new container, then removes the old one, according to its documentation.

Swup follows a similar idea. It intercepts clicks, loads the page in the background and replaces only the content containers. Before swapping, it waits for CSS animations, JavaScript animations and native View Transitions.

GSAP is an animation engine, not a router. Webflow made it free, including the former Club plugins, with a license that covers commercial use.

What you get from these tools is animation in any browser, plus prefetch and caching. The price is extra JavaScript and scripts that must re-run after each swap, and you now own the history handling.

Framework Routers

Astro’s ClientRouter component is opt-in, offers animate, swap and none fallbacks, and keeps elements such as a video playing through transition:persist. Astro’s own docs add that the router will become less necessary as browser APIs mature.

In SvelteKit, the onNavigate callback can return a promise, which is where document.startViewTransition fits. The docs warn against slow promises, because navigation then appears stalled.

Nuxt treats viewTransition as an experimental option, and the docs flag a known issue with data fetching in page setup, since transitions freeze DOM updates.

React’s ViewTransition component calls startViewTransition itself and interrupts any other view transition running on the page, so its docs say not to call it yourself.

My rule of thumb is to start with the native rule, check your analytics for the browsers you must cover, and reach for a library only when a persistent element or guaranteed motion justifies the added JavaScript.

CSS Page Transitions FAQ

Do page transitions affect SEO?

Neither MDN nor Chrome documents any ranking effect. Cross-document transitions leave each page a normal document with its own URL, and Astro’s docs say they do not alter core multi-page behavior.

The indirect risk is speed, because a slow update step hurts responsiveness metrics.

Can a page transition run between two different domains?

No. Chrome limits cross-document transitions to same-origin navigations, so a move from developer.chrome.com to www.chrome.com fails because the hostnames differ.

MDN notes the restriction could be relaxed in future implementations.

Does the back button play the transition in reverse?

Not automatically. A slide that moves right to left on every navigation feels wrong when the visitor goes back, as Chrome’s guide points out.

Use view transition types to assign a backwards animation, and set a type for each direction.

Do Webflow and WordPress sites support page transitions?

Yes, because the cross-document rule needs only CSS and no JavaScript. Webflow lists a CSS-only cloneable demo that animates where cross-document transitions are supported and loads normally elsewhere.

WordPress serves full pages from one origin, so a rule in the theme stylesheet reaches both documents.

Rolling Out CSS Page Transitions Safely

Ship the default cross-fade first and measure it in the field before adding any custom motion. That order keeps failures cheap, since the default animation adds the least work to each page load.

Opt both pages into the cross-fade, then compare real-user INP and LCP before and after. If the numbers hold, add shared elements, then types, then render blocking.

Render blocking goes last because Chrome advises measuring its Core Web Vitals cost and Safari supports it as well, so one field measurement per browser decides whether it stays.

This advice changes the day Mozilla lists @view-transition in a Firefox release note. The position was verified against MDN and Mozilla’s notes on October 27, 2026.

Draft the keyframes for the custom motion step with a CSS animation generator before wiring them to the root pseudo-elements.