CreaTech
LAB-007ReactExperimental

View Transitions in React: morphing a card into a detail view

A grid card that grows into a detail panel, using the View Transitions API and one shared name, with flushSync doing the work most examples leave out.

Built 18 Aug 2026 · Last verified 18 Aug 2026

The short version

The View Transitions API animates between two DOM states without you writing either state as an animation. You give the element a view-transition-name in both the before and after markup, call document.startViewTransition with the update inside it, and the browser morphs the old box into the new one. In React the update has to be wrapped in flushSync, because the snapshot is taken as soon as the callback returns and a normal state update has not committed by then.

LAB-007live

How it works

  1. Each card and the detail panel it opens into share a view-transition-name. Only one of them is ever in the DOM, which is the requirement: two rendered elements with the same name is an error and the transition is skipped entirely.
  2. The name is set inline from the item's id rather than in the stylesheet, because it has to be unique per item and a stylesheet cannot know how many items there are.
  3. document.startViewTransition takes a callback that changes the DOM. The browser screenshots the page before it runs and again after it returns, then animates between the two.
  4. flushSync wraps the setState inside that callback. Without it React batches the update, the callback returns before the DOM has changed, and the browser captures two identical snapshots: no error, no animation, just nothing happening. This is the single most common reason a React view transition does nothing at all.
  5. The root snapshot covers the whole page, so an embedded demo would cross-fade the entire article around it. Setting animation: none on ::view-transition-old(root) and ::view-transition-new(root) leaves only the named element morphing. Keep those as two separate rules: merged into an :is() list by a minifier they stop matching anything at all, silently.
  6. Both branches degrade: no startViewTransition, or a reduced-motion preference, and the state change happens on its own with no transition. Nothing is conditional except the animation.

The one line most React examples leave out

If you have tried the View Transitions API in React and got nothing, no error and no animation, this is almost certainly why:

document.startViewTransition(() => {
  flushSync(() => setSelected(id));
});

startViewTransition screenshots the page, runs your callback, and screenshots it again the moment the callback returns. React batches state updates. So without flushSync the callback returns before the DOM has changed, the browser captures two identical snapshots, and it animates diligently between them: a perfect, invisible transition from a thing to itself.

There is no warning. Nothing throws. It simply does nothing, which is a much harder failure to debug than an exception would be.

flushSync is normally a smell, and React's own documentation says so, because it forces a synchronous re-render and abandons batching. This is the exception the documentation has in mind. You are not reaching for it to work around a render-timing bug; you are reaching for it because an external API needs the DOM committed at a specific instant.

Names, and why only one element can wear them

The morph itself is one property:

view-transition-name: --card-alu;

Put the same name on the grid card and on the detail panel it opens into, and the browser treats them as the same thing in two places. It captures the old box, captures the new one, and animates position, size and content between them.

The rule that catches people: a name must be unique among rendered elements. Two elements with the same view-transition-name on screen at once is an error, and the browser's response is to skip the entire transition. Not that group. All of it.

That is why this demo renders either the grid or the detail, never both. It also means the name cannot live in the stylesheet when the list is dynamic, since a stylesheet has no way to know how many items there are. It goes inline, derived from the item's id:

<button style={{ viewTransitionName: `lab-vt-${card.id}` }}>

The root snapshot will surprise you

startViewTransition does not capture the element you named. It captures the whole page, as ::view-transition-old(root) and ::view-transition-new(root), and cross-fades that, with your named elements animating as separate groups on top.

For a full-page navigation that is exactly right. For a demo embedded halfway down an article, it means clicking a card cross-fades the entire article, header and all. The first time I saw it I assumed I had broken something.

The fix is to opt the root out of animating:

::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
}

Now only the named element moves. Worth knowing well beyond demos: any time you want a targeted morph rather than a page-level crossfade, this is the declaration that gets you there.

There is a trap in shipping it, and I walked into it here. Write those two selectors as a list, which is how every example on the web writes them, and a CSS minifier may merge them into :is(::view-transition-old(root), ...). :is() does not accept pseudo-elements: the browser strips them out, the selector collapses to a bare :is() matching nothing, and the rule is silently dead. The symptom is the whole page cross-fading again in production while it behaves in development, with no error anywhere. Keeping them as two separate rules survives minification intact.

Cross-document, and why this site cannot use it

The version of this API that gets the most attention is the cross-document one: add @view-transition { navigation: auto; } to two pages and navigating between them animates, with no JavaScript at all. It is genuinely remarkable, and it is the reason the API got so much attention when it shipped.

It also does not apply to this site, and it is worth being precise about why. Cross-document transitions run on real document navigations. Next's App Router intercepts internal links and swaps the page client-side, so no document navigation happens and the rule never fires. The same is true of every SPA router.

That is not a Next problem to be worked around. It is the trade: client-side routing buys you preserved state and no reload, and the cost is that the document-level transition primitive does not apply. What applies instead is the same-document API, which is what this demo uses, and React 19's <ViewTransition> component is a thinner way to reach it.

Treat it as an upgrade, not a feature

Both exits from this component are the same exit:

if (!document.startViewTransition || REDUCED?.matches) {
  setOpenId(next);
  return;
}

No support, or a reduced-motion preference, and the state changes instantly. That is the app you had before you added any of this. Nothing about the feature is load-bearing, which is the correct relationship to have with an API that a third of your visitors cannot run.

The reduced-motion branch is not politeness either. A morph is a large element travelling a long way across the viewport at speed, which is the exact motion profile the setting exists to remove.

One thing to do properly in a real app

Focus. When the view changes, the button that was focused stops existing, and focus falls back to <body>. A keyboard user is then at the top of the document with no idea the view changed.

