RoadieRoadie
  • Home
  • Brand
  • Foundations
  • Content
  • Tokens
  • Components
  • Charts
  • Widgets
  • Appearance
HomeBrandFoundations
Appearance

Foundations

  • Visual

    • Colors
    • Elevation
    • Iconography
    • Shape
    • Theming
  • Content

    • Date and time
    • Layout
    • Prose styles
    • Typography
  • Behaviour

    • Accessibility
    • Interactions
    • Motion
    • View transitions
  • Building apps

    • Forms
    • Linking
    • Navigation
    • Performance
    • Records modelAlpha
    • Tables

View transitions

Docs updated
9 Oct 2026

ReferenceMotion tokens

The CSS View Transitions API lets you morph, slide, and fade content across navigation boundaries without touching a JavaScript animation library. Roadie components compose cleanly with it. Sticky headers, hero images, and search-param-only navigation each have gotchas, covered below.

How view transitions work

When the browser sees a new document during a view transition, it captures a snapshot of the old DOM and a snapshot of the new DOM, then interpolates between them. Each snapshot lives inside a pseudo-element tree:

  • ::view-transition-group(name) is the box that holds the old and new snapshots for a named element.
  • ::view-transition-image-pair(name) is the cross-fade pair.
  • ::view-transition-old(name) and ::view-transition-new(name) are the two snapshots that the browser animates between.

Anything without an explicit view-transition-name lives under the implicit root group. The root group paints first, which is why sticky headers sometimes end up underneath a hero image during a transition. The hero gets its own group, and the root group has no z-index.

Layering sticky headers above content

Give the sticky header its own named group and set a z-index on its ::view-transition-group. Without this, content sliding in from below the fold will visually pass through or over the header mid-transition.

/* 1. Name the header's group */
header[data-sticky] {
view-transition-name: site-header;
}
/* 2. Float it above everything else during a transition */

Pick any integer above the default 0. 100 is enough for most apps. Do the same for floating buttons, bottom sheets, and any other UI that should stay fixed while the page underneath animates.

Naming elements

When you name an element with view-transition-name, it gets its own animation group and morphs from its old position and size to its new position and size. Unnamed elements cross-fade together under the root group.

Name only the elements that need their own motion or layering. Names aren't free. The browser captures every rendered element with a name as its own group and runs that group's default animation, even when the element doesn't move. Each name must also be unique on the page, or the browser skips the transition. Naming the hero image, the title, and the header separately is still cheaper than debugging a root-group cross-fade that clobbers half your UI.

Common named groups:

  • site-header for the sticky top nav
  • collection-hero for the above-the-fold hero image
  • collection-title for the title that persists across routes
  • collection-header for the elevated surface that wraps the hero on per-collection routes

Recommended keyframes

Default view transitions cross-fade for 250ms. Override with a named @keyframes rule and attach it to either the old or new snapshot.

/* Slide the outgoing page up and fade it out */
::view-transition-old(root) {
animation: roadie-slide-up var(--duration-moderate) var(--ease-exit) both;
}
/* Fade the incoming page in */

Keep durations short. Stay under 400ms for navigation-level transitions. Respect motion tokens and prefers-reduced-motion. Wrap your ::view-transition-* rules in @media (prefers-reduced-motion: no-preference) so motion-sensitive users get an instant swap.

Triggering on search-param-only navigation

Next.js App Router doesn't trigger a hard navigation when only a search parameter changes, so browser-native view transitions don't fire automatically. Wrap the update in document.startViewTransition inside your navigation handler.

'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useRef } from 'react'
import { Button } from '@oztix/roadie-components/button'

startViewTransition waits for the promise its callback returns before it captures the new snapshot. router.push returns before Next renders the new search params, so the callback returns a promise that resolves once they have rendered. Without it, the browser captures the old page twice and nothing animates. If href is already the current URL, push it without a transition, since the params never change and the promise never resolves.

Inside a Navigator shell, Navigator animates pane-to-pane route changes itself. Don't wrap a router.push that changes the active pane in document.startViewTransition. Keep this pattern for query-string updates within a pane's content, or for pages outside a <Navigator>.

Guidelines

  • Always feature-detect. document.startViewTransition is still gaining adoption. Wrap every call in a typeof check and fall back to the standard navigation.
  • Reserve view transitions for spatial continuity. Use them when the user would benefit from seeing an element move (hero → detail page, list → filtered list). Skip them for navigation that isn't spatially related.
  • Stay under 400ms. Longer transitions feel sluggish, especially on search-param navigation where the user expects instant filter feedback.
  • Test on mobile Safari. iOS has stricter rules about when startViewTransition can run. If you trigger it from an async callback, the captured snapshot may be stale.
  • Don't animate intent. Changing --accent-hue or --accent-chroma during a transition produces noticeably ugly interpolation. Set the new accent before the transition starts, or after it completes.
  • Skip this inside a Navigator shell. Navigator owns its own pane-to-pane transitions.
Previous page← MotionNext pageForms →