w3c / csswg-drafts

CSS Working Group Editor Drafts
https://drafts.csswg.org/
Other
4.5k stars 661 forks source link

[css-transitions-2] Exit animations (`@exit-style`) #9500

Open jessicajaniuk opened 1 year ago

jessicajaniuk commented 1 year ago

Problem statement

Elements are often removed from the DOM, either via JS (element.remove() etc), or by applying display: none to them. Some common examples: deleting items from lists, toasts, dismissing dialogs and popovers, etc.

Current solutions:

All of these solutions are too heavyweight, and most require additional JS and possibly the allocation of an event listener which is a perf overhead.

Proposed solution

In #8174 the group added @starting-style, to solve the reverse of this problem and make it easier to specify entrance transitions. Something analogous to that, such as @exit-style (name TBB) would complement it nicely.

.posts {
  > li {
    transition: .4s opacity;

    @exit-style {
      opacity: 0;
    }
  }
}

Other potential names: @ending-style, @remove-style, @exiting-style, @removing-style

If renaming @starting-style would not cause compat issues (it just shipped in Blink and it has not shipped anywhere else), we could name them as a pair:

Additionally, if there is a way to specify both entrance and exit styles, this simplifies a lot of other interactions that are technically neither, e.g. moving elements around.

Other solutions

LeaVerou commented 1 year ago

I helped @jessicajaniuk flesh out and write this proposal, so obviously strong +1 for this! It’s very low hanging fruit, that addresses a very common author pain point.

For context, Jessica works on the Angular team at Google, and thus us intimately familiar with how much of a common pain point this is.

If renaming @starting-style would not cause compat issues (it just shipped in Blink and it has not shipped anywhere else), we could name them as a pair:

I would be very surprised if it's too late to rename @starting-style given its current level of browser support. I think naming them together as a pair would lead to a more learnable, consistent design. If we're going to rename it we need to do it very soon though, so I’m going to Agenda+ this.

Loirooriol commented 1 year ago

I don't see how this would work. The thing about @starting-style is that when an element becomes rendered, it gets the styles from @starting-style, and then transitions towards the normal styles.

But with this, once the element stops being rendered, it immediately disappears, so no transition is possible. We would either need to delay the actual removal until the transition finishes, which seems to open a can of worms, or somehow detect that the element is going to be removed before it happens and start the transition then.

chriscoyier commented 1 year ago

This would be excellent. The ability to animate an element as it ultimately leaves the DOM has been janky forever.

I could be mistaken, but I think you can do this already with View Transitions, something to the tune of:

Element.style.viewTransitionName = "unique-outgoing-ident";
Element.remove();

Then in CSS if you want control...

::view-transition-old(unique-outgoing-ident) {
  animation: outgoing 1s;
}

This example does that for list items "on the way out" of the DOM.

We would either need to delay the actual removal until the transition finishes, which seems to open a can of worms

I think the View Transitions thing avoids the "can of worms" because it still immediately removes the element from the DOM, and the animation takes place on a rasterized visual version of the element, right?

Still, I think @exit-style is a more elegant API than View Transitions, because:

  1. The pairing with @starting-style is sensible.
  2. You need JS to make View Transitions work because the "outgoing" elements need entirely unique idents otherwise the transitions don't work at all. Plus, if the elements have "incoming" view transitions, you need to swap out the ident to a different one so it can have a different animation, which seems awkward. Seems like a concern best left to CSS alone.
  3. It could automatically trigger on situations other than leaving the DOM, as Jessica notes, like when an element becomes display: none;
andruud commented 1 year ago

I don't see how this would work.

Easy, we add the vitality: auto | none property, make it transitionable, make it prefer the auto side during the transition, and spec in HTML that any element with no vitality gets removed at a certain point in the event loop.

(worm-wriggling intensifies)

LeaVerou commented 1 year ago

@Loirooriol

I don't see how this would work.

