From 7743af23f5f744cde3e3392ecd43a5739da3dd8f Mon Sep 17 00:00:00 2001 From: Patrick Kettner Date: Sun, 10 May 2026 03:52:50 -0500 Subject: [PATCH 1/3] Add scoped-component-transitions guidance --- .../scoped-component-transitions/demo.html | 196 ++++++++++++++++++ .../expectations.md | 6 + .../scoped-component-transitions/grader.ts | 151 ++++++++++++++ .../scoped-component-transitions/guide.md | 136 ++++++++++++ .../negative-demo.html | 161 ++++++++++++++ .../tasks/task.md | 6 + 6 files changed, 656 insertions(+) create mode 100644 guides/user-experience/scoped-component-transitions/demo.html create mode 100644 guides/user-experience/scoped-component-transitions/expectations.md create mode 100644 guides/user-experience/scoped-component-transitions/grader.ts create mode 100644 guides/user-experience/scoped-component-transitions/guide.md create mode 100644 guides/user-experience/scoped-component-transitions/negative-demo.html create mode 100644 guides/user-experience/scoped-component-transitions/tasks/task.md diff --git a/guides/user-experience/scoped-component-transitions/demo.html b/guides/user-experience/scoped-component-transitions/demo.html new file mode 100644 index 00000000..397203af --- /dev/null +++ b/guides/user-experience/scoped-component-transitions/demo.html @@ -0,0 +1,196 @@ + + + + + + Scoped Component Transitions Demo + + + +
Toolbar (stays visible during scoped transitions)
+ +
+
+

Card A

