# View transitions

> Patterns for using the CSS View Transitions API with Roadie layouts. Covers z-index layering, named groups, keyframes, and search-param navigation.

Reference: [Motion tokens](https://ticketsolutionsptyltd.github.io/roadie/tokens/motion.md).

The CSS
[View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/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.

```css
/* 1. Name the header's group */
header[data-sticky] {
  view-transition-name: site-header;
}

/* 2. Float it above everything else during a transition */
::view-transition-group(site-header) {
  z-index: 100;
}
```

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.

```css
/* 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 */
::view-transition-new(root) {
  animation: roadie-fade-in var(--duration-slow) var(--ease-enter) both;
}

@keyframes roadie-slide-up {
  to { opacity: 0; transform: translateY(-16px); }
}
@keyframes roadie-fade-in {
  from { opacity: 0; }
}
```

Keep durations short. Stay under 400ms for navigation-level transitions.
Respect [motion tokens](https://ticketsolutionsptyltd.github.io/roadie/foundations/motion.md) 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.

```tsx
'use client'

import { useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useRef } from 'react'

import { Button } from '@oztix/roadie-components/button'

function FilterButton({ href }: { href: string }) {
  const router = useRouter()
  const searchParams = useSearchParams()
  const resolveRendered = useRef<(() => void) | null>(null)

  useEffect(() => {
    resolveRendered.current?.()
    resolveRendered.current = null
  }, [searchParams])

  function handleClick() {
    // Not all browsers implement view transitions yet
    if (typeof document.startViewTransition === 'function') {
      document.startViewTransition(
        () =>
          new Promise<void>((resolve) => {
            resolveRendered.current = resolve
            router.push(href)
          })
      )
    } else {
      router.push(href)
    }
  }

  return <Button onClick={handleClick}>Filter</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](https://ticketsolutionsptyltd.github.io/roadie/foundations/navigation.md), 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.

Docs updated 9 Oct 2026.
