Przejścia komponentów o określonym zakresie

Rozpoczęcie przejścia widoku za pomocą document.startViewTransition() dołącza nakładkę ::view-transition do elementu :root, co blokuje zdarzenia wskaźnika na pozostałej części strony, uniemożliwia jednoczesne przejścia i zasłania nagłówki position: fixed lub otwarte elementy [popover] (nawet jeśli używane są zagnieżdżone grupy przejść widoku, aby zapobiec przepełnieniu).

Wywołanie funkcji element.startViewTransition() w elemencie głównym komponentu rozwiązuje ten problem, ograniczając przejście do tego poddrzewa DOM: drzewo pseudoelementów ::view-transition jest wstrzykiwane bezpośrednio do elementu, co pozwala zachować zewnętrzne konteksty stosu i interaktywność strony, a jednocześnie umożliwia jednoczesne przejście wielu komponentów.

Implementacja

1. Aktywowanie przejścia ograniczonego do zakresu

Wywołaj element.startViewTransition() w głównym elemencie kontenera komponentu zamiast document.startViewTransition(). Element, na którym wywołujesz startViewTransition(), staje się zakresem przejścia (elementem zakresu): przechwytywane są tylko elementy w jego poddrzewie, tylko renderowanie zakresu jest zatrzymywane podczas wywołania zwrotnego aktualizacji, a wynikowa nakładka ::view-transition odpowiada rozmiarowi, kształtowi i kontekstowi stosu elementu zakresu.

2. Nazwa elementów uczestniczących

Przypisz view-transition-name do elementów podrzędnych w zakresie, które mają być animowane. Możesz użyć view-transition-name: match-element w dynamicznych elementach listy, aby automatycznie uzyskać nazwę przejścia każdego elementu z jego tożsamości węzła DOM, lub przypisać jednoznaczne nazwy (które muszą być unikalne tylko w tym zakresie).

Nazwy są izolowane w każdym zakresie, co zapobiega kolizjom między komponentami w różnych częściach DOM. Jednak view-transition-name może kolidować z nazwami w zagnieżdżonych przejściach widoku zakresu. Aby temu zapobiec, dodaj view-transition-scope: all do elementu zakresu podrzędnego.

3. Samodzielne uczestnictwo i przycinanie przepełnienia

Element zakresu automatycznie uczestniczy we własnym przejściu widoku, ponieważ arkusz stylów klienta użytkownika stosuje do niego właściwości view-transition-name: root i view-transition-group: contain. Dzięki temu, gdy zmiana w DOM wpłynie na geometrię zakresu, będzie on płynnie animować swoją geometrię wraz z elementami podrzędnymi w ramach przejścia widoku.

Gdy zakres przycina przepełnienie (overflow: hidden, scroll lub clip), ::view-transition-group-children(root) automatycznie stosuje overflow: clip, co zapobiega wychodzeniu elementów podrzędnych poza granice komponentu.

4. Przejścia równoczesne i zagnieżdżone

Oddzielne poddrzewa komponentów mogą działać równolegle element.startViewTransition() bez pomijania się nawzajem. Aktywne zakresy automatycznie otrzymują view-transition-scope: all w arkuszu stylów UA, dzięki czemu wewnętrzne wartości view-transition-name nie wyciekają do zewnętrznych przejść. Zastosuj view-transition-scope: all w kodzie CSS w powtarzających się kontenerach komponentów, aby zapobiec przechwytywaniu nazw wewnętrznych przez przejścia zewnętrzne, nawet gdy komponent jest nieaktywny.

Jeśli przejście zewnętrzne musi przenieść komponent, gdy przejście wewnętrzne jest aktywne, przypisz view-transition-name do elementu, który otacza zakres wewnętrzny. Przejście zewnętrzne przechwytuje i animuje tylko opakowanie, a nie element zakresu wewnętrznego, co pozwala na zakończenie przejścia wewnętrznego.

Przykład

