w3c/csswg-drafts

[css-view-transitions-2] Distinguish between old and new DOM in CSS

Closed this issue ยท 12 comments

#8960 adds the capability to add custom names to identify the transition which can then be used to conditionally apply styles. But it doesn't provide a way to specify whether a style rule applies only to the old DOM in a transition or only to the new. For example, with the following script:

document.startViewTransition({
  update: updateTheDOMSomehow, 
  classNames: ["main-page-slide"]
});

:active-view-transition(main-page-slide will apply from when startViewTransition is called to when the transition finishes. If you want add a name to an element in the old DOM but not in the new DOM, that still needs to be managed in script.

One way to solve this is UA supplied class names: "--old" and "--new". Such that :active-view-transition(--old) applies after startVT is called and until the update callback is dispatched. :active-view-transition(--new) applies when the update callback is dispatched and until the transition finishes. Then authors could combine this with their own class names:

:active-view-transition(main-page-slide):active-view-transition(--old) {
   /* Only applies to the old DOM.
}

:active-view-transition(main-page-slide):active-view-transition(--new) {
   /* Only applies to the new DOM.
}

@noamr FYI.

noamr commented

In this case, wouldn't you have some other things in the old and new DOM that you can rely on? e.g. if you're moving between a home and article page, you'd probably have <section id=home> and <section id=article> or some such in the old and new DOM respectively.

Can you expand, how you would use old & new in a way that's not achievable with regular selectors?

I'll let @calinoracation clarify the use-case.

For us we try to declare everything at the site that's triggering a transition. For example we might have the element screen that we want to apply a View Transition with.

Transition: Search -> Details

  • On Page A, we map screen to --screen-a_container
  • On Page B, we map screen to --screen-b_container

We would use the new syntax like this:

.main-page-slide {
  &:active-view-transition(main-page-slide):active-view-transition(--old) {
    --screen-a_container: screen;
  }

  &:active-view-transition(main-page-slide):active-view-transition(--new) {
    --screen-b_container: screen;
  }

  // other view transition related configuration for this transition
}
document.startViewTransition({
  update: updateTheDomSomehow,
  classNames: ['main-page-slide'],
});

The main reason is that at Airbnb we're already seeing scaling issues with each component or route needing to know about every transition it needs to participate in. People clobber others or don't want the view-transition-name assigned in a certain instance. We've adopted a semantic naming approach, where instead of the callsite needing to know a name like screen, it only needs to assign a value like view-transition-name: var(--screen-a_container). That way it's none by default, but when a particular transition needs it, it maps it at the location we call startViewTransition.

Sidenote: I'm a bit confused on the naming of --old|--new and the repeating of the selector. Would we be able to do :active-view-transition(main-page-slide --old) as well? Also just want to make sure there's not potential confusion on the CSS variable route, unless potentially that does set it as well?

noamr commented

Thanks for the use case @calinoracation !

Curious: what happens inside your updateTheDomSomehow? Isn't there somewhere where you change the current view from being search (a) to being details (b) in the DOM? e.g a dataset attribute on the mainPageSlide or so that points to the current screen?

(using search and details instead of a/b, helps me feel closer to the use-case, hope that's ok)

JS:

function transitionToDetails() {
  // mainPageSlide.dataset.current === "search"
document.startViewTransition({
   update: () => {
      // Start a react chain that ends up doing a lot of stuff but also:
     mainPageSlide.dataset.current = "details";
   },
   classNames: ["main-page-slide"]
});
}

CSS:

html:active-view-transition(main-page-slide) {
  .main-page-slide {
     &[data-current=search] { --screen-search: screen; }
     &[data-current=details] { --screen-details: screen; }
  }
}

Note that having this old/new feature would allow "ghost" view transitions - running a whole transition without changing the DOM at all by responding to class names + old + new, and resetting to the original state at the end. Not sure if this is a bug or a feature, but it's definitely a bit of an odd side-effect...

In your case what you're trying to do is not a ghost transition, but rather a classic transition to a new state. By using the old/new idiom here, wouldn't you have to define the old/new state twice in some sense?

For us the updateTheDOMSomehow is almost always a route transition, so history.pushState. We certainly could use a similar syntax to what you're describing, but it also would mean folks would need to honor a pattern like data-current="search", and when you start scoping smaller like an image that becomes harder to manage overall.

Yeah I prefer the actual use case of search to details, it's one of our most common flows!

Yeah agreed on the ghost side-effect, but it sounds pretty interesting. We weren't trying to do that but if we ever had a CSS way to trigger a view transition then this side effect combined would make things very fascinating.

noamr commented

For us the updateTheDOMSomehow is almost always a route transition, so history.pushState.

It must do other things as well, no? pushState doesn't change the DOM.

We certainly could use a similar syntax to what you're describing, but it also would mean folks would need to honor a pattern like data-current="search", and when you start scoping smaller like an image that becomes harder to manage overall.

This pattern is an example, I'm suggesting to use whatever your DOM already exposes when you change screens.
I don't understand "when you start scoping smaller like an image"?

Yeah agreed on the ghost side-effect, but it sounds pretty interesting. We weren't trying to do that but if we ever had a CSS way to trigger a view transition then this side effect combined would make things very fascinating.

It would be interesting, trying to understand if it's useful/needed by anyone... we can leave this issue open to see if that need emerges.

@noamr Yeah pushState is effectively for us like a multi-page app navigation. Many things can change and a page's contents might be getting composed by 3 or 4 different teams. Many times we use a server driven section based system, where we don't know ahead of time the page contents or even the container.

Think a product details page that might be different for plus, lux, experiences, etc or search through different things. It might be in search or in a product details page or a details preview embedded in another screen.

What we do know generally during the triggering of the transition is the general semantic elements. Since a target shared element can be in 20+ animations and continue to grow, it's hard to be certain about a specific DOM structure. That's why the old/new switch is so nice for us.

That said, it's far from impossible. We make it work today. We do redefine startViewTransition to make this following snippet possible.

${Transition.map({ screen: '--screen-panel' })}
${Transition.map({
  box: {
    old: '--box-pdp',
    new: '--box-checkout'
  }
})}

@calinoracation would you have a preference between a model that requires declaring old/new types when you trigger a transition vs setting the new types within the update callback?

// If newType is specified, its used as types on the new DOM instead of type.
document.startViewTransition(updateCallback, {type: old, newType: new});

vs

let vt;
async function updateCallback() {
  ...
  vt.type = new;
}
vt = document.startViewTransition(updateCallback, {type: old});

Only reason we can think for the latter would be if some components execute script as a part of updateCallback() which you need to figure out the new type. But don't know if that's an issue in practice. The catch though is that timing is subtle to make sure types are updated at the correct spot so they apply when the browser decides view-transition-names in the new DOM. I'm unsure about whether the following would work for instance:

vt = document.startViewTransition(updateCallback, {type: old});
vt.updateCallbackDone().then(() => {vt.type = new});

It requires a microtask checkpoint after updateCallback finishes and browser looks for names in the new DOM to ensure updateCallbackDone resolves.

@khushalsagar For the use cases I can think of we'd always have a good idea of the old/new types at the time of triggering it. The only time it's not explicit from us is in handling back/forwards navigation. That's not too hard in this case though as I'd imagine we'd just swap out the types & newTypes.

// If newType is specified, its used as types on the new DOM instead of type.

Is this allowed to be an array or just a single possible item on each call? Assuming the former due to naming but wanted to check.

${Transition.map({
  box: {
    old: '--box-pdp',
    new: '--box-checkout'
  }
})}

From my example above, would now become something like this?

document.startViewTransition(updateTheDOMSomehow, { 
  types: 'box-pdp', 
  newTypes: 'box-checkout',
});

Thanks for the feedback!

Is this allowed to be an array or just a single possible item on each call?

Its an array, you can see syntax details here.

Since this is quite easy with types, I think we should close this. OK with you @khushalsagar ?

const transition = document.startViewTransition(
  { types: ["old"], update: () => {
      transition.types.remove("old");
        transition.types.add("new");
      // update the DOM somehow ...
  }});

@bramus perhaps we should have some post about using types for this kind of use-case?