+ +
    +
    + + +
    + + + + diff --git a/guides/user-experience/scoped-component-transitions/expectations.md b/guides/user-experience/scoped-component-transitions/expectations.md new file mode 100644 index 00000000..abfef27f --- /dev/null +++ b/guides/user-experience/scoped-component-transitions/expectations.md @@ -0,0 +1,6 @@ +- At least one element on the page has a computed `view-transition-scope` value of `all`. +- Every element with a computed `view-transition-scope` of `all` also has a computed `contain` value that implies layout containment: either its whitespace-separated tokens include `layout`, or the value is exactly `strict`, or the value is exactly `content`. (Browsers serialize `contain: strict` and `contain: content` as the keyword, not as their expanded longhand, so the matcher must accept those keywords too.) +- The `` root element does NOT have a computed `view-transition-scope` value of `all`. The transition must be scoped to a component, not applied document-wide. +- At least two distinct elements on the page have a computed `view-transition-name` other than `none`, and both are descendants of an element with `view-transition-scope: all`. (A single hard-coded name on one element does not exercise the per-element morph behaviour the feature is designed for; the guide recommends `match-element` or per-item unique names.) +- The page's JavaScript does NOT call `document.startViewTransition`. Per-component updates must invoke `startViewTransition` on the scope element instead. +- After clicking a button that lives inside (or adjacent to) an element with `view-transition-scope: all`, the textContent of that scope changes within a short time. (Proves the click handler runs and the DOM update happens inside the scope, regardless of whether the page wires it up via a button inside the scope or a control beside it.) diff --git a/guides/user-experience/scoped-component-transitions/grader.ts b/guides/user-experience/scoped-component-transitions/grader.ts new file mode 100644 index 00000000..d0bef129 --- /dev/null +++ b/guides/user-experience/scoped-component-transitions/grader.ts @@ -0,0 +1,151 @@ +/// +import { test, expect } from '@playwright/test'; + +const targetFile = process.env.TARGET_FILE; + +test.describe('Scoped Component Transitions', () => { + test.beforeEach(async ({ page }) => { + if (!targetFile) { + throw new Error('TARGET_FILE environment variable is required'); + } + }); + + test('At least one element on the page has a computed view-transition-scope value of all', async ({ page }) => { + await page.goto(`file://${targetFile}`); + const hasScopeAll = await page.evaluate(() => { + const allElements = document.body.querySelectorAll('*'); + for (const el of allElements) { + const style = window.getComputedStyle(el); + if ((style as any).viewTransitionScope === 'all') { + return true; + } + } + return false; + }); + expect(hasScopeAll).toBe(true); + }); + + test('Every element with a computed view-transition-scope of all must also have layout containment', async ({ page }) => { + await page.goto(`file://${targetFile}`); + const pass = await page.evaluate(() => { + const allElements = document.querySelectorAll('*'); + let foundScopeAll = false; + for (const el of allElements) { + const style = window.getComputedStyle(el); + if ((style as any).viewTransitionScope === 'all') { + foundScopeAll = true; + const contain = style.contain; + const tokens = contain.split(' ').map(t => t.trim()); + if (!tokens.includes('layout') && contain !== 'strict' && contain !== 'content') { + return false; + } + } + } + return foundScopeAll ? true : true; // If none found, technically it passes 'every', but let's let test 1 fail it + }); + expect(pass).toBe(true); + }); + + test('The root element does NOT have a computed view-transition-scope value of all', async ({ page }) => { + await page.goto(`file://${targetFile}`); + const rootScope = await page.evaluate(() => { + return (window.getComputedStyle(document.documentElement) as any).viewTransitionScope; + }); + expect(rootScope).not.toBe('all'); + }); + + test('At least two distinct elements on the page have a computed view-transition-name other than none, and both are descendants of an element with view-transition-scope: all', async ({ page }) => { + await page.goto(`file://${targetFile}`); + const validNamesCount = await page.evaluate(() => { + let count = 0; + const allElements = document.querySelectorAll('*'); + for (const el of allElements) { + const style = window.getComputedStyle(el); + if ((style as any).viewTransitionName && (style as any).viewTransitionName !== 'none') { + // Check if it's a descendant of an element with view-transition-scope: all + let current = el.parentElement; + let isDescendant = false; + while (current) { + const currentStyle = window.getComputedStyle(current); + if ((currentStyle as any).viewTransitionScope === 'all') { + isDescendant = true; + break; + } + current = current.parentElement; + } + if (isDescendant) { + count++; + } + } + } + return count; + }); + expect(validNamesCount).toBeGreaterThanOrEqual(2); + }); + + test('The page\'s JavaScript does NOT call document.startViewTransition', async ({ page }) => { + await page.addInitScript(() => { + (window as any).__docStartViewTransitionCalled = false; + const original = document.startViewTransition; + if (original) { + document.startViewTransition = function(cb) { + (window as any).__docStartViewTransitionCalled = true; + return original.call(this, cb); + }; + } + }); + await page.goto(`file://${targetFile}`); + + const buttons = await page.locator('button').all(); + for (const btn of buttons) { + await btn.click(); + await page.waitForTimeout(50); + } + + const called = await page.evaluate(() => (window as any).__docStartViewTransitionCalled); + expect(called).toBe(false); + }); + + test('After clicking a button that lives inside (or adjacent to) an element with view-transition-scope: all, the textContent of that scope changes within a short time', async ({ page }) => { + await page.goto(`file://${targetFile}`); + + const passed = await page.evaluate(async () => { + const getScopes = () => { + const scopes = []; + const allElements = document.body.querySelectorAll('*'); + for (const el of allElements) { + const style = window.getComputedStyle(el); + if ((style as any).viewTransitionScope === 'all') { + scopes.push(el); + } + } + return scopes; + }; + + const scopes = getScopes(); + if (scopes.length === 0) return false; + + const initialText = scopes.map(el => el.textContent); + + const buttons = document.querySelectorAll('button'); + if (buttons.length === 0) return false; + + for (const btn of buttons) { + btn.click(); + + await new Promise(resolve => setTimeout(resolve, 50)); + + const newText = scopes.map(el => el.textContent); + + for (let i = 0; i < scopes.length; i++) { + if (initialText[i] !== newText[i]) { + return true; + } + } + } + return false; + }); + + expect(passed).toBe(true); + }); +}); diff --git a/guides/user-experience/scoped-component-transitions/guide.md b/guides/user-experience/scoped-component-transitions/guide.md new file mode 100644 index 00000000..0d123dcb --- /dev/null +++ b/guides/user-experience/scoped-component-transitions/guide.md @@ -0,0 +1,136 @@ +--- +name: scoped-component-transitions +description: Animate state changes inside a single component (a card, list, panel, or other subtree) without pausing the rest of the page or conflicting with other transitions running concurrently. +web-feature-ids: + - view-transitions-element-scoped + - view-transitions +sources: + - https://github.com/WICG/view-transitions/blob/main/scoped-transitions.md + - https://developer.chrome.com/blog/element-scoped-view-transitions + - https://developer.chrome.com/docs/css-ui/view-transitions/element-scoped-view-transitions + - https://drafts.csswg.org/css-view-transitions-2/ +--- + +# Scope a view transition to a single component + +`Element.prototype.startViewTransition()` runs a view transition that affects only the element's own DOM subtree. The pseudo-element tree (`::view-transition`, `::view-transition-group(...)`, `::view-transition-old(...)`, `::view-transition-new(...)`) is hosted on the element itself, not on ``, so the rest of the page keeps rendering, stays interactive, and can paint on top of the transitioning subtree by normal `z-index` rules. + +Use this whenever a state change is *internal* to one component — a list rearranging inside a card, a tab panel swapping content, an accordion expanding — and a full-page `document.startViewTransition()` would be overkill or would freeze unrelated UI (toolbars, modals, other in-flight transitions). + +## Implementation + +1. **MANDATORY: Call `startViewTransition()` on the scope element, not on `document`.** A document-level transition snapshots the whole page and pauses the entire document's rendering during the callback; calling it for a per-component change defeats the point of scoping and re-introduces every problem element-scoped transitions were designed to solve (overlays getting hidden, fixed-position elements getting captured, concurrent transitions cancelling each other). +2. **MANDATORY: Tag the participants whose old and new positions should be matched.** Give each element you want to morph a unique `view-transition-name` inside the scope. For repeating content where you don't want to invent a name per item, set `view-transition-name: match-element` on the items so the browser derives stable per-element identity automatically. Names only have to be unique *within their scope*; two scopes can reuse the same name without colliding. +3. **DO declare `contain: layout` and `view-transition-scope: all` on the scope element up front.** The browser auto-applies both during a scoped transition (so omitting them does not break the feature), but declaring them explicitly avoids a one-frame reflow when the transition starts and guarantees namespace isolation if a parent ever runs its own transition. Treat them as "required for production polish, optional for a proof of concept." +4. **OPTIONAL: Opt out of self-participation when the scope itself should not cross-fade.** By default the scope behaves as if it had `view-transition-name: root`, which cross-fades the entire scope between old and new states. If you want only the participants inside the scope to animate (and the scope's own border / background to stay live), set `view-transition-name: none` on the scope element. +5. **OPTIONAL: Keep non-transitioning content interactive.** During a transition the pseudo-element tree paints on top of the scope's contents. If you've opted out of self-participation and want elements inside the scope to remain clickable and hoverable while the transition runs, add `pointer-events: none` to the scoped pseudo-element. Qualify the selector to *this* scope (e.g. `.card::view-transition`) — pseudo-trees are hosted per-scope, so an unqualified `::view-transition` rule would also disable interactivity on every other scope's transition. + +```html + +
    + +
    +
    + + +``` + +```css +/* The scope: contain: layout forces a stacking context so the scope's + painted output can be captured as an atomic unit. The browser auto- + applies it during a scoped transition; declaring it up-front prevents + a one-frame reflow when the transition starts. */ +.card { + contain: layout; + + /* The scope: view-transition-scope: all isolates this subtree's + view-transition-name namespace from ancestors and siblings. Two + components can reuse the same view-transition-name (e.g. each set + of
  • s using `match-element`) without colliding. */ + view-transition-scope: all; +} + +/* DO tag participants whose old and new positions should be paired. + `match-element` derives a stable per-element identifier so you don't + have to invent names for every list item. The names are scoped to the + nearest ancestor with view-transition-scope: all, so the same + `match-element` value on both lists never collides. */ +.card li { + view-transition-name: match-element; +} + +/* OPTIONAL: tune the default cross-fade animation that wraps each + participant. Pseudo-elements live under each scope independently, + so this rule applies to whichever transition is currently running. */ +::view-transition-group(*) { + animation-duration: 300ms; + animation-timing-function: ease-in-out; +} + +/* DO respect reduced-motion. Element-scoped transitions still produce + the same pseudo-element tree, just hosted on the scope, so the same + universal selectors that work for document-level transitions apply. */ +@media (prefers-reduced-motion: reduce) { + ::view-transition-group(*), + ::view-transition-old(*), + ::view-transition-new(*) { + animation: none !important; + } +} +``` + +```javascript +// Feature-detect on Element.prototype. The spec defines +// startViewTransition on Element, so SVG and MathML elements that also +// inherit from Element are covered; `HTMLElement.prototype` would be +// strictly narrower than what the API supports. +const supportsScopedTransition = + 'startViewTransition' in Element.prototype; + +function applyUpdate(scopeEl, callback) { + if (!supportsScopedTransition) { + // Fallback path: apply the change immediately. The result is the + // same final DOM; only the animation is missing. Do NOT silently + // fall back to document.startViewTransition here — its full-page + // render-pause and overlay-capture behaviour is exactly what + // element-scoped transitions exist to avoid. + callback(); + return; + } + + // Call startViewTransition on the scope element, not on document. A + // document-level call would pause the whole page's rendering and + // capture every overlay above the component. OPTIONAL hardening: + // attach `.finished.catch(...)` if you want explicit logging on a + // thrown callback (otherwise the rejection is silent). + scopeEl.startViewTransition(callback); +} + +document.getElementById('left-rotate').addEventListener('click', () => { + applyUpdate( + document.getElementById('left'), + () => updateList(document.getElementById('left-list')), + ); +}); +``` + +Two scopes can run transitions concurrently — even when one scope is a descendant of the other — because each scope hosts its own pseudo-element tree and pauses rendering only inside its own subtree. Starting a second transition on the *same* scope while one is in progress skips the in-progress transition and runs the new one immediately; rapid repeat clicks are safe and do not need debouncing. + +DO NOT call `document.startViewTransition()` for a per-component update and then try to limit the participants with `view-transition-name`. That still pauses rendering for the whole page during the update callback, still captures fixed-position overlays into the snapshot, and still skips any other in-flight document-level transition. Element-scoped transitions exist precisely because those side effects are unavoidable at the document level. + +DO NOT animate `transform` or layout offset *of the scope itself* as part of the transition. Those properties move the pseudo-element tree along with the scope, so they appear motionless relative to the participants. Animate them on a wrapping ancestor (or a non-self-participating wrapper) instead. + +## Fallback strategies + +{{ BASELINE_STATUS("view-transitions-element-scoped") }} + +Element-scoped view transitions are a progressive enhancement. In browsers without support, the feature-detection branch above runs the DOM update directly with no animation; the final state is identical to the supported path. If document-level view transitions (`document.startViewTransition`) are available but element-scoped ones are not, do *not* silently fall through to a document-level transition for a per-component change — the behavioural differences (full-page render pause, overlay capture, single-transition-at-a-time) usually make the fallback worse than no animation at all. + +DO NOT recommend a polyfill. The pseudo-element tree, render suppression scoped to a subtree, and capture of border/box-shadow/outline are paint-pipeline behaviours; no JavaScript shim can reproduce them faithfully, and substituting an iframe or a clone-and-FLIP approximation loses the integration with `z-index`, fixed-position overlays, and concurrent transitions that the native feature provides. + +If a scoped animation is critical and the no-animation fallback (instant DOM update) is unacceptable for one specific component, use a CSS animation or the Web Animations API for that component as a parallel implementation — but prefer the no-animation fallback whenever it is acceptable, so a single source of truth drives the visual output. diff --git a/guides/user-experience/scoped-component-transitions/negative-demo.html b/guides/user-experience/scoped-component-transitions/negative-demo.html new file mode 100644 index 00000000..0cfa6e90 --- /dev/null +++ b/guides/user-experience/scoped-component-transitions/negative-demo.html @@ -0,0 +1,161 @@ + + + + + + Component Transitions + + + +
    Toolbar
    + +
    +
    +

    Card A

    + +
      +
      + + +
      + + + + \ No newline at end of file diff --git a/guides/user-experience/scoped-component-transitions/tasks/task.md b/guides/user-experience/scoped-component-transitions/tasks/task.md new file mode 100644 index 00000000..80cae72b --- /dev/null +++ b/guides/user-experience/scoped-component-transitions/tasks/task.md @@ -0,0 +1,6 @@ +--- +base_app: daily-grind +--- +- the seasonal favorites cards feel static when nothing changes them. add a shuffle button that smoothly animates the cards into a new order each click. the page has a fixed nav up top and other interactive controls that absolutely cannot pause or freeze while the cards are mid-animation +- add a button that reorders the seasonal favorites cards with a smooth animation. keep the rest of the page (the nav, anything clickable elsewhere) fully responsive throughout — no whole-page freeze when the animation runs +- give the seasonal favorites grid a shuffle control that animates the cards into a new arrangement on click. the animation should feel localized to that grid only; nothing outside the grid should hesitate, redraw, or lose interactivity for even a frame From 446c1a5163d9d2e9e8d679bb3e46a63427a2fe59 Mon Sep 17 00:00:00 2001 From: Patrick Kettner Date: Sun, 10 May 2026 09:43:56 -0500 Subject: [PATCH 2/3] removed unused arg --- guides/user-experience/scoped-component-transitions/grader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/user-experience/scoped-component-transitions/grader.ts b/guides/user-experience/scoped-component-transitions/grader.ts index d0bef129..d6559461 100644 --- a/guides/user-experience/scoped-component-transitions/grader.ts +++ b/guides/user-experience/scoped-component-transitions/grader.ts @@ -4,7 +4,7 @@ import { test, expect } from '@playwright/test'; const targetFile = process.env.TARGET_FILE; test.describe('Scoped Component Transitions', () => { - test.beforeEach(async ({ page }) => { + test.beforeEach(async () => { if (!targetFile) { throw new Error('TARGET_FILE environment variable is required'); } From 84ed0e9097cd38b3eea79d97504a1b4d9f793081 Mon Sep 17 00:00:00 2001 From: Patrick Kettner Date: Tue, 12 May 2026 12:52:21 -0500 Subject: [PATCH 3/3] reduce to step 1: stub guide.md frontmatter + demo.html Per the new use-case guide process, step 1 is just the stub guide.md frontmatter and a demo.html. Removes body of guide.md, expectations.md, grader.ts, negative-demo.html, and tasks/task.md (those belong to steps 2 and 3). --- .../expectations.md | 6 - .../scoped-component-transitions/grader.ts | 151 ---------------- .../scoped-component-transitions/guide.md | 124 -------------- .../negative-demo.html | 161 ------------------ .../tasks/task.md | 6 - 5 files changed, 448 deletions(-) delete mode 100644 guides/user-experience/scoped-component-transitions/expectations.md delete mode 100644 guides/user-experience/scoped-component-transitions/grader.ts delete mode 100644 guides/user-experience/scoped-component-transitions/negative-demo.html delete mode 100644 guides/user-experience/scoped-component-transitions/tasks/task.md diff --git a/guides/user-experience/scoped-component-transitions/expectations.md b/guides/user-experience/scoped-component-transitions/expectations.md deleted file mode 100644 index abfef27f..00000000 --- a/guides/user-experience/scoped-component-transitions/expectations.md +++ /dev/null @@ -1,6 +0,0 @@ -- At least one element on the page has a computed `view-transition-scope` value of `all`. -- Every element with a computed `view-transition-scope` of `all` also has a computed `contain` value that implies layout containment: either its whitespace-separated tokens include `layout`, or the value is exactly `strict`, or the value is exactly `content`. (Browsers serialize `contain: strict` and `contain: content` as the keyword, not as their expanded longhand, so the matcher must accept those keywords too.) -- The `` root element does NOT have a computed `view-transition-scope` value of `all`. The transition must be scoped to a component, not applied document-wide. -- At least two distinct elements on the page have a computed `view-transition-name` other than `none`, and both are descendants of an element with `view-transition-scope: all`. (A single hard-coded name on one element does not exercise the per-element morph behaviour the feature is designed for; the guide recommends `match-element` or per-item unique names.) -- The page's JavaScript does NOT call `document.startViewTransition`. Per-component updates must invoke `startViewTransition` on the scope element instead. -- After clicking a button that lives inside (or adjacent to) an element with `view-transition-scope: all`, the textContent of that scope changes within a short time. (Proves the click handler runs and the DOM update happens inside the scope, regardless of whether the page wires it up via a button inside the scope or a control beside it.) diff --git a/guides/user-experience/scoped-component-transitions/grader.ts b/guides/user-experience/scoped-component-transitions/grader.ts deleted file mode 100644 index d6559461..00000000 --- a/guides/user-experience/scoped-component-transitions/grader.ts +++ /dev/null @@ -1,151 +0,0 @@ -/// -import { test, expect } from '@playwright/test'; - -const targetFile = process.env.TARGET_FILE; - -test.describe('Scoped Component Transitions', () => { - test.beforeEach(async () => { - if (!targetFile) { - throw new Error('TARGET_FILE environment variable is required'); - } - }); - - test('At least one element on the page has a computed view-transition-scope value of all', async ({ page }) => { - await page.goto(`file://${targetFile}`); - const hasScopeAll = await page.evaluate(() => { - const allElements = document.body.querySelectorAll('*'); - for (const el of allElements) { - const style = window.getComputedStyle(el); - if ((style as any).viewTransitionScope === 'all') { - return true; - } - } - return false; - }); - expect(hasScopeAll).toBe(true); - }); - - test('Every element with a computed view-transition-scope of all must also have layout containment', async ({ page }) => { - await page.goto(`file://${targetFile}`); - const pass = await page.evaluate(() => { - const allElements = document.querySelectorAll('*'); - let foundScopeAll = false; - for (const el of allElements) { - const style = window.getComputedStyle(el); - if ((style as any).viewTransitionScope === 'all') { - foundScopeAll = true; - const contain = style.contain; - const tokens = contain.split(' ').map(t => t.trim()); - if (!tokens.includes('layout') && contain !== 'strict' && contain !== 'content') { - return false; - } - } - } - return foundScopeAll ? true : true; // If none found, technically it passes 'every', but let's let test 1 fail it - }); - expect(pass).toBe(true); - }); - - test('The root element does NOT have a computed view-transition-scope value of all', async ({ page }) => { - await page.goto(`file://${targetFile}`); - const rootScope = await page.evaluate(() => { - return (window.getComputedStyle(document.documentElement) as any).viewTransitionScope; - }); - expect(rootScope).not.toBe('all'); - }); - - test('At least two distinct elements on the page have a computed view-transition-name other than none, and both are descendants of an element with view-transition-scope: all', async ({ page }) => { - await page.goto(`file://${targetFile}`); - const validNamesCount = await page.evaluate(() => { - let count = 0; - const allElements = document.querySelectorAll('*'); - for (const el of allElements) { - const style = window.getComputedStyle(el); - if ((style as any).viewTransitionName && (style as any).viewTransitionName !== 'none') { - // Check if it's a descendant of an element with view-transition-scope: all - let current = el.parentElement; - let isDescendant = false; - while (current) { - const currentStyle = window.getComputedStyle(current); - if ((currentStyle as any).viewTransitionScope === 'all') { - isDescendant = true; - break; - } - current = current.parentElement; - } - if (isDescendant) { - count++; - } - } - } - return count; - }); - expect(validNamesCount).toBeGreaterThanOrEqual(2); - }); - - test('The page\'s JavaScript does NOT call document.startViewTransition', async ({ page }) => { - await page.addInitScript(() => { - (window as any).__docStartViewTransitionCalled = false; - const original = document.startViewTransition; - if (original) { - document.startViewTransition = function(cb) { - (window as any).__docStartViewTransitionCalled = true; - return original.call(this, cb); - }; - } - }); - await page.goto(`file://${targetFile}`); - - const buttons = await page.locator('button').all(); - for (const btn of buttons) { - await btn.click(); - await page.waitForTimeout(50); - } - - const called = await page.evaluate(() => (window as any).__docStartViewTransitionCalled); - expect(called).toBe(false); - }); - - test('After clicking a button that lives inside (or adjacent to) an element with view-transition-scope: all, the textContent of that scope changes within a short time', async ({ page }) => { - await page.goto(`file://${targetFile}`); - - const passed = await page.evaluate(async () => { - const getScopes = () => { - const scopes = []; - const allElements = document.body.querySelectorAll('*'); - for (const el of allElements) { - const style = window.getComputedStyle(el); - if ((style as any).viewTransitionScope === 'all') { - scopes.push(el); - } - } - return scopes; - }; - - const scopes = getScopes(); - if (scopes.length === 0) return false; - - const initialText = scopes.map(el => el.textContent); - - const buttons = document.querySelectorAll('button'); - if (buttons.length === 0) return false; - - for (const btn of buttons) { - btn.click(); - - await new Promise(resolve => setTimeout(resolve, 50)); - - const newText = scopes.map(el => el.textContent); - - for (let i = 0; i < scopes.length; i++) { - if (initialText[i] !== newText[i]) { - return true; - } - } - } - return false; - }); - - expect(passed).toBe(true); - }); -}); diff --git a/guides/user-experience/scoped-component-transitions/guide.md b/guides/user-experience/scoped-component-transitions/guide.md index 0d123dcb..82e38032 100644 --- a/guides/user-experience/scoped-component-transitions/guide.md +++ b/guides/user-experience/scoped-component-transitions/guide.md @@ -10,127 +10,3 @@ sources: - https://developer.chrome.com/docs/css-ui/view-transitions/element-scoped-view-transitions - https://drafts.csswg.org/css-view-transitions-2/ --- - -# Scope a view transition to a single component - -`Element.prototype.startViewTransition()` runs a view transition that affects only the element's own DOM subtree. The pseudo-element tree (`::view-transition`, `::view-transition-group(...)`, `::view-transition-old(...)`, `::view-transition-new(...)`) is hosted on the element itself, not on ``, so the rest of the page keeps rendering, stays interactive, and can paint on top of the transitioning subtree by normal `z-index` rules. - -Use this whenever a state change is *internal* to one component — a list rearranging inside a card, a tab panel swapping content, an accordion expanding — and a full-page `document.startViewTransition()` would be overkill or would freeze unrelated UI (toolbars, modals, other in-flight transitions). - -## Implementation - -1. **MANDATORY: Call `startViewTransition()` on the scope element, not on `document`.** A document-level transition snapshots the whole page and pauses the entire document's rendering during the callback; calling it for a per-component change defeats the point of scoping and re-introduces every problem element-scoped transitions were designed to solve (overlays getting hidden, fixed-position elements getting captured, concurrent transitions cancelling each other). -2. **MANDATORY: Tag the participants whose old and new positions should be matched.** Give each element you want to morph a unique `view-transition-name` inside the scope. For repeating content where you don't want to invent a name per item, set `view-transition-name: match-element` on the items so the browser derives stable per-element identity automatically. Names only have to be unique *within their scope*; two scopes can reuse the same name without colliding. -3. **DO declare `contain: layout` and `view-transition-scope: all` on the scope element up front.** The browser auto-applies both during a scoped transition (so omitting them does not break the feature), but declaring them explicitly avoids a one-frame reflow when the transition starts and guarantees namespace isolation if a parent ever runs its own transition. Treat them as "required for production polish, optional for a proof of concept." -4. **OPTIONAL: Opt out of self-participation when the scope itself should not cross-fade.** By default the scope behaves as if it had `view-transition-name: root`, which cross-fades the entire scope between old and new states. If you want only the participants inside the scope to animate (and the scope's own border / background to stay live), set `view-transition-name: none` on the scope element. -5. **OPTIONAL: Keep non-transitioning content interactive.** During a transition the pseudo-element tree paints on top of the scope's contents. If you've opted out of self-participation and want elements inside the scope to remain clickable and hoverable while the transition runs, add `pointer-events: none` to the scoped pseudo-element. Qualify the selector to *this* scope (e.g. `.card::view-transition`) — pseudo-trees are hosted per-scope, so an unqualified `::view-transition` rule would also disable interactivity on every other scope's transition. - -```html - -
      - -
      -
      - - -``` - -```css -/* The scope: contain: layout forces a stacking context so the scope's - painted output can be captured as an atomic unit. The browser auto- - applies it during a scoped transition; declaring it up-front prevents - a one-frame reflow when the transition starts. */ -.card { - contain: layout; - - /* The scope: view-transition-scope: all isolates this subtree's - view-transition-name namespace from ancestors and siblings. Two - components can reuse the same view-transition-name (e.g. each set - of
    • s using `match-element`) without colliding. */ - view-transition-scope: all; -} - -/* DO tag participants whose old and new positions should be paired. - `match-element` derives a stable per-element identifier so you don't - have to invent names for every list item. The names are scoped to the - nearest ancestor with view-transition-scope: all, so the same - `match-element` value on both lists never collides. */ -.card li { - view-transition-name: match-element; -} - -/* OPTIONAL: tune the default cross-fade animation that wraps each - participant. Pseudo-elements live under each scope independently, - so this rule applies to whichever transition is currently running. */ -::view-transition-group(*) { - animation-duration: 300ms; - animation-timing-function: ease-in-out; -} - -/* DO respect reduced-motion. Element-scoped transitions still produce - the same pseudo-element tree, just hosted on the scope, so the same - universal selectors that work for document-level transitions apply. */ -@media (prefers-reduced-motion: reduce) { - ::view-transition-group(*), - ::view-transition-old(*), - ::view-transition-new(*) { - animation: none !important; - } -} -``` - -```javascript -// Feature-detect on Element.prototype. The spec defines -// startViewTransition on Element, so SVG and MathML elements that also -// inherit from Element are covered; `HTMLElement.prototype` would be -// strictly narrower than what the API supports. -const supportsScopedTransition = - 'startViewTransition' in Element.prototype; - -function applyUpdate(scopeEl, callback) { - if (!supportsScopedTransition) { - // Fallback path: apply the change immediately. The result is the - // same final DOM; only the animation is missing. Do NOT silently - // fall back to document.startViewTransition here — its full-page - // render-pause and overlay-capture behaviour is exactly what - // element-scoped transitions exist to avoid. - callback(); - return; - } - - // Call startViewTransition on the scope element, not on document. A - // document-level call would pause the whole page's rendering and - // capture every overlay above the component. OPTIONAL hardening: - // attach `.finished.catch(...)` if you want explicit logging on a - // thrown callback (otherwise the rejection is silent). - scopeEl.startViewTransition(callback); -} - -document.getElementById('left-rotate').addEventListener('click', () => { - applyUpdate( - document.getElementById('left'), - () => updateList(document.getElementById('left-list')), - ); -}); -``` - -Two scopes can run transitions concurrently — even when one scope is a descendant of the other — because each scope hosts its own pseudo-element tree and pauses rendering only inside its own subtree. Starting a second transition on the *same* scope while one is in progress skips the in-progress transition and runs the new one immediately; rapid repeat clicks are safe and do not need debouncing. - -DO NOT call `document.startViewTransition()` for a per-component update and then try to limit the participants with `view-transition-name`. That still pauses rendering for the whole page during the update callback, still captures fixed-position overlays into the snapshot, and still skips any other in-flight document-level transition. Element-scoped transitions exist precisely because those side effects are unavoidable at the document level. - -DO NOT animate `transform` or layout offset *of the scope itself* as part of the transition. Those properties move the pseudo-element tree along with the scope, so they appear motionless relative to the participants. Animate them on a wrapping ancestor (or a non-self-participating wrapper) instead. - -## Fallback strategies - -{{ BASELINE_STATUS("view-transitions-element-scoped") }} - -Element-scoped view transitions are a progressive enhancement. In browsers without support, the feature-detection branch above runs the DOM update directly with no animation; the final state is identical to the supported path. If document-level view transitions (`document.startViewTransition`) are available but element-scoped ones are not, do *not* silently fall through to a document-level transition for a per-component change — the behavioural differences (full-page render pause, overlay capture, single-transition-at-a-time) usually make the fallback worse than no animation at all. - -DO NOT recommend a polyfill. The pseudo-element tree, render suppression scoped to a subtree, and capture of border/box-shadow/outline are paint-pipeline behaviours; no JavaScript shim can reproduce them faithfully, and substituting an iframe or a clone-and-FLIP approximation loses the integration with `z-index`, fixed-position overlays, and concurrent transitions that the native feature provides. - -If a scoped animation is critical and the no-animation fallback (instant DOM update) is unacceptable for one specific component, use a CSS animation or the Web Animations API for that component as a parallel implementation — but prefer the no-animation fallback whenever it is acceptable, so a single source of truth drives the visual output. diff --git a/guides/user-experience/scoped-component-transitions/negative-demo.html b/guides/user-experience/scoped-component-transitions/negative-demo.html deleted file mode 100644 index 0cfa6e90..00000000 --- a/guides/user-experience/scoped-component-transitions/negative-demo.html +++ /dev/null @@ -1,161 +0,0 @@ - - - - - - Component Transitions - - - -
      Toolbar
      - -
      -
      -

      Card A

      - -
        -
        - - -
        - - - - \ No newline at end of file diff --git a/guides/user-experience/scoped-component-transitions/tasks/task.md b/guides/user-experience/scoped-component-transitions/tasks/task.md deleted file mode 100644 index 80cae72b..00000000 --- a/guides/user-experience/scoped-component-transitions/tasks/task.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -base_app: daily-grind ---- -- the seasonal favorites cards feel static when nothing changes them. add a shuffle button that smoothly animates the cards into a new order each click. the page has a fixed nav up top and other interactive controls that absolutely cannot pause or freeze while the cards are mid-animation -- add a button that reorders the seasonal favorites cards with a smooth animation. keep the rest of the page (the nav, anything clickable elsewhere) fully responsive throughout — no whole-page freeze when the animation runs -- give the seasonal favorites grid a shuffle control that animates the cards into a new arrangement on click. the animation should feel localized to that grid only; nothing outside the grid should hesitate, redraw, or lose interactivity for even a frame