The demo gets away with it because the detail's back button is the next thing in the tab order. A real implementation should move focus deliberately, to the heading of the incoming view, and announce the change. The animation is the easy half of a view change; this is the half that decides whether it is usable.

The source

MIT licensed. Use it in anything, no attribution needed.

Read straight off the file the demo above imports, then highlighted at build time. What you are reading is what is running.

Demo.tsxtsx
import { useCallback, useState } from "react";
import { flushSync } from "react-dom";
import styles from "./Demo.module.scss";

const CARDS = [
  { id: "alu", name: "Aluminium", note: "Brushed, 6000 series, anodised." },
  { id: "gls", name: "Glass", note: "Chemically strengthened, 0.7mm." },
  { id: "cer", name: "Ceramic", note: "Zirconia, sintered at 1500C." },
];

const REDUCED =
  typeof window === "undefined"
    ? null
    : window.matchMedia("(prefers-reduced-motion: reduce)");

export default function ViewTransitionMorph() {
  const [openId, setOpenId] = useState<string | null>(null);

  const go = useCallback((next: string | null) => {
    // Two reasons to skip straight to the state change: the browser has no
    // View Transitions, or the visitor has asked for less motion. Either way
    // the app still works, which is the point of treating it as an upgrade.
    if (!document.startViewTransition || REDUCED?.matches) {
      setOpenId(next);
      return;
    }

    // flushSync is required: startViewTransition snapshots the DOM when the
    // callback returns, and a normal React update would not have committed yet.
    document.startViewTransition(() => {
      flushSync(() => setOpenId(next));
    });
  }, []);

  const open = CARDS.find((c) => c.id === openId);

  return (
    <div className={styles.wrap}>
      {open ? (
        <div className={styles.detail} style={{ viewTransitionName: `lab-vt-${open.id}` }}>
          <h3 className={styles.detailName}>{open.name}</h3>
          <p className={styles.detailNote}>{open.note}</p>
          <button type="button" className={styles.back} onClick={() => go(null)}>
            ← Back to the grid
          </button>
        </div>
      ) : (
        <ul className={styles.grid}>
          {CARDS.map((c) => (
            <li key={c.id}>
              <button
                type="button"
                className={styles.card}
                style={{ viewTransitionName: `lab-vt-${c.id}` }}
                onClick={() => go(c.id)}
              >
                {c.name}
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
Demo.module.scssscss
/* The root snapshot covers the WHOLE page, so without this the demo would
   cross-fade the entire article around it. Killing the root animation leaves
   only the named element morphing, which is what an embedded demo wants.
   :global because view-transition pseudo-elements live on the document.

   These two MUST stay separate rules. Written as one selector list, Lightning
   CSS merges them into :is(::view-transition-old(root), ...), and :is() does
   not accept pseudo-elements: the browser strips them, the selector collapses
   to a bare :is() that matches nothing, and the whole page cross-fades again
   with no error anywhere. Do not tidy these into a list. */
:global(::view-transition-old(root)) {
  animation: none;
}
:global(::view-transition-new(root)) {
  animation: none;
}

.wrap {
  display: grid;
  place-items: center;
  width: 100%;
  min-height: 210px;
  padding: clamp(20px, 4vw, 34px);
}

.grid {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 12px;
}

.card {
  cursor: pointer;
  padding: 22px 20px;
  min-width: 116px;
  border-radius: var(--r-md);
  border: 1px solid var(--line);
  background: var(--glass);
  color: var(--silver-050);
  font-family: var(--sans);
  font-size: 15px;
  font-weight: 600;
  letter-spacing: -0.01em;
  transition: border-color 0.16s ease, background 0.16s ease;

  &:hover {
    border-color: var(--accent);
    background: var(--glass-strong);
  }
}

.detail {
  width: min(100%, 340px);
  padding: 24px;
  border-radius: var(--r-md);
  border: 1px solid var(--accent);
  background: var(--glass-strong);
  text-align: center;
}

.detailName {
  font-size: 22px;
  font-weight: 600;
  letter-spacing: -0.02em;
}

.detailNote {
  margin-top: 8px;
  font-size: 14px;
  line-height: 1.6;
  color: var(--silver-300);
}

.back {
  margin-top: 16px;
  cursor: pointer;
  font-family: var(--mono);
  font-size: 12px;
  letter-spacing: 0.04em;
  padding: 7px 13px;
  border-radius: var(--r-pill);
  border: 1px solid var(--line);
  background: transparent;
  color: var(--silver-300);
  transition: 0.16s ease;

  &:hover {
    color: var(--silver-050);
    border-color: var(--accent);
  }
}

/* Both halves of the morph share one group, so one duration governs it. */
:global(::view-transition-group(*)) {
  animation-duration: 0.32s;
  animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}

Questions worth answering

Does it work in Safari?

Chrome and Edge 111+, Safari 18+. Firefox has it behind a flag. Everywhere else the feature check fails and the view changes instantly, which is the same thing the app did before you added it.

Does it need JavaScript?

Yes, and it is the point: the API is a JavaScript entry point into a CSS animation. What you do not write is the animation itself, the measuring, or any of the FLIP arithmetic this used to require.

Is it accessible?

Reduced motion. The transition is skipped entirely rather than shortened, and the view changes instantly. A morph is a large, fast movement across the viewport, which is exactly the class of motion the setting exists to remove.

Keyboard. The cards are buttons and the detail has a real back button, so the whole demo is operable by Tab and Enter. Focus does move when the view changes, which is worth handling deliberately in a real app: send it to the heading of the incoming view rather than letting it fall back to the body.

Want this kind of care on a build?

I build fast, production-grade sites for freelance clients. The polish is the same; there is just more of it.