Yes, it's more challenging than @starting-style but it's such a common problem that I think it's worth spending some cycles on. Some ideas below.

The thing about @starting-style is that when an element becomes rendered, it gets the styles from @starting-style, and then transitions towards the normal styles.

But with this, once the element stops being rendered, it immediately disappears, so no transition is possible. We would either need to delay the actual removal until the transition finishes, which seems to open a can of worms, or somehow detect that the element is going to be removed before it happens and start the transition then.

We cannot make removal async for a number of reasons. Beyond the obvious, one nonobvious reason is also that the exit style and the removal code may be written by different entities, and it's important for this to work well. E.g. I'm using a library which removes elements with no animation, so I write some styles to animate them.

We could however delay it (sync) for the instant it would take to apply exit styles. Then, the transition effect would be entirely visual and the element would be taken out of the DOM immediately. Probably with some safeguard against infinite animations that could keep its ghost around forever. An easy such safeguard could be to simply not support animations, or cut them off when all transitions finish regardless of where they are in their playback.

It could even be defined in terms of view transitions under the hood, just that the UA would generate it for you.

Loirooriol commented 1 year ago

I haven't really been following view transitions, but yeah, using them under the hood sounds more reasonable I guess.

dbaron commented 1 year ago

transition-behavior: allow-discrete was recently added (see #4441 and #8857 and Chrome blog post) to address what I think are the same set of use cases. [Edit: except for the DOM removal one.]

@starting-style is special because transitions operate on the style before and after a style change, and browsers don't keep styles up-to-date for elements that are display:none -- so there's no record of what the style "before the change" was and it's not necessarily possible to reconstruct it after the change has happened. When something is changing to display: none that problem doesn't exist and we don't need the special construct; we can use existing transition mechanisms. What @starting-style does is allow transitions to operate on elements that are changing from none to other values of display; we don't need a special mechanism to allow transitions in the other direction, and they should just work.

dbaron commented 1 year ago

But yes, actually removing an element from the DOM is different. It feels like a pretty significant layering violation to delay DOM operations from CSS. That said, maybe we shouldn't worry too much about that, but I'm inclined to think that if we want to improve the ergonomics of doing a transition that ends with the element being removed from the dom, we might want to think about an API on the DOM side to make that more ergonomic.

dbaron commented 1 year ago

I should probably stop replying late at night, but now that I've reread https://github.com/w3c/csswg-drafts/issues/9500#issuecomment-1781298753 more carefully I see Lea's point about immediate DOM removal being important -- and given that I agree that something built on top of view transitions is probably best. But I could imagine building it on top of view transitions in interesting ways.

(Sorry for the multiple comments.)

chrishtr commented 1 year ago

I should probably stop replying late at night, but now that I've reread #9500 (comment) more carefully I see Lea's point about immediate DOM removal being important -- and given that I agree that something built on top of view transitions is probably best. But I could imagine building it on top of view transitions in interesting ways.

I can also see the developer difficulty, but keeping track of a now-gone DOM element and where it used to be until the end of the animation is very difficult/maybe impossible. Even keeping it until the next render and a View Transition can start is hard and in general almost as hard. The alternative of stalling the rendering pipeline to get a screenshot of the pixels for a View Transition is possible, but that comes at a very high cost of forced layout, stalled pipeline, and memory impact.

OTOH the developer code to do it async would be something like:

startViewTranstion(() => { removeNode(); });

(setting it to display:none is already doable with allow-discrete has the same effect visually but of course not in terms of DOM state)

Which is certainly not as easy for the developer to deal with, but also seems feasible.

dbaron commented 1 year ago

One question in this context: how useful to developers is it if the DOM removal is async, but still at the start time of the animation (rather than the end).

LeaVerou commented 1 year ago

@chrishtr

I can also see the developer difficulty, but keeping track of a now-gone DOM element and where it used to be until the end of the animation is very difficult/maybe impossible. Even keeping it until the next render and a View Transition can start is hard and in general almost as hard. The alternative of stalling the rendering pipeline to get a screenshot of the pixels for a View Transition is possible, but that comes at a very high cost of forced layout, stalled pipeline, and memory impact.

OTOH the developer code to do it async would be something like:

startViewTranstion(() => { removeNode(); });

First, it's not quite a one liner, it's more like:

if (document.startViewTransition) {
    await document.startViewTransition(() => { el.remove() }).finished;
}
else {
    el.classList.add("removing");
    await new Promise(r => el.addEventListener("transitionend", r, {once: true}));
    el.remove();
}

But that could be abstracted away. More importantly it's about coupling. Yes, if you are the same entity writing the element removal code it's trivial to replace it with a view transition. But that's not usually the case. Authors these days use long pipelines of tooling to do these things, they rarely write DOM element removal code themselves. E.g. I would have no idea where to even start to use a view transition to animate the removal of an element that’s removed via Vue’s v-if or a React conditional.

@dbaron

One question in this context: how useful to developers is it if the DOM removal is async, but still at the start time of the animation (rather than the end).

I think my comment above may reply to this too? If not, could you expand on what you mean a bit more, ideally with a code example?

argyleink commented 1 year ago

I agree a sugar sweet way to specify an animation to play for DOM mutations would be useful. I've been thoroughly enjoying view transitions for this, and am very familiar with the naming dances that need to happen for it all to work. I'm going to try and share where my mind is dwelling on when comparing exit-style and view transitions, and where each can shine.

For dom mutations and providing visual feedback to users, here's some of the transitions that are common:

  1. transition out / exit stage: remove()
  2. transition in / enter stage: append()
  3. transition to a new location / move on the stage: combo of remove and append
  4. transition to a new visual style: .style = '…' or classList.toggle()
  5. reduced motion

View Transitions has ways to handle all these scenarios, and i think with exit-style i'm most curious about configuration for when it shouldn't run. Like, if an element is moving position in a list, due to sort/filter/removal/etc. Would exit-style be a bit too blunt of an instrument for this case, as the elements would animate out (because they were removed) and then animate in again? If so, would I need to add a class to a "moving" element and ensure in my CSS that exit-style isn't applying to those? Now JS is articulating edge case handling, which is something we're trying to avoid here?

To me, seems like exit-style is sugar for "certain" node removal scenarios and not all of them. And to prevent it from running I'd need to intervene with JS classes to inform selectors? It's almost like I'd like a way to tell exit-style which DOM mutations to observe and intercept for the exit animation.

Meanwhile, view transitions have this logic already figured out. Authors can use CSS, no JS required beyond the call .startViewTransition(), to distinguish and customize exit stage, enter stage, and move within stage. The tradeoff is naming, but afaik, angular and nearly every front-end framework now has unique ID tracking of loop rendered lists, which can make naming trivial.

maybe there's a handoff point between exit-style and view transitions? exit-style for when you know the elements are only going to be removed and you want to animate it; view transitions when they might morph or change position in the dom?

there's also allow-discrete which has some super powers in that it can delay the transition of elements in overlay or not, in addition to delaying display:none until just the right moment. lots of moving parts here!

tldr; my biggest question is how do i tell exit-style when not to run? i can foresee moments where it'd run at times i'd like it not to, like sorting a list.

jessicajaniuk commented 1 year ago

I think the biggest thing here to me is that this addresses a developer need. As @LeaVerou pointed out, writing the code to handle element removal, even with a view transition, is not just a single line, and that code ends up having to be used everywhere an element is removed. That includes the overhead of adding an event listener for onanimationend and then the removal / garbage collection of it afterwards. If someone chooses to use a setTimeout instead, they have to match their CSS's duration and keep them in sync. And with a view transition, you have to match identifiers between JS and CSS. @exit-style would effectively eliminate all of that extra code.

Yes, frameworks and libraries can obfuscate this so users don't have to write it, but that means we're shipping that removal javascript along in everyone's bundle sizes, and we're still dealing with overhead of the event listener. We're constantly looking for ways to shave off as much as we can from bundle sizes as we can. So if we can eliminate the need to have code to handle the removal, that would be ideal for us in the framework space.

And of course, the symmetry of @starting-style and @exit-style is really nice and very discoverable for what it does. That's obviously not a strong reason for adopting it, but it is a nice bonus.

chrishtr commented 1 year ago

That includes the overhead of adding an event listener for onanimationend and then the removal / garbage collection of it afterwards. If someone chooses to use a setTimeout instead, they have to match their CSS's duration and keep them in sync. And with a view transition, you have to match identifiers between JS and CSS. @exit-style would effectively eliminate all of that extra code.

Just wanted to clarify that the View Transitions approach does not require any new event listeners. The element can be removed in the callback passed to startViewTransition. So for browsers that support View Transitions, it is this "one line of code" vs the "one line of code" to remove the element from the DOM via removeChild or similar. For this aspect, I don't see a substantial difference in amount of code developers have to write.

However, there will be a difference for the web developer when using startViewTransition in that the element won't be removed until after the next render, which may introduce some complexity they have to deal with due to their DOM state being briefly out of sync with any JS model state.

dbaron commented 1 year ago

I think the requirement stated in https://github.com/w3c/csswg-drafts/issues/9500#issuecomment-1783863101 -- to have the ability to do DOM node removal animations, starting from standard DOM node removal APIs that continue to have their usual effects on the DOM (synchronous removal), without unacceptable pre-removal performance costs -- just changes too many fundamental assumptions built into the Web. CSS has always worked on top of the DOM; we don't have the ability to render elements that aren't in the DOM, and changing that in either specifications or browser engines (and making sure it's reliable across all element types, all features that it needs to interact with, etc.) would be a very large amount of work. (Without thinking about it very hard I'd guess that we'd be talking about engineer years of work for each browser engine, and I'd be hard-pressed to answer whether we're talking about a single-digit or a double-digit number of engineer-years.)


There are also a bunch of very deep issues here that interact deeply with all of correctness, interoperability, and performance. Let me try to give a simple example. Suppose you have some sort of widget containing a list of items, each of which has class="item". Let's say these items have exit animations (for when an item goes away), and that they have the style:

.item { background: white; color: black; }
.item:nth-child(odd) { background: silver; }

Now let's suppose a script comes along and removes the 5th, 6th, and 7th items in the container, in that order:

container.children[4].remove();
container.children[4].remove();
container.children[4].remove();

During their exit animations, is the background of the items white or silver? Why?

Our normal practices for defining such behavior would be that the background of all three items would be silver, because at the time of their removal each item would be the 5th item in the container and thus the :nth-child(odd) selector would apply. However, reaching this result requires flushing style at the start of each removal operation, which is very bad for performance -- it causes the pattern of frequent interleaving of reads and writes that we push authors to avoid (and try to design new APIs so it's easy to avoid).

Could we avoid this without adding additional APIs (which would then not satisfy the original requirement that the developer can use the normal removal APIs)?

Well, then, suppose we didn't flush style at all during the removals. That would expose when the previous style flush was -- something that is entirely allowed to vary between implementations. For example, if there was an earlier element inserted into the list prior to the removals, and implementations differed (which they can) on whether a style update happened since that insertion, then specifying that we not flush style would cause the background color to vary between implementations.

Could we avoid that without adding other APIs? I don't think we can without doing something even more unreasonable (like flushing style before executing any script that comes after anything that might have been run asynchronously).

astearns commented 11 months ago

I removed the Agenda+ tag for now because the debate here seems ongoing.

yisibl commented 4 months ago

Framer Motion has an exit animation API: https://www.framer.com/motion/animate-presence/##exit-animations