---
title: "Animate page changes with view transitions"
audience: "anyone who wants a page change to feel like one screen rather than two"
description: "You'll build a grid of colors where clicking a tile grows it into the full swatch on the next page. Back shrinks it home again. It's the browser's own View…"
snippet_caption: ""
snippet: ""
---

import { Install } from "@src/ui/Install";
import { Demo } from "@src/ui/Demo";
import { Tiles } from "@src/demos/Tiles";
import { COLORS } from "@src/demos/colors";

You'll build a grid of colors where clicking a tile grows it into the full swatch on the next page. Back shrinks it home again. It's the browser's own View Transitions API, driven by the navigator fsr already uses for client navigation. It takes one option and one CSS property.

<Install captured="0.x" />

## A list and a detail page

```shell-session
$ fsr new swatches --with react
```

The colors, in `app/src/colors.ts`:

```ts title="app/src/colors.ts"
export type Color = { slug: string; name: string; hex: string };

export const COLORS: Color[] = [
  { slug: "ember", name: "Ember", hex: "#e4572e" },
  { slug: "saffron", name: "Saffron", hex: "#f3a712" },
  { slug: "moss", name: "Moss", hex: "#669d31" },
  { slug: "lagoon", name: "Lagoon", hex: "#17bebb" },
  { slug: "dusk", name: "Dusk", hex: "#4b3f72" },
  { slug: "rose", name: "Rose", hex: "#d1495b" },
];
```

The index lists them as tiles:

```ts title="app/routes/page.loader.ts"
import type { Ctx } from "@snapfire/fsr";
import { COLORS } from "@src/colors";

export async function load(_ctx: Ctx<"/">) {
  return { colors: COLORS };
}
```

```tsx title="app/routes/page.tsx"
import type { RootProps } from "@generated/client";
import { Link } from "@snapfire/fsr-client/react";

export default function Index({ colors }: RootProps) {
  return (
    <ul className="grid">
      {colors.map((c) => (
        <li key={c.slug}>
          <Link href={`/color/${c.slug}`}>
            <span className="tile" style={{ backgroundColor: c.hex, viewTransitionName: `swatch-${c.slug}` }} />
            {c.name}
          </Link>
        </li>
      ))}
    </ul>
  );
}
```

Each color gets a page with one big swatch:

```ts title="app/routes/color/[slug]/page.loader.ts"
import type { Ctx } from "@snapfire/fsr";
import { fail } from "@snapfire/fsr";
import { COLORS } from "@src/colors";

export async function load({ params }: Ctx<"/color/{slug}">) {
  const color = COLORS.find((c) => c.slug === params.slug);
  if (!color) fail("not_found", `there is no color ${params.slug}`);
  return { color };
}
```

```tsx title="app/routes/color/[slug]/page.tsx"
import type { ColorSlugProps } from "@generated/client";
import { Link } from "@snapfire/fsr-client/react";

export default function ColorPage({ color }: ColorSlugProps) {
  return (
    <article>
      <Link href="/">All colors</Link>
      <div className="swatch" style={{ backgroundColor: color.hex, viewTransitionName: `swatch-${color.slug}` }} />
      <h1>{color.name}</h1>
      <code>{color.hex}</code>
    </article>
  );
}
```

Look at the two `viewTransitionName`s. The tile for Lagoon on the index and the swatch on `/color/lagoon` are both called `swatch-lagoon`. That shared name is the whole trick: it tells the browser they're the same thing in two places.

Some layout, at the end of `app/styles/app.css`:

```css title="app/styles/app.css"
.grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
  list-style: none;
  padding: 0;
}

.grid a {
  color: inherit;
  display: grid;
  gap: 8px;
  text-decoration: none;
}

.tile {
  aspect-ratio: 1;
  border-radius: 12px;
}

.swatch {
  border-radius: 16px;
  height: 320px;
  margin: 24px 0;
}
```

## Turn transitions on

The scaffold's `app/src/main.ts` already starts the navigator, which swaps pages without a full reload when a `Link` is clicked. Give it one option:

```ts title="app/src/main.ts"
import { boot, enableNavigation } from "@snapfire/fsr-client";
import { registerIslands } from "@generated/islands.js";

registerIslands();

boot();
enableNavigation({ transitions: true });
```

```shell-session
$ fsr dev swatches/app
```

Open `http://127.0.0.1:3000` and click Lagoon. The teal tile grows out of its grid cell into the wide swatch while the rest of the page crossfades to the detail page. Press Back and it shrinks into its cell again.

<Demo title="The swatch grid from this tutorial, on a site with transitions on">
  <Tiles colors={COLORS} />
</Demo>

This site's navigator runs with `transitions: true`, so the tiles above behave the way yours will. Click one, then press Back.

## What happened

With `transitions: true` the navigator does its usual swap inside `document.startViewTransition`. The browser takes a picture of the old page. The navigator puts the new one in and the browser animates between the two. Without any names that's a crossfade of the whole page. An element with a `view-transition-name` gets its own animation from where it was to where it is, so `swatch-lagoon` moves and resizes from the tile's box to the swatch's.

The pages are still rendered by the server. The navigator fetches the next page's payload, the same request it makes with transitions off, so nothing about loading or data changes. The animation is all CSS in the browser.

## Who doesn't see it

The navigator skips the transition when the visitor's system asks for reduced motion, when the browser has no View Transitions API and when the tab is hidden. The page still changes; it just changes at once. Only a navigation animates, so a refresh after an action doesn't.

## Try it

Turn on reduced motion in your system settings or in the browser's rendering tools, then click a tile. You land on the color page with no animation.

Then turn it off for one link. Change the "All colors" link on the color page to:

```tsx
<Link href="/" data-sf-transition="none">All colors</Link>
```

That link now swaps without animating, while every tile on the index still grows. The opposite works too: leave `enableNavigation()` with no options and put `data-sf-transition` on just the links that should animate.
