View Transitions skill for Codex

Native page morphs that degrade cleanly. Codex adds native page and state transitions using the View Transitions API, choosing same-document or cross-document mode from the site's architecture. Shared elements get unique names assigned at the right moment, data loads before the snapshot so input never freezes, and every transition is feature-detected with a reduced-motion path, so browsers without support still navigate normally.

  • Frontend
  • $view-transitions
  • 161 lines
  • 2 files
  • ~1,750 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- view-transitions

Installs to ~/.agents/skills/view-transitions/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $view-transitions, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Adds View Transitions API animations to single-page apps (document.startViewTransition) and multi-page sites (@view-transition navigation auto), with view-transition-name shared elements, view-transition-class, transition types, framework router integration and reduced-motion fallbacks. Use when a user wants smooth page or route transitions, a thumbnail that morphs into a hero image across pages, cross-document transitions on a static or server-rendered site, or when a view transition flickers, skips or throws InvalidStateError.

Use it when

  • Route or page changes should animate without a heavy animation library
  • An element should visually carry over from one view to the next
  • An existing view transition flickers, skips or blocks input

Use something else when

How to check the result

Scrub the ::view-transition pseudo-element tree in the DevTools Animations panel, navigate rapidly to check for duplicate-name errors, and confirm unsupported browsers navigate with no errors.

The mistake it prevents

Two elements sharing one view-transition-name at capture time, which aborts the whole transition; assign the name only to the clicked item.

~/.agents/skills/view-transitions/SKILL.md

--- name: view-transitions description: Adds View Transitions API animations to single-page apps (document.startViewTransition) and multi-page sites (@view-transition navigation auto), with… license: MIT ---

Done well means: navigation still works instantly where the API is missing, every shared view-transition-name is unique at capture time, transitions finish in under ~400 ms, input is never blocked by a slow update, and reduced-motion users get a cross-fade or nothing.

Support model (late 2026)

FeatureChromiumSafariFirefox
Same-document (document.startViewTransition, view-transition-name)Yes18+144+
view-transition-class, :active-view-transitionYesYes144+
Cross-document (@view-transition { navigation: auto })126+18.2+Not yet
Transition types (types option, :active-view-transition-type())YesYesCheck before relying on it

Treat the API as progressive enhancement: feature-detect, and make the non-animated path the correct one. Re-check MDN or caniuse when exact versions matter; this table ages.

Pick the mode

SiteMode
MPA, server-rendered pages, Astro without client router, static siteCross-document: CSS opt-in on both pages
SPA with a client router (React Router, Vue Router, SvelteKit, TanStack)Same-document: wrap the DOM update in startViewTransition
Next.js App RouterSame-document via the router’s experimental support, or a small wrapper around router.push; check the installed version’s docs
Astro with <ClientRouter />Built in; use transition:name / transition:animate
One component changing state (list filter, tab panel)Same-document on that update

Workflow

- [ ] 1. Read AGENTS.md; identify router, framework version, and browser support policy
- [ ] 2. Choose mode (table above); add feature detection
- [ ] 3. Get the default cross-fade working end to end first
- [ ] 4. Add named shared elements one at a time
- [ ] 5. Customize animations with ::view-transition-* pseudo-elements or classes
- [ ] 6. Add reduced-motion and slow-update handling
- [ ] 7. Verify in DevTools Animations panel and in a browser without support

Same-document (SPA)

export function withTransition(update: () => void | Promise<void>, types: string[] = []) {
  if (!document.startViewTransition ||
      matchMedia("(prefers-reduced-motion: reduce)").matches) {
    return void update();
  }
  const vt = document.startViewTransition({ update, types });
  vt.finished.catch(() => {}); // skipped transitions reject; not an app error
  return vt;
}
  • The update callback must change the DOM synchronously or return a promise that resolves once it has. With React, wrap the state change in flushSync inside the callback so the new DOM exists when the browser captures it.
  • While the update runs, the page is frozen on the old snapshot and does not accept input. Fetch data before calling startViewTransition, then do only the DOM swap inside.
  • Older Safari versions accept only the function form, not { update, types }; if supporting them, feature-detect types or pass the function.

React Router (v6.4+ data routers and v7) accepts viewTransition on <Link>/<NavLink> and navigate(to, { viewTransition: true }), and exposes useViewTransitionState(to) to set names only on the item being navigated. Prefer that over hand-wrapping.

Cross-document (MPA)

Both the old and new page must opt in, and they must be same-origin.

@view-transition { navigation: auto; }

@media (prefers-reduced-motion: reduce) {
  @view-transition { navigation: none; }
}
  • The new page renders when it is ready to paint; large render-blocking resources delay the transition. Keep the critical CSS small. <link rel="expect" href="#hero" blocking="render"> can hold first render until a specific element is parsed; use sparingly.
  • pageswap and pagereveal events let you set names or types per navigation (for example, only name the clicked card): read event.viewTransition and navigation.activation there.
  • Reloads and cross-origin navigations never animate.

Shared elements

.product-card img { view-transition-name: var(--vt-name, none); }
.product-hero img { view-transition-name: product-image; }

::view-transition-group(product-image) {
  animation-duration: 320ms;
  animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}

Set the name on the clicked card only, just before the transition (card.style.setProperty("--vt-name", "product-image")), or in pageswap for MPAs.

Rules:

  • A name must be unique in the document at capture time. Two elements with the same name abort the transition (InvalidStateError, or a console warning and skip).
  • For many elements that need their own group (list reorder), use view-transition-name: match-element where supported, or generate names from IDs (view-transition-name: item-42), and share styling through view-transition-class: card.
  • Elements are captured as images; text inside a morphing element scales as a bitmap. Give headings their own name so they cross-fade instead of stretching, or keep aspect ratios equal.
  • Names must be valid CSS idents: no leading digit, no spaces; escape or prefix generated IDs.

Customizing the animation

::view-transition-old(root) { animation: 180ms ease-in both fade-out; }
::view-transition-new(root) { animation: 260ms ease-out both slide-in; }
@keyframes fade-out { to { opacity: 0; } }
@keyframes slide-in { from { opacity: 0; translate: 24px 0; } }

/* Direction-aware with types */
html:active-view-transition-type(back) ::view-transition-new(root) {
  animation-name: slide-in-from-left;
}

Keep the header and other persistent chrome stable by naming it (view-transition-name: site-header) so it is not part of the root cross-fade.

Gotchas

  • startViewTransition while another is running skips the first; rapid clicks are fine but finished rejects. Always catch it.
  • Elements with view-transition-name become stacking contexts and isolated groups during the transition; position: fixed overlays may appear above or below unexpectedly.
  • Scroll position changes during an SPA transition; restore scroll inside the update callback, not after, or the snapshot jumps.
  • Focus is not moved for you. After an SPA route change, move focus to the new page’s h1 or main region and update document.title.
  • Long transitions block input; keep total time under ~400 ms.

Verification

  • DevTools Animations panel: pause and scrub the ::view-transition tree; check each named group pairs old with new.
  • Console shows no duplicate-name errors while navigating rapidly.
  • Firefox (MPA mode) and any unsupported browser: navigation works with no errors.
  • Reduced motion emulated: no movement, navigation unchanged.
  • Keyboard navigation: focus lands on the new content after each route change.
  • Throttle the CPU 4x; transition still completes and the page stays responsive.

Output

Report the mode chosen and why, named elements and where names are assigned, custom animations and durations, types used, and the fallback behavior per browser.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "View Transitions"
  short_description: "Native page morphs that degrade cleanly"
  default_prompt: "$view-transitions Add smooth page transitions to our multi-page site with no JavaScript framework"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc