View Transition Types and Classes

A view transition with one or two named elements is easy to style: a selector per name, done. Real interfaces outgrow that quickly. A grid of twenty product cards needs twenty unique names, and writing ::view-transition-group(card-1), ::view-transition-group(card-2), … does not scale. And the same change often needs different motion depending on context — paging forward through a gallery should slide left, paging back should slide right. Two features address these problems: view-transition-class groups many named elements under one styling hook, and transition types tag a whole transition so its styles can vary. This page covers both, for same-document and cross-document transitions. It belongs to View Transitions for CSS Developers in the CSS-Only Micro-Interactions & Animations guide.

Names identify, classes style

Every participating element still needs a unique view-transition-name — that is how the browser pairs an element's old and new state. The class is an additional, non-unique label used only for styling the pseudo-elements. Many elements can share a class; one element can have several classes, separated by spaces.

Unique names, shared class Three card boxes labelled with view-transition-name card-1, card-2 and card-3. Below each, the shared view-transition-class card. Lines lead from all three to a single selector box reading ::view-transition-group(*.card), which applies one animation rule to every card's group. One selector for many groups name: card-1 name: card-2 name: card-3 class: card class: card class: card ::view-transition-group(*.card) The * matches any name; .card narrows to groups carrying that class.

The complete implementation: classes

A filterable card grid. Each card has a unique name generated from its ID and the shared class card; one set of rules animates all of them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>view-transition-class</title>
<style>
  .grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
    gap: 1rem;
  }

  .card {
    view-transition-class: card;
    padding: 1rem;
    border-radius: 10px;
    background: #eef2ff;
  }
  /* Unique names, one per card. */
  .card[data-id="1"] { view-transition-name: card-1; }
  .card[data-id="2"] { view-transition-name: card-2; }
  .card[data-id="3"] { view-transition-name: card-3; }

  /* One rule for every card group. */
  ::view-transition-group(*.card) {
    animation-duration: 300ms;
    animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
  }

  /* Cards that appear or disappear scale rather than crossfade. */
  ::view-transition-new(*.card):only-child {
    animation: card-in 250ms ease-out both;
  }
  ::view-transition-old(*.card):only-child {
    animation: card-out 200ms ease-in both;
  }

  @keyframes card-in  { from { opacity: 0; scale: 0.85; } }
  @keyframes card-out { to   { opacity: 0; scale: 0.85; } }

  @media (prefers-reduced-motion: reduce) {
    ::view-transition-group(*.card) { animation-duration: 1ms; }
    ::view-transition-new(*.card):only-child,
    ::view-transition-old(*.card):only-child {
      animation: none;
    }
  }
</style>
</head>
<body>
  <button type="button" id="filter">Show only featured</button>
  <div class="grid">
    <article class="card" data-id="1">Lake</article>
    <article class="card" data-id="2" data-featured>Ridge</article>
    <article class="card" data-id="3">Pier</article>
  </div>
  <script>
    document.getElementById('filter').addEventListener('click', () => {
      const update = () => {
        document.querySelectorAll('.card:not([data-featured])')
          .forEach((card) => card.toggleAttribute('hidden'));
      };
      if (!document.startViewTransition) return update();
      document.startViewTransition(update);
    });
  </script>
</body>
</html>

The :only-child trick is worth understanding. When a card exists in both states, its ::view-transition-image-pair holds both an old and a new image. When a card is removed, only the old image exists, so ::view-transition-old(*.card) is the pair's only child; when a card is added, only the new one. Matching :only-child therefore targets exactly the entering and leaving cards, and lets moving cards keep the default crossfade inside their morphing group. In a real app the per-card names come from the template, for example style="view-transition-name: card-{{ id }}", or from view-transition-name: match-element where it is supported.

Why :only-child finds entering and leaving cards Three columns. Moving card: the image pair contains an old and a new image, so neither is an only child. Removed card: the pair contains only the old image, which matches old with only-child. Added card: the pair contains only the new image, which matches new with only-child. Inside each image pair moving card removed card added card old new old (only child) new (only child) default crossfade card-out animation card-in animation

Types: one change, different motion

A class says which elements are involved. A type says what kind of transition this is. Types are attached when the transition starts and matched with :active-view-transition-type() on the document root, so every pseudo-element rule can depend on them.

Types choose the direction Top row, type forward: the old view moves off to the left while the new view enters from the right. Bottom row, type backward: the old view moves off to the right and the new view enters from the left. The same DOM change produces both; only the type differs. Same update, typed differently forward old: exits left new: enters from right backward new: enters from left old: exits right
function goTo(index) {
  const direction = index > current ? 'forward' : 'backward';
  const update = () => { current = index; render(); };

  if (!document.startViewTransition) return update();
  document.startViewTransition({ update, types: [direction] });
}
html:active-view-transition-type(forward) {
  &::view-transition-old(root) { animation: slide-to-left 280ms ease-in both; }
  &::view-transition-new(root) { animation: slide-from-right 280ms ease-out both; }
}

