concepts
view transitions

View Transitions

Style the View Transitions API with viewTransition(), which returns one class you can share across elements.

The View Transitions API (opens in a new tab) animates between two states of a page. The animation itself is CSS, written against ::view-transition-* pseudo-elements — and that is the part viewTransition() writes for you.

import { viewTransition } from '../styled-system/css'
 
const slide = viewTransition({
  group: { animationDuration: '0.4s', animationTimingFunction: 'ease-in-out' },
  old: { animationName: 'slide-out' },
  new: { animationName: 'slide-in' },
})
// → 'vt_tEsgy'

It returns a single class. Put that class on any element that should use the transition:

<img className={slide} src="/hero.png" alt="" />

What gets emitted

@layer utilities {
  .vt_tEsgy {
    view-transition-class: vt_tEsgy;
  }
 
  ::view-transition-group(.vt_tEsgy) {
    animation-duration: 0.4s;
    animation-timing-function: ease-in-out;
  }
 
  ::view-transition-old(.vt_tEsgy) {
    animation-name: slide-out;
  }
 
  ::view-transition-new(.vt_tEsgy) {
    animation-name: slide-in;
  }
}

The class sets view-transition-class, and the pseudo-element rules match on it.

You still set view-transition-name

view-transition-name has to be unique per element — two elements sharing one breaks the transition. So bamboo cannot set it for you, and does not try.

view-transition-class is the opposite: it is shared on purpose, exactly like a CSS class. That is what makes a transition something bamboo can extract, deduplicate and emit once. You pair the two:

<img className={slide} style={{ viewTransitionName: `photo-${id}` }} src={src} alt="" />

Framework helpers that own the name work the same way — React's <ViewTransition name>, Astro's transition:name:

<ViewTransition name="hero">
  <img className={slide} src="/hero.png" alt="" />
</ViewTransition>

Slots

Four slots map to the four pseudo-elements:

SlotPseudo-elementWhat it styles
group::view-transition-groupThe container animating between the two positions
imagePair::view-transition-image-pairThe pair of snapshots, old and new together
old::view-transition-oldThe outgoing snapshot
new::view-transition-newThe incoming snapshot

Every slot is an ordinary style object, so tokens, breakpoints and at-rule conditions work inside one:

const fade = viewTransition({
  group: {
    animationDuration: '0.3s',
    md: { animationDuration: '0.5s' },
    _motionReduce: { animationDuration: '0.01s' },
  },
  old: { animationName: 'fade-out' },
})

Conditions that lower to a selector rather than an at-rule — _hover, _dark, _before — do not work here, and are not rejected. A ::view-transition-* pseudo-element lives in its own overlay tree: it is not a descendant of the element carrying the class, so _dark's .dark & can never reach it, and nothing may follow a pseudo-element, so _hover cannot attach to it either. Both emit a rule that simply never matches. Stick to @media, @supports and @container conditions inside a slot.

Keyframes

animationName takes a keyframe name — one from theme.keyframes or one you declared in global.css:

export default defineConfig({
  theme: {
    extend: {
      keyframes: {
        'slide-in': {
          from: { transform: 'translateX(100%)' },
          to: { transform: 'translateX(0)' },
        },
      },
    },
  },
})

A keyframe reached only from a viewTransition() slot is still reachable, so prune.keyframes will not drop it. The same holds for tokens and prune.tokens.

The class is stable

The class is a hash of the options, so it does not depend on where or how you wrote them. These produce the same class:

viewTransition({ group: { animationDuration: '0.4s' }, old: { animationName: 'x' } })
viewTransition({ old: { animationName: 'x' }, group: { animationDuration: '0.4s' } })

Two calls with the same options anywhere in your codebase emit one set of rules.

Keys that are not slots are ignored — including by the hash — so a stray property cannot silently fork the class.

Sharing a transition

Because the class is derived from the options alone, a shared bag is just a shared module:

// transitions.ts
import { viewTransition } from '../styled-system/css'
 
export const slide = viewTransition({
  group: { animationDuration: '0.4s' },
  old: { animationName: 'slide-out' },
  new: { animationName: 'slide-in' },
})

Import it anywhere. The CSS is emitted once, from wherever the extractor found the call.

Limitations

  • The whole bag has to be static. This is stricter than css(), and the difference matters. Because one class covers all four slots, a single value bamboo cannot resolve at build time changes the hash for the entire bag — so the static slots lose their CSS too, not just the dynamic one:

    import { duration } from './theme-config'
     
    // ⚠️ `duration` is not statically known, so nothing here is emitted —
    // including the `old` slot, which is perfectly static.
    viewTransition({
      group: { animationDuration: duration },
      old: { animationName: 'fade-out' },
    })

    Keep the options a literal. If part of a transition is genuinely dynamic, split it into its own bag so it cannot take the static half down with it.

  • Selector conditions do not apply. See Slots — use at-rule conditions inside a slot.

  • Not resolvable inside a style object. css({ viewTransitionClass: viewTransition({ … }) }) is a call expression where a static value is expected, so the declaration is dropped. Assign the class to a variable and pass it as a className.

  • Compiled by Vite. A static viewTransition() bag becomes its compact class literal. Its carrier selector, pseudo-element selectors, and view-transition-class value are renamed and pruned as one unit. An open runtime bag fails compilation because no finite emitted transition rule can back it.

  • No view-transition-type conditions. Styling by transition type (:active-view-transition-type()) is not exposed as a condition yet.

Browser support

The View Transitions API is not supported everywhere (opens in a new tab). Where it is missing, the emitted CSS is inert and the page changes state without animating — so this degrades on its own, with nothing to guard.