Do określenia zakresu nie jest potrzebny żaden kod CSS w samym elemencie zakresu (.card lub .card ul) – zakres jest określany przez wywołanie startViewTransition() w elemencie zamiast document (jeśli komponent będzie zagnieżdżony w zewnętrznym przejściu widoku, zobacz Uruchamianie przejścia zewnętrznego w przypadku view-transition-scope: all).

/* 1. Name participating child items */
.card ul li {
  view-transition-name: match-element;
}

/* 2. OPTIONAL: Customize transition timing (300ms is an example duration) */
::view-transition-group(*) {
  animation-duration: 300ms;
  animation-timing-function: ease-in-out;
}

/* 3. MANDATORY: Respect user preference for reduced motion */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}
// 1. Trigger a transition scoped to a single component subtree
function runScopedTransition(scopeEl, updateDOM) {
  // MANDATORY: Call startViewTransition on the component element, not document
  const transition = scopeEl.startViewTransition(() => {
    updateDOM();
  });

  // Re-triggering startViewTransition() on the same element skips the active transition
  // and rejects `ready`. Catch the rejection to avoid unhandled promise rejection errors.
  transition.ready.catch(() => {});
}

// 2. Reorder items by moving existing DOM nodes (required for `match-element`)
// Scope to `listEl` since only the list order changes, keeping controls outside `listEl` interactive
function reorderItems(listEl, orderedIds) {
  const existing = new Map(
    Array.from(listEl.children, (li) => [li.dataset.id, li]),
  );

  runScopedTransition(listEl, () => {
    for (const id of orderedIds) {
      const li = existing.get(id);
      if (li) listEl.appendChild(li);
    }
  });
}

// 3. Remove an item while preserving exit animation and keyboard focus
// Scope to the outer `cardEl` so the card itself also transitions its height change
function removeItem(cardEl, itemEl) {
  const hadFocus = itemEl.contains(document.activeElement);
  const nextFocusTarget =
    itemEl.nextElementSibling?.querySelector('button') ??
    itemEl.previousElementSibling?.querySelector('button') ??
    cardEl.querySelector('button');

  runScopedTransition(cardEl, () => {
    itemEl.remove();
    if (hadFocus) {
      nextFocusTarget?.focus();
    }
  });
}

Uruchamianie przejścia zewnętrznego

Gdy przejście widoku rozpoczyna się poza zamierzonym zakresem (np. z komponentu nadrzędnego lub z dokumentu w przypadku przejścia na poziomie strony), przejście zewnętrzne przechwyci i zanimuje element zakresu oraz wszystko, co się w nim znajduje.

Aby temu zapobiec, umieść element zakresu w osobnym elemencie opakowującym z własnym atrybutem view-transition-name i zastosuj atrybut view-transition-scope: all do wewnętrznego elementu zakresu. Dzięki temu sam kontener może zostać przechwycony i animowany przez przejście zewnętrzne, a zawartość zakresu wewnętrznego nie zostanie przechwycona.

<div class="page" id="page">
  <div class="card-wrapper">
    <section class="card" id="left">
      <h2>Card A</h2>
      <ul id="left-list">
        <li>…</li>
      </ul>
    </section>
  </div>
  <div class="card-wrapper">
    <section class="card" id="right">
      <h2>Card B</h2>
      <ul id="right-list">
        <li>…</li>
      </ul>
    </section>
  </div>
</div>
document.querySelector('#page').startViewTransition(() => {
  // do your DOM updates here...
});
/* Apply a view-transition-name on the wrapper */
.card-wrapper {
  view-transition-name: match-element;
}

/* Prevent the contents of the card being captured by an outer transition */
.card {
  view-transition-scope: all;
}

.card ul li {
  view-transition-name: match-element;
}