html:active-view-transition-type(backward) {
  &::view-transition-old(root) { animation: slide-to-right 280ms ease-in both; }
  &::view-transition-new(root) { animation: slide-from-left 280ms ease-out both; }
}

@keyframes slide-to-left    { to   { translate: -30% 0; opacity: 0; } }
@keyframes slide-from-right { from { translate: 30% 0;  opacity: 0; } }
@keyframes slide-to-right   { to   { translate: 30% 0;  opacity: 0; } }
@keyframes slide-from-left  { from { translate: -30% 0; opacity: 0; } }

Because the selector sits on html, types can also change non-pseudo-element styles for the duration of the transition — for example, disabling pointer events on a toolbar while a slide runs. The type is removed when the transition finishes.

The key technique: baseline first, types and classes on top

Write untyped, unclassed rules as the baseline — the default crossfade or a single set of durations — and add typed and classed rules as refinements. That ordering has two benefits. Browsers that support view transitions but not these newer selectors drop only the refinements and still animate sensibly. And transitions started without a type, such as those triggered by a library you do not control, get the baseline instead of no styling at all.

A second rule of thumb: keep types semantic. forward, backward, reload-data and open-detail describe intent and stay meaningful when the animation changes; slide-left describes a particular animation and becomes a lie the day the design switches to a fade.

Types across documents

For Cross-Document View Transitions, there is no startViewTransition call. Types come from two places: the types descriptor of the @view-transition rule, which applies the same types to every opted-in navigation, and the pageswap and pagereveal events, whose viewTransition object exposes a types set that script can modify. The usual pattern compares the old and new URLs in pagereveal — for instance, the index of two chapters — and adds forward or backward accordingly. The CSS is identical to the same-document version.

Choosing the right hook

Names, classes and types overlap enough to cause hesitation. A quick way to choose:

  • Style one specific element — a site header, a hero image — with its name: ::view-transition-group(site-header). It is the most specific hook and reads clearly.
  • Style a family of elements — cards, list rows, avatars — with a class: ::view-transition-group(*.card). Add a second class for sub-families, such as card featured, and target *.card.featured for the exceptions.
  • Vary the whole transition by intent — direction, a modal opening, a data refresh — with a type on the root. Types combine with names and classes: html:active-view-transition-type(forward)::view-transition-group(*.card) styles cards only on forward transitions.
  • Change ordinary page styles during a transition — with a type selector on html that targets normal elements, since the type is active for exactly the transition's lifetime.

Debugging classes and types

When a classed rule does not apply, inspect the transition in Chromium's DevTools Animations panel, pause it, and select the pseudo-element in the Elements panel: the ::view-transition tree appears under html with each group's name. If the group is there but unstyled, the class was probably missing from the element when it was captured. Classes, like names, are read from computed style at capture, so a class added by script after the transition starts, or applied only on hover, never reaches the pseudo-element tree. Put view-transition-class in the same rule as the element's ordinary styles so it is always present. For types, log transition.types from the object returned by startViewTransition; an empty set means the options object was not passed or the browser predates types.

Common mistakes

  • Forgetting the unique name. A class alone does nothing. An element with view-transition-class: card but no view-transition-name does not participate in the transition at all.
  • Using the class selector without *. ::view-transition-group(.card) is not valid; the argument is a name followed by optional classes, and * stands for any name: ::view-transition-group(*.card).
  • Leaving a type-specific animation without a reduced-motion override. Directional slides are exactly the motion reduced-motion users want removed. Wrap typed rules in @media (prefers-reduced-motion: no-preference) or override them afterwards; View Transitions and Reduced Motion shows a substitution approach.

Browser support

Same-document view transitions and view-transition-name are supported in Chrome and Edge 111+, Firefox 144+ and Safari 18+. The cross-document @view-transition rule is supported in Chrome and Edge 126+ and Safari 18.2+, not in Firefox. view-transition-class, transition types and :active-view-transition-type() arrived after the core API, so older versions of supporting browsers run the transition but ignore these selectors; the baseline-first approach above keeps those browsers working. Browsers without view transitions at all apply the DOM change instantly through the feature check in the script.

FAQ

What is view-transition-class for? It lets many elements with different view-transition-name values share one set of animation rules. Instead of writing a selector for card-1, card-2 and card-3, give them all view-transition-class: card and target ::view-transition-group(*.card).

How is a transition type different from a class? A class describes which elements are involved; a type describes what kind of transition is happening, such as going forward or backward. Types are set when the transition starts and matched on the root with :active-view-transition-type().

How do I set a transition type? For same-document transitions, pass types to document.startViewTransition({ update, types: ['forward'] }). For cross-document transitions, list them in the @view-transition rule's types descriptor or add them in a pagereveal or pageswap handler.

What happens if a browser does not support types or classes? Rules using the unsupported selectors are ignored, so the transition falls back to your untyped styles or the default crossfade. Write the untyped rules first as a sensible baseline and layer typed rules on top.

Related articles

More pages in the same section.

dy>