Skip to content
All posts
#React#Animation#CSS#Architecture

View Transitions Without Framework Lock-In

4 min read

A progressive-enhancement pattern for adding polished route and panel transitions while keeping state, accessibility, and fallbacks under your control.

Why this matters

A polished route change does not need to become a framework feature, a router rewrite, or a full animation system. The platform now gives frontend teams a useful primitive: the View Transition API can capture the current page, let your code update the DOM, and animate between the old and new visual states. MDN documents document.startViewTransition() as a same-document SPA API and notes that its callback may return a Promise, so asynchronous updates can participate in the transition.[1]

The practical design goal is progressive enhancement. Navigation, focus management, loading states, and reduced-motion behavior must remain correct when the API is unavailable. The transition should improve continuity, not become a dependency that blocks the underlying interaction.

Start with a boring update function

Put the state change in one function that works without animation. Then wrap only the call site in a capability check. This keeps the feature boundary small and makes it easy to test the actual product behavior separately from the visual effect.

For a client-side tab panel, the shape is intentionally simple:

view-transition.tstypescript
function showPanel(id: string) {
  document.querySelectorAll('[data-panel]').forEach((panel) => {
    panel.toggleAttribute('hidden', panel.id !== id);
  });
}

export function navigateToPanel(id: string) {
  if (!('startViewTransition' in document)) {
    showPanel(id);
    return;
  }

  document.startViewTransition(() => showPanel(id));
}

The fallback is not an afterthought: it is the contract. If an older browser reaches this code, the panel still changes immediately. If the update throws or an asynchronous callback rejects, the browser can abandon the transition while your application remains responsible for leaving state consistent.[1]

Use CSS for the motion, JavaScript for orchestration

The API supplies a transition pseudo-element tree; CSS decides how that tree moves. Start with the default cross-fade, then customize only the parts that communicate continuity. MDN's guide describes both same-document and cross-document transitions, and it highlights named elements and view-transition pseudo-elements as the main customization points.[2]

A restrained stylesheet is often enough:

view-transition.csscss
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 180ms;
  animation-timing-function: ease-out;
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*) {
    animation-duration: 1ms;
  }
}

Do not animate every surface by default. A global transition can make a dense dashboard feel slow, especially when a route changes several regions at once. Prefer a short duration, stable layout, and one visual anchor—such as a heading, card, or image—that helps the user understand what persisted.

React integration: separate urgency from animation

In React, the state update and the browser transition solve different problems. React's startTransition marks updates as non-blocking work, while document.startViewTransition coordinates the visual snapshot around a DOM update.[3] They can be composed, but neither replaces loading, error, or accessibility states.

A useful rule is to keep the transition wrapper at the edge of the interaction and keep data fetching explicit. If a click changes the URL immediately but content arrives later, transition the shell or selected state first; do not hide a long network wait behind an animation. For expensive rendering, use startTransition where it improves responsiveness, then measure whether the extra coordination actually helps.

Three production checks

First, test the unsupported path. Delete the API call in a unit test or run the feature in a browser where the method is absent. The result should still update state, preserve focus, and expose the same semantics.

Second, test interruption. Rapid clicks, back navigation, and a rejected data request should not leave two panels visible or a stale selection announced to assistive technology. Treat the transition as cancellable presentation, never as your source of truth.

Third, test motion preferences. Reduced motion is not just a smaller duration; for some users, even a cross-fade is distracting. Offer a near-instant style or skip the transition entirely when prefers-reduced-motion is active.

Also inspect the real layout, not only a clean demo. Images without dimensions, late-loading fonts, and content that changes height during the snapshot can produce a transition that feels like a jump. Reserve media space, keep the animated region stable, and avoid tying the effect to a component that is frequently remounted. In code review, ask whether the transition improves orientation or merely adds movement; that question prevents a visually impressive interaction from becoming a usability regression.

The payoff

View transitions are most valuable when they clarify continuity: the user can see which surface changed, which surface stayed, and where the new content came from. The implementation stays maintainable when the application owns state and the platform owns the snapshot, while CSS owns the motion. That division gives you a better animation with less framework-specific code—and a perfectly functional page when the enhancement is unavailable.

Sources

[1] MDN, Document.startViewTransition(): https://developer.mozilla.org/en-US/docs/Web/API/Document/startViewTransition

[2] MDN, Using the View Transition API: https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API/Using

[3] React documentation, startTransition: https://react.dev/reference/react/startTransition