Możesz też zezwolić przejściu zewnętrznemu na przechwytywanie elementów podrzędnych. Aby zachować efekty przycinania, użyj zagnieżdżonych grup przejść widoku, aby przyciąć przechwycone elementy podrzędne na granicach ich kontenera. W takim przypadku musisz jednak ręcznie zastosować clip do wynikowego pseudoelementu ::view-transition-group-children().

.card-wrapper {
  overflow: clip;
  view-transition-name: match-element; /* Capture me as part of the outer VT */
  view-transition-group: contain; /* Nest children that also have a view-transition-name */
  view-transition-class: card; /* For targeting purposes */
}

/* Manually copy back the clip onto the ::view-transition-group-children pseudo */
::view-transition-group-children(.card) {
  overflow: clip;
}

.card ul li {
  view-transition-name: match-element;
}

Ograniczenia i ułatwienia dostępu

  • OBOWIĄZKOWO: wywołaj startViewTransition() w elemencie głównym komponentu, a nie document, aby zewnętrzne elementy position: fixed i nakładki [popover] pozostały na wierzchu, a reszta strony była interaktywna.
  • OBOWIĄZKOWO: zachowaj i przenieś istniejące węzły DOM (zamiast odtwarzać je za pomocą innerHTML), gdy używasz view-transition-name: match-element, ponieważ match-element śledzi tożsamość węzła DOM.
  • OBOWIĄZKOWE: uwzględniaj preferencje użytkowników dotyczące ograniczenia ruchu za pomocą @media (prefers-reduced-motion: reduce), ustawiając animation: none !important na ::view-transition-group(*), ::view-transition-old(*) i ::view-transition-new(*).
  • OBOWIĄZKOWE: zachowaj zaznaczenie klawiatury podczas usuwania zaznaczonego elementu w trakcie przejścia, przenosząc zaznaczenie na sąsiedni element lub element sterujący w ramach wywołania zwrotnego aktualizacji.
  • DO: Use Nested View Transition Groups (view-transition-group: contain and overflow: clip on ::view-transition-group-children()) when a captured element inside the scope clips its own overflow and contains nested captured elements.
  • DO: Attach transition.ready.catch(() => {}) when rapid interactions can re-trigger element.startViewTransition() on the same scope element.
  • DO: Wrap an inner scope element in a separate named wrapper element if an outer transition needs to animate the component's position while an inner transition is running.
  • NIE: ustawiaj view-transition-name: none na elemencie zakresu, który przycina przepełnienie (overflow: hidden, scroll lub clip) lub zmienia własną geometrię, ponieważ rezygnacja z samodzielnego uczestnictwa wyłącza automatyczne przycinanie przepełnienia.

Strategie zastępcze

Jeśli Twój cel Baseline nie obsługuje przejść widoku ograniczonych do elementu, zastosuj łagodną degradację, natychmiast uruchamiając wywołanie zwrotne aktualizacji DOM bez animacji. Możesz użyć document.startViewTransition(), ale nie jest to zalecane w przypadku aktualizacji odizolowanych komponentów, ponieważ przejście na poziomie dokumentu blokuje jednoczesne przejścia komponentów i zasłania elementy position: fixed i [popover] w górnej warstwie.

// Feature detect specifically on Element.prototype (not document.startViewTransition)
const supportsScopedTransition = 'startViewTransition' in Element.prototype;

function runWithTransition(scopeEl, updateDOM) {
  if (!supportsScopedTransition) {
    // Fallback: apply the DOM update immediately without animation
    updateDOM();
    return;
  }

  const transition = scopeEl.startViewTransition(() => {
    updateDOM();
  });

  transition.ready.catch(() => {});
}

Jeśli naprawdę musisz użyć kreacji zastępczej z document.startViewTransition(), upewnij się, że efekty przycinania kontenerów są zachowane, jak opisano w sekcji Uruchamianie przejścia zewnętrznego. Pamiętaj, że to rozwiązanie zastępcze uniemożliwia jednoczesne uruchamianie wielu przejść widoku i będzie zasłaniać elementy position: fixed i [popover] w górnej warstwie.