Upgrade to Pro — share decks privately, control downloads, hide ads and more …

๐Ÿ‡ช๐Ÿ‡ธ React Alicante 2026

Sponsored · Ship Features Fearlessly Turn features on and off without deploys. Used by thousands of Ruby developers. →

๐Ÿ‡ช๐Ÿ‡ธ React Alicanteย 2026

Visual Continuity on the Web: My Journey With View Transitions

View transitions promise app-like fluidity on the web, but integrating them into real products isnโ€™t always just about a few API callsโ€”itโ€™s a journey.

In this talk, Iโ€™ll share my story of adopting view transitions at work. Weโ€™ll discuss how browsers implement them (e.g., how snapshots are taken, how animation pseudo-elements are orchestrated, and how promises let you hook into the lifecycle). Weโ€™ll also explore how frameworks like Astro and React integrate view transitions into both SPA and MPA workflows.

Last but not least, weโ€™ll discuss real-world patterns, gotchas, and lessons learned around edge cases, accessibility concerns, fallback strategies, and performance considerations.

Avatar for Matheus Albuquerque

Matheus Albuquerque PRO

September 25, 2026

More Decks by Matheus Albuquerque

Other Decks in Programming

Transcript

  1. Hola, Alicante! ๐Ÿ‘‹ ๐Ÿ‡ช๐Ÿ‡ธ VISUAL CONTINUITY ON THE WEB: MY

    JOURNEY WITH VIEW TRANSITIONS โ€ข SEPTEMBER 26, 2026.
  2. VISUAL CONTINUITY ON THE WEB: MY JOURNEY WITH VIEW TRANSITIONS

    MATHEUS ALBUQUERQUE โ€ข @ythecombinator
  3. Matheus Albuquerque โ† ๐Ÿ‘จ๐Ÿ’ป STAFF SWE @ MEDALLIA โ† โš›

    CHAIR @ REACT SUMMIT NYC โ† โšก GOOGLE DEVELOPER EXPERT โ† โœ YTHECOMBINATOR
  4. OUR CHALLENGES โ† SURVEY FATIGUE IS A WELL-DOCUMENTED PHENOMENON THAT

    LEADS TO ABANDONMENT AND INCOMPLETE RESPONSES. โ† LONG SURVEYS CAN SEE COMPLETION RATES DROP DRAMATICALLY AS THE NUMBER OF QUESTIONS AND PAGES INCREASES.
  5. #insight ๐Ÿ’ก What if we could make survey navigation feel

    less like page loads and more like a conversation?
  6. THE BASICS: ONE MODEL, TWO WORLDS SAME-DOCUMENT โ€ข SPA document.startViewTransition(async

    () CROSS-DOCUMENT โ€ข MPA { @view-transition { await updateTheCurrentDocument(); navigation: auto; > } = });
  7. THE BASICS: ONE MODEL, TWO WORLDS I) CAPTURE OLD VISUAL

    STATE. II) ESTABLISH THE NEW APPLICATION/DOCUMENT STATE. III) CAPTURE NEW VISUAL STATE. IV) BUILD THE SAME TRANSITION PSEUDO-ELEMENT TREE. V) ANIMATE WITH THE SAME CSS MODEL.
  8. SINGLE-PAGE APPLICATIONS โ† ONE DOCUMENT SURVIVES THE ENTIRE CHANGE. โ†

    YOUR APPLICATION OWNS THE DOM MUTATION. โ† startViewTransition CAPTURES THE OLD STATE, RUNS THE UPDATE, THEN CAPTURES THE NEW STATE. โ† RENDERING AND INPUT ARE SUPPRESSED DURING THE STATE CHANGE.
  9. MULTI-PAGE APPLICATIONS โ† THE OUTGOING AND INCOMING VIEWS BELONG TO

    DIFFERENT DOCUMENT OBJECTS. โ† THE BROWSER OWNS NAVIGATION AND STATE CAPTURE. ITโ€™S ALSO THE ONLY ACTOR THAT SEES BOTH SIDES. โ† BOTH DOCUMENTS OPT IN DECLARATIVELY. โ† JAVASCRIPT IS OPTIONAL FOR THE BASIC TRANSITION.
  10. Hunt esley ject L y b ro ed Creat he

    Noun P t m fro MULTI-PAGE APPLICATIONS โ€” CROSS-DOCUMENT VIEW TRANSITIONS FOR MULTI-PAGE APPLICATIONS โ€ข BRAMUS
  11. #win ๐Ÿ† No need to force an MPA to become

    an SPA just to get continuity.
  12. OUR CONTEXT: ARCHITECTURE RENDERING LAYER LOADING DEFINITIONS MANAGEMENT PLATFORM AUTHOR

    SURVEY DEFINITIONS GENERATE RESPONSE RECORDS DEFINITIONS โ‡ข SERVER-RENDERING HTML ANALYTICS & REPORTING INLINING DATA AS JSON
  13. OUR CONTEXT: ARCHITECTURE MANAGEMENT PLATFORM RENDERING LAYER AUTHOR SURVEY DEFINITIONS

    LOADING DEFINITIONS GENERATE RESPONSE RECORDS ANALYTICS & REPORTING DEFINITIONS โ‡ข SERVER-RENDERING HTML INLINING DATA AS JSON โ‡ฃ HTML SHELL + INLINED JSON โ‡ก POSTS ANSWERS CLIENT (BROWSER) SPA BUNDLE BOOTS FROM INLINED DATA RENDERS/CAPTURES ANSWERS
  14. OUR CONTEXT: ARCHITECTURE LOAD BALANCER MANAGEMENT PLATFORM RENDERING LAYER AUTHOR

    SURVEY DEFINITIONS LOADING DEFINITIONS DISTRIBUTED KV STORE DEFINITIONS โ‡† READ/WRITE โ‡† GENERATE RESPONSE RECORDS ANALYTICS & REPORTING DEFINITIONS โ‡ข SERVER-RENDERING HTML INLINING DATA AS JSON โ‡ฃ HTML SHELL + INLINED JSON โ‡ก POSTS ANSWERS CLIENT (BROWSER) RECORDS ENQUEUE โ‡ข MESSAGE BUS โ‡ฃ DEQUEUE ASYNC WORKERS SPA BUNDLE BOOTS FROM INLINED DATA RENDERS/CAPTURES ANSWERS โ‡  VERSIONED BUNDLE CDN/EDGE CACHE
  15. OUR CONTEXT: ARCHITECTURE LOAD BALANCER MANAGEMENT PLATFORM RENDERING LAYER AUTHOR

    SURVEY DEFINITIONS LOADING DEFINITIONS DISTRIBUTED KV STORE DEFINITIONS โ‡† READ/WRITE โ‡† GENERATE RESPONSE RECORDS ANALYTICS & REPORTING DEFINITIONS โ‡ข SERVER-RENDERING HTML INLINING DATA AS JSON โ‡ฃ HTML SHELL + INLINED JSON โ‡ก POSTS ANSWERS CLIENT (BROWSER) RECORDS ENQUEUE โ‡ข MESSAGE BUS โ‡ฃ DEQUEUE ASYNC WORKERS SPA BUNDLE BOOTS FROM INLINED DATA RENDERS/CAPTURES ANSWERS โ‡  VERSIONED BUNDLE CDN/EDGE CACHE
  16. INTENT โ‰  IDENTITY โ† AVOID ENCODING DIRECTION INTO EVERY viewtransition-name.

    โ† TRANSITION TYPES KEEP INTENT IN THE RIGHT PLACE: NAMES IDENTIFY OBJECTS, TYPES IDENTIFY THE TRANSITION MODE. โ† USEFUL FOR DIRECTION, ROUTE CATEGORY, OR GENERAL ACTION SEMANTICS.
  17. INTENT โ‰  IDENTITY :root:active-view-transition-type(forward) view-transition-old(root) { anim ation-name: slide-out-left; }

    :root:active-view-transition-type(forward) anim ation-name: slide-in-right; : : : : } view-transition-new(root) {
  18. VISUAL CONTINUITY ON THE WEB: MY JOURNEY WITH VIEW TRANSITIONS

    MPA #2 The First Frame Is the New Snapshot
  19. THE FIRST FRAME IS THE NEW SNAPSHOT โ† INCOMING CAPTURE

    HAPPENS BEFORE THE FIRST PRESENTED FRAME AND THE MATCHING ELEMENT MUST EXIST BY THEN. โ† ABOVE-THE-FOLD STRUCTURE IS PART OF A TRANSITION CONTRACT. โ† LATE CRITICAL MARKUP TURNS A NICE MORPH EFFECT INTO A WEIRD EXIT/ENTRY.
  20. #protip ๐Ÿ“ State does not automatically cross documents: an SPA

    gives us one callback in one world while an MPA gives us two hooks in two worlds.
  21. THINK ABOUT BOTH ENDS addEventListener("pagereveal", (event) { if (!event.viewTransition) return;

    const activation const type navigation.activation; activation.navigationType; if (type === "traverse") { event.viewTransition.types.add("history"); } else { event.viewTransition.types.add("forward"); } > = = = });
  22. THINK ABOUT BOTH ENDS addEventListener("pagereveal", (event) { if (!event.viewTransition) return;

    const activation = navigation.activation; const type = activation.navigationType; if (type === "traverse") { event.viewTransition.types.add("history"); } else { event.viewTransition.types.add("forward"); } > = });
  23. THINK ABOUT BOTH ENDS addEventListener("pagereveal", (event) { if (!event.viewTransition) return;

    const activation const type navigation.activation; activation.navigationType; if (type === "traverse") { event.viewTransition.types.add("history"); } else { event.viewTransition.types.add("forward"); } > = = = });
  24. ENCODING INTENT IN SESSION STORAGE document.addEventListener("click", (event) const link {

    event.target.closest("a[data-vt]"); if (!link) return; sessionStorage.setItem("vt:intent", JSON.stringify({ "list-to-detail" type: link.dataset.vt, from: location.href, to: link.href, ts: Date.now() })); > = = / / });
  25. ENCODING INTENT IN SESSION STORAGE document.addEventListener("click", (event) const link {

    event.target.closest("a[data-vt]"); if (!link) return; sessionStorage.setItem("vt:intent", JSON.stringify({ "list-to-detail" type: link.dataset.vt, from: location.href, to: link.href, ts: Date.now() })); > = = / / });
  26. ENCODING INTENT IN SESSION STORAGE addEventListener("pagereveal", (event) { if (!event.viewTransition)

    return; const raw sessionStorage.getItem("vt:intent"); sessionStorage.removeItem("vt:intent"); if (!raw) return; const intent JSON.parse(raw); const fresh Date.now() - intent.ts < 3000; const target new URL(intent.to).href === location.href; if (fresh target) { event.viewTransition.types.add(intent.type); } > = = = = & = & });
  27. ENCODING INTENT IN SESSION STORAGE addEventListener("pagereveal", (event) { if (!event.viewTransition)

    return; const raw sessionStorage.getItem("vt:intent"); sessionStorage.removeItem("vt:intent"); if (!raw) return; const intent JSON.parse(raw); const fresh Date.now() - intent.ts < 3000; const target new URL(intent.to).href === location.href; if (fresh target) { event.viewTransition.types.add(intent.type); } > = = = = & = & });
  28. ENCODING INTENT IN SESSION STORAGE addEventListener("pagereveal", (event) { if (!event.viewTransition)

    return; const raw = sessionStorage.getItem("vt:intent"); sessionStorage.removeItem("vt:intent"); if (!raw) return; const intent JSON.parse(raw); const fresh Date.now() - intent.ts < 3000; const target new URL(intent.to).href === location.href; if (fresh target) { event.viewTransition.types.add(intent.type); } > = = = = & & });
  29. ENCODING INTENT IN SESSION STORAGE addEventListener("pagereveal", (event) { if (!event.viewTransition)

    return; const raw sessionStorage.getItem("vt:intent"); sessionStorage.removeItem("vt:intent"); if (!raw) return; const intent = JSON.parse(raw); const fresh = Date.now() - intent.ts < 3000; const target = new URL(intent.to).href === location.href; if (fresh target) { event.viewTransition.types.add(intent.type); } > = & & = });
  30. ENCODING INTENT IN SESSION STORAGE IT SHOULD NOT APPLY IF:

    โ† THE USER OPENS THE LINK IN A NEW TAB. โ† THE USER COPIES THE URL AND SENDS IT TO SOMEONE. โ† THE USER RE-VISITS THE PAGE LATER. โ† ANOTHER TAB IS ALREADY OPEN ON THE DESTINATION PAGE.
  31. ENCODING INTENT IN SEARCH PARAMS addEventListener("pagereveal", (event) { if (!event.viewTransition)

    return; const url new URL(location.href); const intent url.searchParams.get("vt"); const direction url.searchParams.get("dir"); if (intent) event.viewTransition.types.add(intent); if (direction) event.viewTransition.types.add(direction); url.searchParams.delete("vt"); url.searchParams.delete("dir"); history.replaceState(history.state, "", url); > = = = = });
  32. ENCODING INTENT IN SEARCH PARAMS addEventListener("pagereveal", (event) { if (!event.viewTransition)

    return; const url new URL(location.href); const intent url.searchParams.get("vt"); const direction url.searchParams.get("dir"); if (intent) event.viewTransition.types.add(intent); if (direction) event.viewTransition.types.add(direction); url.searchParams.delete("vt"); url.searchParams.delete("dir"); history.replaceState(history.state, "", url); > = = = = });
  33. ENCODING INTENT IN SEARCH PARAMS addEventListener("pagereveal", (event) { if (!event.viewTransition)

    return; const url = new URL(location.href); const intent = url.searchParams.get("vt"); const direction = url.searchParams.get("dir"); if (intent) event.viewTransition.types.add(intent); if (direction) event.viewTransition.types.add(direction); url.searchParams.delete("vt"); url.searchParams.delete("dir"); history.replaceState(history.state, "", url); > = });
  34. ENCODING INTENT IN SEARCH PARAMS addEventListener("pagereveal", (event) { if (!event.viewTransition)

    return; const url new URL(location.href); const intent url.searchParams.get("vt"); const direction url.searchParams.get("dir"); if (intent) event.viewTransition.types.add(intent); if (direction) event.viewTransition.types.add(direction); url.searchParams.delete("vt"); url.searchParams.delete("dir"); history.replaceState(history.state, "", url); > = = = = });
  35. ENCODING INTENT IN SEARCH PARAMS DOWNSIDES: โ† DURABLE AND VISIBLE:

    POLLUTE COPIED URLS. โ† CAN LEAK UI METADATA. โ† MAY AFFECT ANALYTICS AND CACHING. โ† REQUIRES CLEANUP.
  36. FOLLOW USER PREFERENCES if (matchMedia("(prefers-reduced-motion: reduce)").matches){ SPA updateDOM(); } else

    { document.startViewTransition(updateDOM); } @media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } } MPA
  37. CAPTURE-TIME GLOBAL NAMESPACE โ† EVERY ACTIVE view-transition-name MUST BE UNIQUE

    IN THE CAPTURED STATE. โ† DUPLICATE NAMES CAN PREVENT THE VISUAL TRANSITION FROM BEING CREATED. โ† CROSS-DOCUMENT MATCHING REQUIRES STABLE NAMES IN BOTH DOCUMENTS.
  38. CAPTURE-TIME GLOBAL NAMESPACE async function openProduct(id) { const card document.querySelector(`[data-product="${id}"]`);

    card.style.viewTransitionName const vt `product-${id}`; document.startViewTransition(() try { await vt.finished; } finally { card.style.viewTransitionName ""; } > = = = = = } renderDetail(id));
  39. CAPTURE-TIME GLOBAL NAMESPACE async function openProduct(id) { const card document.querySelector(`[data-product="${id}"]`);

    card.style.viewTransitionName const vt `product-${id}`; document.startViewTransition(() try { await vt.finished; } finally { card.style.viewTransitionName = ""; } > = = = = } renderDetail(id));
  40. TRANSITIONS IN REACT: OVERVIEW โ† REACTโ€™S IMPLEMENTATION IS THE DEEPEST

    ONE BECAUSE IT SITS INSIDE FIBER ITSELF. โ† THE OLD HOST TREE IS INSPECTED IN THE PRE-MUTATION PHASE. DOM MUTATIONS RUN INSIDE THE VIEW TRANSITION UPDATE CALLBACK. โ† LAYOUT/AFTER-MUTATION/PASSIVE WORK IS SCHEDULED AROUND THAT SPLIT.
  41. TRANSITIONS IN REACT: RECONCILIATION โ† FIBER SEES THE OPERATION THAT

    CREATED THE VISUAL EVENT. REACT CAN THEN CLASSIFY IT BECAUSE IT IS ALREADY CLASSIFYING THE TREE OPERATION. โ† <ViewTransition> IS NOT SYNTACTIC SUGAR OVER viewtransition-name. IT CAN MAP RENDER SEMANTICS TO ANIMATION SEMANTICS.
  42. VISUAL CONTINUITY ON THE WEB #1 Visual continuity is a

    budget. โ€ฆAND A GOOD TRANSITION IS THE SMALLEST ONE THAT PRESERVES CONTEXT.
  43. VISUAL CONTINUITY ON THE WEB The API is small #2

    because you know better about your product.
  44. VISUAL CONTINUITY ON THE WEB Reducing friction #3 and preserving

    context can help users stay engaged longer.
  45. VISUAL CONTINUITY ON THE WEB THATโ€™S ALL, FOLKS! GRACIAS! ๐Ÿ‘‹

    ๐Ÿ‡ช๐Ÿ‡ธ QUESTIONS? MATHEUS ALBUQUERQUE โ€ข @ythecombinator