Skip to content

[css-animations-2][web-animations-2] How should AnimationTrigger work? #12119

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
DavMila opened this issue Apr 25, 2025 · 33 comments
Open

[css-animations-2][web-animations-2] How should AnimationTrigger work? #12119

DavMila opened this issue Apr 25, 2025 · 33 comments

Comments

@DavMila
Copy link
Contributor

DavMila commented Apr 25, 2025

To support scroll triggered animations, animation-trigger was proposed in #8942 and included in the css-animations-2 and web-animations-2 specs. I’m filing this umbrella meta issue so the CSS working group can get familiar with the drafted API and share thoughts that might help answer questions in issues (11914, 11915, 11910, 11918, 11932, 12064) related to this feature.

To summarize, animation-trigger is a shorthand for some new animation triggering behavior usable both as CSS properties and Web Animation properties:

  • animation-trigger-type: whose values can be
    • once: The default value; plays animations only the first time its trigger condition is met. [Demo]
    • repeat: Plays and resets animations each time its trigger conditions are met. [Demo]
    • alternate Plays and reverses animations each time its trigger conditions are met. [Demo]
    • state Plays and pauses animations each time its trigger conditions are met. [Demo]
  • animation-trigger-timeline: the timeline in which the trigger’s conditions are evaluated
  • animation-trigger-range: the range of the timeline in which the trigger’s play condition is met,
  • animation-trigger-exit-range: the range of the timeline in which the trigger’s reset/reverse/pause (depending on the animation-trigger-type) condition is met.

All of the animation-trigger properties can also be specified to the web animations API (WAAPI) using the AnimationTrigger interface.
A scroll-triggered animation is one whose owning element’s trigger’s timeline is a ScrollTimeline or a ViewTimeline.

A quick note about the possibility of “time triggered animations” in the future:
The default animation-trigger-timeline is the document.timeline. This allows the behavior of pre-animation-trigger animations to be explained in the animation-trigger world - they are immediately “triggered” as time boundaries are not currently accepted as range values. The *-range properties leave the door open for “time triggered animations” to be supported in the future by allowing the ranges to be specified in terms of time, e.g. “5s”, rather than scroll position.

One thing we need to specify is exactly what it means for an animation to be triggered, i.e. how do triggers interact with Animation APIs like play(), pause() and cancel() which control playback of an Animation.

I see two ways of thinking about this (the second of which @flackr pointed out to me):

  1. play() as a programmatic trigger. In this way, play() is semantically the same as before, immediately causing an animation to start advancing. Triggers, OTOH, function by effectively implicitly invoking play() (and other APIs). A few questions occur to me about this:

    1. A CSS animation essentially calls play() when it is first generated. I imagine that if a developer sets up a trigger, they would not want the animation to run until its trigger condition is met so we would not want to call play(). We can address this by changing that portion of the CSS animations spec to not immediately play() but instead wait for its trigger. However, a developer will probably want their animation-fill-mode: both | backwards animation to be visually in effect at the first keyframe while waiting for the trigger (In the demos above, e.g. alternate, the elements start animating when fully scrolled into view, but when only partially in view, they are kept at the initial keyframe). This is different from the behavior of a WAAPI animation which has been declared but not triggered/play()ed. If you simply declare a WAAPI Animation, it will be idle and have no effect until play() is called, regardless of animation-fill-mode(demo). I think this should continue to be the case with AnimationTrigger, i.e. declaring anim = new Animation(...); anim.trigger = new AnimationTrigger(...); should leave the animation in an idle state with no visual effect until either play() is called or its trigger condition is met. So the question in this case is how a CSS animation with animation-fill-mode: both | backwards will be in effect having been neither triggered nor played. I suppose it might be okay for CSS animations to behave a bit differently from WAAPI animations in this regard.
    2. If a developer calls play() (or other APIs), should triggers still function? I propose “yes” in 11914 but can see how “no” makes sense too.
    3. Element.animate implicitly calls play, this means that if you call Element.animate naively the animation would play immediately without waiting for the trigger that you have specified.
  2. play() arms a trigger. This moves the job of advancing an animation away from play() and gives it to triggers. play() simply arms/enables the trigger and when the trigger’s condition is met, the animation begins to advance. This wouldn’t require the above adjustment for CSS animations with triggers as we could simply say that after play() has been called the animation-fill-mode: both animation can be in effect but frozen before its first keyframe. Only when its trigger condition is met will it begin to advance beyond the first keyframe. So CSS and WAAPI animations have the same behavior in this regard. However, a few things that will need to be figured out are:

    1. What pause() does. Currently, an idle animation will begin to have visual effect if pause() is called (though remaining paused). If we preserve this behavior, how should pause() on an idle animation affect a trigger? I think we should preserve this behavior and I think pause() should also be considered to arm the trigger, but leave it in a state where the animation isn’t advancing.
    2. Should play() and pause() still directly interact with the playback of an animation? Say an animation is play()ed and triggered and is advancing. I’d think a developer would expect that invoking pause() would still pause the animation. And so I think they’d also expect that calling play() would cause the paused animation to resume playing. So it would seem play() can’t be thought of as strictly interacting with the trigger but will need to retain the ability to directly control playback.
    3. If an animation has no trigger, i.e. animation.trigger = null, should calling play() do nothing or throw an error or something else? If we don't throw an error, should a trigger added later advance the animation? I'd think so.

Part of my hope is that getting ideas about the above questions will inform how specific animation-related concepts, e.g. playState, currentTime, animation events, animation ready promise, account for animation triggers.
Some examples of related questions are:

  • For an animation which has not been triggered
    • Should its playState be idle? paused? running? A new state? (keeping in mind that it could be visually in effect if its animation-fill-mode allows)
    • Should its currentTime be zero? null?
  • How should animation event timing work (also considering animation-play-state)?
  • How should animation ready promise resolution timing work?
@flackr
Copy link
Contributor

flackr commented Apr 28, 2025

With option 1, can you elaborate on how animation.cancel() works? If I cancel before it's triggered? While it's playing? Does it stop the animation from triggering? If so, what is this state and how do I later say that I want it to be triggerable again? When does a trigger become active for a programmatically constructed animation? As soon as the trigger is assigned?

For 1iii, element.animate should not immediately start playing the animation, it should only result in a trigger that is armed.

For option 2i and 2ii, I think that pause() should just pause the animation in whatever state its in. If it hasn't triggered yet then it wouldn't trigger even if the condition is met until you call play.
For 2iii I think it would be reasonable to consider it an error condition and throw an error, this is assuming that we have a default trigger of the document timeline which is "always triggered".

I'm leaning towards option 2 where the trigger being active is semi-synonymous with the animation being in a playing state, the trigger being an extension of the playing lifecycle of the animation (e.g. like being in the before phase) which feels easier to reason about. I imagine also that a trigger once animation might finish like a normal animation where a trigger repeat or alternate animation never stops "playing".

@ydaniv FYI

@DavMila
Copy link
Contributor Author

DavMila commented Apr 29, 2025

With option 1, can you elaborate on how animation.cancel() works? If I cancel before it's triggered? While it's playing? Does it stop the animation from triggering? If so, what is this state and how do I later say that I want it to be triggerable again? When does a trigger become active for a programmatically constructed animation? As soon as the trigger is assigned?

Oh Great question. I think animation.cancel() should have the effects of removing the animation's visual effect and disabling the trigger. For ways to re-enable a trigger, it seems like we'd either need to:

  • add an Animation.enableTrigger method to do just this. This method would
    • throw if animation.trigger is null,
    • do nothing if animation.trigger is already enabled,
    • else, enable the trigger.
  • or, spec that assigning an animation's trigger enables the trigger if the assigned value is not null (though I wonder if it would be weird that animation.trigger = animation.trigger; would be a thing that ought to work and that we'd expect authors to write)

For 1iii, element.animate should not immediately start playing the animation, it should only result in a trigger that is armed.

Hmm, I suppose we could think about it this way but I would have thought a developer calling element.animate signals that they want it to start playing and that this was why, despite animate returning an animation (which a developer could then call play on), animate just directly calls play?

For option 2i and 2ii, I think that pause() should just pause the animation in whatever state its in. If it hasn't triggered yet then it wouldn't trigger even if the condition is met until you call play.

That sounds reasonable but what about code that could have been relying on creating a waapi animation and immediately calling pause in order to keep the animation paused at the initial keyframe before calling play at some other point in time? Would pause still cause the animation to take effect without enabling the trigger under your proposal?
Somewhat separately, I guess this makes pause a way to temporarily disable the trigger while keeping its visual effect, but in a state that isn't advancing, which sounds nice to me.

@ydaniv
Copy link
Contributor

ydaniv commented Apr 29, 2025

With option 1, can you elaborate on how animation.cancel() works? If I cancel before it's triggered? While it's playing? Does it stop the animation from triggering? If so, what is this state and how do I later say that I want it to be triggerable again? When does a trigger become active for a programmatically constructed animation? As soon as the trigger is assigned?

I suggest we discuss each issue on the dedicated issue thread so that we can track and resolve these properly. These questions are discussed in #11914 and I suggested a solution for cancel()/play() here.

For 1iii, element.animate should not immediately start playing the animation, it should only result in a trigger that is armed.

This SGTM, but then we may want to propose a change that WAAPI animations are created with a default trigger with timeline set to the DocumentTimeline, but initially with state set to idle. And I think that will tie these ends together.

So CSS and WAAPI animations have the same behavior in this regard.

I think with the above suggestions we can reach that point where they actually do.

For option 2i and 2ii, I think that pause() should just pause the animation in whatever state its in. If it hasn't triggered yet then it wouldn't trigger even if the condition is met until you call play.

So far I proposed (also in the comment above), as @DavMila also suggested, that calling pause()/play()/reverse() don't affect the behavior of the trigger, but thinking about it again I see how your suggestion also makes sense, and probably makes it more coherent with regard to how these work with CSS Animations.
We could say that calling pause() also sets the trigger's state to idle. But then calling play() again will reset it again instead of allowing it to continue from its current position, so need to consider that.
And we also need to think how reverse() works in this context.

For 2iii I think it would be reasonable to consider it an error condition and throw an error, this is assuming that we have a default trigger of the document timeline which is "always triggered".

In this case then, what is the base-case for a null trigger?

I imagine also that a trigger once animation might finish like a normal animation where a trigger repeat or alternate animation never stops "playing".

I think you mean the animation stays relevant as we proposed in #11971.

@flackr
Copy link
Contributor

flackr commented Apr 30, 2025

I suggest we discuss each issue on the dedicated issue thread so that we can track and resolve these properly. These questions are discussed in #11914 and I suggested a solution for cancel()/play() #11914 (comment).

My concern with this approach is that in isolation it's easy to make decisions that don't fit a consistent higher level model. This is why I suggested we have a meta issue to agree on the fundamental model of how triggers fit into the animation ecosystem.

@flackr
Copy link
Contributor

flackr commented Apr 30, 2025

It might be helpful to agree on a set of expectations (prioritized in case some of them are not self consistent) and work through the details of how well each model explains them / requires fewer special cases. E.g. here's some ideas to start:

Of course, existing animations should work as they do today, i.e.

  • CSS animations or element.animate or calling play after constructing an animation should play the animation immediately
  • canceled or finished animations should have no effect and not be listed in getAnimations

Setting up an animation with a trigger that is not yet active (either via CSS* or element.animate* or the animation constructor and calling play) should result in:

  • an animation in its before phase (or after if reversed, as if the animation has a future start time that we just don't precisely know yet). Being in effect in the before phase ensures that developers can create triggered entry animations without needing to modify the style.
  • that will not advance until the trigger is active
  • and is listed in getAnimations

* CSS animations and Element.animate implicitly "play" their animations

If you create an idle animation with a trigger but don't play it (e.g. Animation constructor), just like with regular animations, it will:

  • not be actively producing an effect
  • not listed in getAnimations
  • not start if/when the trigger condition is active

If you call pause on an animation:

  • if it is currently playing, it should pause
  • if it is not yet triggered, it should not trigger even if the trigger condition becomes active, until play is called

If you explicitly call play on an animation (this one may be the crux of the differences), but I argue that it should be consistent with implicit play (from CSS or element.animate):

  • if play has not yet been called (either implicitly or explicitly), then it should arm the trigger,
  • if the trigger was already armed, perhaps it should immediately consider the trigger to have been tripped?

If you call cancel() on an animation

  • it should no longer have an effect
  • it should not trigger in the future

If you assign a trigger to a finished animation

  • probably the same as setting up an idle animation - i.e. shouldn't become active until play is called? Is this dependent on whether the finished animation is in effect (i.e. fill: forwards) or not?

@flackr
Copy link
Contributor

flackr commented Apr 30, 2025

@birtles @graouts FYI want to call your attention to this meta issue in case you have further thoughts about the animation trigger model

@birtles
Copy link
Contributor

birtles commented May 1, 2025

@birtles @graouts FYI want to call your attention to this meta issue in case you have further thoughts about the animation trigger model

Thanks @flackr. The expectations sound mostly reasonable to me. I definitely agree with trying to make Web Animations and CSS animations behave consistently at a basic level.

Since we're talking about the high-level architecture, I think it makes sense that triggers operate at the playback level and should ideally not need to touch the animation effect timing.

However, taking this a step further, I wonder if it is possible to have triggers be entirely independent of animations such that they exist as external mechanisms that simply call play(), pause(), cancel() etc. on their target animations. I think that would produce behavior that is easy to reason about for all cases.

If we want to distinguish between certain cases where the to-be-triggered animation fills backwards or not (e.g. distinguishing between Element.animate() and CSS animations vs new Animation()) then we could introduce some concept to the trigger like "arming" or whatever which effectively just calls pause() on the associated animation.

@flackr
Copy link
Contributor

flackr commented May 1, 2025

Since we're talking about the high-level architecture, I think it makes sense that triggers operate at the playback level and should ideally not need to touch the animation effect timing.

Agreed, I've been thinking of a trigger as being similar to an animation whose start time (which is not part of the effect timing) is in the future.

However, taking this a step further, I wonder if it is possible to have triggers be entirely independent of animations such that they exist as external mechanisms that simply call play(), pause(), cancel() etc. on their target animations. I think that would produce behavior that is easy to reason about for all cases.

I understand the rationale behind this model, and I think this represents the current direction (option 1) of how animation trigger works. I think we'll have to carefully work through the expectations to avoid any surprises though, as currently we have a very confusing state around cancelation.

To this end, it seems like we'd need to update the procedure to play an animation such that if the animation is currently idle and if it has a trigger, it pauses the animation (i.e. "arms" the trigger, though see my later response). Then a second play could start the animation right away if we think this makes sense.

I think though that where this gets confusing is what happens when you call pause()? Does this not have any effect if the trigger hasn't happened yet and leave the trigger able to play the animation? Does pausing an animation that has started playing due to the trigger leave it in a state where it will continue playing if the trigger condition happens again?

If we want to distinguish between certain cases where the to-be-triggered animation fills backwards or not (e.g. distinguishing between Element.animate() and CSS animations vs new Animation()) then we could introduce some concept to the trigger like "arming" or whatever which effectively just calls pause() on the associated animation.

Calling pause() would result in an active effect even if it was not fill: backwards though right? I think we'd have to pause conceptually just before time 0 (do we reflect this in currentTime?) or make the animation start time exclusive if it has a pending trigger.

@DavMila
Copy link
Contributor Author

DavMila commented May 1, 2025

However, taking this a step further, I wonder if it is possible to have triggers be entirely independent of animations such that they exist as external mechanisms that simply call play(), pause(), cancel() etc. on their target animations. I think that would produce behavior that is easy to reason about for all cases.

This matches how I had been thinking about it and I think it might be possible with a proposal I've just uploaded.

Regarding some of the expectations put forward by @flackr

Certainly, existing animations should work as before and I think gating AnimationTrigger behind enableTrigger and disableTrigger is a very clean way to do that. Existing CSSAnimations always “call enableTrigger” which immediately calls play() as they have document.timeline as their trigger’s timeline. Existing WAAPI animations never call enableTrigger and just rely on play() being directly called.

Setting up an animation with a trigger that is not yet active (either via CSS* or element.animate* or the animation constructor and calling play) should result in:

  • an animation in its before phase (or after if reversed, as if the animation has a future start time that we just don't precisely know yet)
  • that will not advance until the trigger is active
  • and is listed in getAnimations

CSS animations and Element.animate implicitly "play" their animations

It seems to me that the expectation that element.animate will not advance until the trigger is active and that element.animate implicitly calls play is kind of already assuming that play functions by arming a trigger and does not advance an animation. I guess in my proposal we can say that element.animate calls enableTrigger rather than play, which satisfies the idea that it doesn’t advance until its trigger’s condition is met. (Though in my mind still, element.animate is synonymous will animation.play)

If you create an animation with a trigger but don't play it (e.g. Animation constructor), just like with regular animations, it will:

  • not be actively producing an effect
  • not listed in getAnimations
  • not start if/when the trigger condition is active

On creating but not playing an animation, I think if you don’t play but you call enableTrigger we should play the animation when the trigger's condition is met.
On getAnimations, we can specify that an inactive trigger does not make an animation relevant but any other state will.

@birtles
Copy link
Contributor

birtles commented May 3, 2025

However, taking this a step further, I wonder if it is possible to have triggers be entirely independent of animations such that they exist as external mechanisms that simply call play(), pause(), cancel() etc. on their target animations. I think that would produce behavior that is easy to reason about for all cases.

This matches how I had been thinking about it and I think it might be possible with a proposal I've just uploaded.

Thanks. I had a read and I think that's pretty close to what I was imagining. However, I wonder if Animation.enableTrigger is even needed? Why can't that be a method on the trigger itself? i.e. AnimationTrigger.enable()?

On creating but not playing an animation, I think if you don’t play but you call enableTrigger we should play the animation when the trigger's condition is met. On getAnimations, we can specify that an inactive trigger does not make an animation relevant but any other state will.

I don't follow why that clarification is needed. If enableTrigger is not called, the animation won't be relevant, right? If enableTrigger is called—putting the animation in a kind-of paused state—then it will be relevant and should be returned by getAnimations.

@DavMila
Copy link
Contributor Author

DavMila commented May 5, 2025

However, taking this a step further, I wonder if it is possible to have triggers be entirely independent of animations such that they exist as external mechanisms that simply call play(), pause(), cancel() etc. on their target animations. I think that would produce behavior that is easy to reason about for all cases.

This matches how I had been thinking about it and I think it might be possible with a proposal I've just uploaded.

Thanks. I had a read and I think that's pretty close to what I was imagining. However, I wonder if Animation.enableTrigger is even needed? Why can't that be a method on the trigger itself? i.e. AnimationTrigger.enable()?

I am currently imagining that Animations and AnimationTriggers are not 1-to-1. I think we want to be able to set animation.trigger = ... which means multiple animations could share the same trigger, but we would not want enabling or disabling the trigger for one animation to affect other animations with the same trigger. So what I've been referring to as the trigger's state is really state that is tracked per-animation. I think my proposal needs to be updated with a TriggerState concept which each Animation has.

On creating but not playing an animation, I think if you don’t play but you call enableTrigger we should play the animation when the trigger's condition is met. On getAnimations, we can specify that an inactive trigger does not make an animation relevant but any other state will.

I don't follow why that clarification is needed. If enableTrigger is not called, the animation won't be relevant, right? If enableTrigger is called—putting the animation in a kind-of paused state—then it will be relevant and should be returned by getAnimations.

In my proposal, when we call enableTrigger we only pause the animation if it has fillMode: both|backwards. I felt that if it has fillMode : none it should still be returned by getAnimations, reflecting that it could be played by the trigger at some point in the future.

Edit: It occurred to me that under my proposal, an animation-fill-mode: none animation will not show visually before the trigger's condition is met. This might be right but i can also see how showing the animation anyway (regardless of animation-fill-mode: none) could be considered more consistent with existing CSS animations which simply didn't have this pre-trigger-condition-met phase and just played immediately, regardless of animation-fill-mode: none. To make the animation show, the update to my proposal would simply be to call pause and enableTrigger automatically on a new CSS animation, rather than just enableTrigger. Alternatively, enableTrigger can call pause regardless of fill-mode.

@ydaniv
Copy link
Contributor

ydaniv commented May 6, 2025

The way I see AnimationTriggers (and how they're currently speced) is a separate object, external to animations, that you attach onto Animations, and it uses the existing Animations' procedures that control playback (playing/reversing/pausing). They don't set anything on the animations, they just trigger the same procedures triggered when play()/reverse()/pause().

I think we have agreement on how they should work with existing features, as described above. I think we also agree that AnimationTriggers' domain is effects on Animations' playback.

In regard to @DavMila's proposal, I don't think we should be adding imperative API to triggers. IMHO this over complicates the API. But this proposal does build on some features of Triggers that I think we can agree on:

  1. Adding a fourth state as I suggested here, and we can agree on naming later.
  2. Introduce a "reset" procedure that stops the animation, rewinds it, and holds it in the before phase.
  3. Animations should be considered "relevant" if a trigger can potentially activate them in the future - i.e. when they're associated trigger is in repeat or alternate state.

Since I envisioned Triggers as a separate entity, it seems weird to me that calling play() on an animation with a null trigger should throw. And also, I suppose, why I considered triggers to work as option 1 describes it.

But the main question boils down to whether the playback methods produce the same effects as they currently do? Or do they delegate it to the triggers?
I think we should agree first on the behavior, and after that decide the mechanics.

I think we also have an agreement on the following behaviors:

  • Effect of pause(), as in it should simply freeze the animation in place and also suppress the trigger's effect.
  • Effect of cancel(), should cancel the animation's effect and suppress the trigger's effect.
  • Effect of initial animate()/play() should delegate to the trigger to apply its effect.
  • Effect of play() after a pause/cancel should resume/reset the animation and reactivate the trigger's effect.

The questions that remain open are the following behaviors:

  • What is the effect of calling a consecutive play()?
  • What is the effect of calling reverse()?

I think after we agree on expected behavior we can move forward to clarify the technical details.

@css-meeting-bot

This comment has been minimized.

@css-meeting-bot
Copy link
Member

The CSS Working Group just discussed [css-animations-2][web-animations-2] How should AnimationTrigger work?.

The full IRC log of that discussion DavidA: we have this concept of animation triggers, which is a way to declaratively tie *when* to play/pause to a timeline, same as scroll-driven animations
DavidA: end goal is to support scroll-triggered animations
DavidA: two appraoches we can do this. different implications for the existing concepts
DavidA: one appraoch is thinking about animation triggers as an internal animation mechanism
DavidA: the other is triggers as an external thing.
DavidA: diff between modesl is their relationship between triggers and play/pause/etc
DavidA: for internal, we think of play as arming the trigger, so responsibility is the trigger itself. calling play() just gets the trigger ready, and then when the trigger is satisfied it actually plays
DavidA: the exteneral way, the trigger is what invokes play()
DavidA: we're not looking for a resolution today, just feedback on which way, if any, the WG would lean towards
DavidA: i think that'll inform how a lot of other issues i've filed will resolve
DavidA: i can talk about details
q+
fantasai: i think it woudl be confusing if play() didn't make the animation play. i think the second animation makes more sense
q+
fantasai: coudl imagine an animation easily that the author wants to tirgger on a scorll position, but also have an explicit trigger from the uesr. those shoudl interact in a reasonable way
ack fantasai
DavidA: that would still be accommodated i nthe internal model
DavidA: what play() does, if the trigger isn';t armed, play() arms the trigger. if it's armed and tripped, play() starts the animation.
TabAtkins: I don't think that's right, if you have a trigger set up and you're not at the position, but want to have a button to trigger the animation, that won't trigger it in this case
ack ydaniv
ydaniv: on my last issue comment, i tried to enumerate the things i think we already agree on in the behavior. we shoudl solve the behavior first, what we expect
ydaniv: like what fantasai asked about
ydaniv: and once we agree on the behavior, can decide what the model should be
ydaniv: so i enumerated stuff i think we agree on, and rob has a comment on a lot of stuff about how it works with existing animation features, which i also think we have agreement on
https://github.com//issues/12119#issuecomment-2842322212
ydaniv: a couple of issues i think are still open are, what happens with multiple calls to play() with an already activated trigger?
ydaniv: and what happens if a user calls reverse()?
DavidA: on the first, repeated calls to play(), i think that's what i just talked about - i think the expectation is it would play again if you call it repeatedly
DavidA: for reverse(), i think the expectation is also tha tit would play, it's like play() it just auto-reverses it
flackr: another important aspect to consider is, if you cancel an animation, it won't have an effect until you play() again. if you construct an animation but don't play() it it wont' ahve an effect
flackr: so does assigning a trigger to it make it start having an effect? particularly in the "before" phase?
ack flackr
flackr: that seems surprising. seems like you need some way to "arm" the trigger that's not just implict
flackr: also from element.animate() it impliciatly calls play, we wouldn't expect it to start playing
flackr: note i'm not looking to address these issues right now
ack fantasai
fantasai: i'd ahve to read the whole thing to have a good sense, but i feel like the trigger should be another actor - the user (via JS) and the trigger, they're both animating on the animation and can each make it play or pause or whatever
fantasai: i think those calls interleave in that way, like there's two users that each have access to the play/pause buttons
(modulo some questions about the animation being "active" for before effect, etc, I also agree with fantasai initially)
I had the same model in mind as fantasai
s/animating on the/acting on the/
Rossen5: so, what resolution are you looking for?
DavidA: none right now, looking for feedback on what model to go with
DavidA: [summarizes the models again]
ydaniv, fantasai that model naively would mean that the animation isn't in getAnimations, and isn't in the before phase until triggered
DavidA: would probably be useful to know that, in the second model (trigger calls play()), to rob's point, we'll need some explicit apis to say when to enable and disable the trigger
DavidA: so if people still want to think about it, that's fine, otherwise we can resolve one way or another
(I think continuing on the issue and treating this as just raising awareness is just fine.)
fantasai: adding extra apis to enable/disable the trigger makes sense tome
s/tome/to me
Rossen5: all right, let's come back with a model with those extra APIs
q+

@flackr
Copy link
Contributor

flackr commented May 8, 2025

Another use case to consider is developer tools where you have a button that pauses all animations and resumes them (as chrome dev tools has). I would expect that if I do the following:

const paused = document.getAnimations().filter(anim => anim.playState == 'playing');
paused.forEach(anim.pause());

This would pause all of the animations in their current state, and then if I later call:

paused.forEach(anim => anim.play());

I'd expect it to restore the animations to the state they were in prior to pausing. With play arming the trigger, I think this code just works, however if it's a separate API to arm the triggers then developers will have to carefully handle that in some way.

@DavMila
Copy link
Contributor Author

DavMila commented May 8, 2025

paused.forEach(anim => anim.play());
I'd expect it to restore the animations to the state they were in prior to pausing. With play arming the trigger, I think this code just works, however if it's a separate API to arm the triggers then developers will have to carefully handle that in some way.

Thanks for bringing this up! This might be the key difference between the models boiling down to a question:

Is it more surprising that after I have set up a trigger, when I explicitly call play(), the animation plays even if the trigger condition hasn't been met or that after I have set up a trigger, explicitly calling play() doesn't play the animation if the trigger condition hasn't been met?

I'm inclined to think that because play() is a very widely used API that is already familiar to authors as immediately playing the animation, the latter is more surprising. I do acknowledge how the external model creates a need, e.g. for dev tools, to incorporate explicit support for triggers.

Also, there might be a bit of a parallel to the relationship between animation-play-state and play() in that even if you have animation-play-state: paused, play() ignores that and makes the animation advance. (I'll note that this is only relevant for CSS animations and not WAAPI animations) It would seem to make sense that play() and animation-trigger have a similar relationship.

Overall, it seems to me like a bit of a severe limitation that an author cannot play an animation because they have set up a trigger whose condition has not yet been met?

@flackr
Copy link
Contributor

flackr commented May 8, 2025

or that after I have set up a trigger, explicitly calling play() doesn't play the animation if the trigger condition hasn't been met?

Though to be clear, even though it wouldn't progress through the animation, the call to play arming the trigger would also change the animation play state, result in thefill: backwards effect being visible, and the animation being in getAnimations. So it's not as if it does nothing.

@ydaniv
Copy link
Contributor

ydaniv commented May 9, 2025

Another use case to consider is developer tools where you have a button that pauses all animations and resumes them (as chrome dev tools has). I would expect that if I do the following:

const paused = document.getAnimations().filter(anim => anim.playState == 'playing');
paused.forEach(anim.pause());
This would pause all of the animations in their current state, and then if I later call:

paused.forEach(anim => anim.play());
I'd expect it to restore the animations to the state they were in prior to pausing. With play arming the trigger, I think this code just works, however if it's a separate API to arm the triggers then developers will have to carefully handle that in some way.

Yes, I think we're on the same page here. These 2 expectations I listed above as things we agree on:

  • Effect of pause(), as in it should simply freeze the animation in place and also suppress the trigger's effect.
  • Effect of play() after a pause/cancel should resume/reset the animation and reactivate the trigger's effect.

I'm fine with the "arming the trigger" approach, as long as we agree on behavior and APIs.

'm inclined to think that because play() is a very widely used API that is already familiar to authors as immediately playing the animation, the latter is more surprising.

I agree that having play() only arm the trigger and not start playback may seem weird. Perhaps if both options are viable we should consider an addition to the API? Like .play({ force: true }) which disables the trigger and forces playback to rewind and advance?
I can easily see in my work both use-cases being valid in authoring tools. Both as resume "preview live behavior" with trigger armed and "force play" the animation.


Regarding constructing an Animation with a trigger using WAAPI, since the animation starts in idle state I think it's expected that the trigger doesn't play it immediately until play() is called, either of it "arms" the trigger or some by other way. I think that is something we agree on.

@birtles
Copy link
Contributor

birtles commented May 27, 2025

I've gone through the AnimationTrigger doc and with the help of some explanations from @DavMila I think it makes sense. However, I'm pretty sure I'm still missing some important understanding of how triggers work.

I wonder if anyone would be kind enough to explain to me what wouldn't work in the following very simplistic adaptation of the proposal:

  • We introduce means of temporarily overriding the interval endpoint behavior to Web Animations. We already need this in [web-animations-1] Make commitStyles use an endpoint-inclusive active interval #11881 so perhaps in a similar vein we allow a flag to be passed in to the pause procedure (at a spec level, not the API level) that allows overriding the endpoint behavior whilst paused. (Could scroll animations use something like this too?)
  • AnimationTriggers keep track of which animations they are controlling and call play / pause etc. on them.
  • An animation can be controlled by multiple triggers and, provided they have a well-defined evaluation order, the result is just whatever you'd get from calling play / pause / etc. on them in that order.
  • Animation's have no notion of whether they are being controlled by a trigger or not but they retain a list of triggers associated with them purely for convenience so that you can navigate from a CSSAnimation to any trigger targeting it. (Although even that might not be necessary? If the only use case is disassociating triggers then we could just let cancel() do that?)
  • Triggers are armed when they are constructed and there is no default trigger.
  • cancel() disassociates any triggers from the animation.

Sorry for the delay and for where I've surely overlooked important behaviors. I'm just trying to wrap my head around this and see if there's a simpler, less-coupled approach that lets us avoid adding more boolean state parameters to the already-complicated Web Animations model.

@DavMila
Copy link
Contributor Author

DavMila commented May 27, 2025

I've gone through the AnimationTrigger doc and with the help of some explanations from @DavMila I think it makes sense. However, I'm pretty sure I'm still missing some important understanding of how triggers work.

I think all your suggestions are good and I'd be happy with moving forward this way. I'll add a few details.

  • We introduce means of temporarily overriding the interval endpoint behavior to Web Animations. We already need this in [web-animations-1] Make commitStyles use an endpoint-inclusive active interval #11881 so perhaps in a similar vein we allow a flag to be passed in to the pause procedure (at a spec level, not the API level) that allows overriding the endpoint behavior whilst paused. (Could scroll animations use something like this too?)

Yes. I believe this works just fine conceptually for AnimationTriggers. The slight difference with the way that PR is currently written is that AnimationTrigger will want to be in the before phase for a forward-playing animation when the local time is at the before-active boundary time. But I guess those are just details and using a/this flag is conceptually fine... just thought I'd mention the detail in case there is anything about that that is unexpected (which I doubt).

  • AnimationTriggers keep track of which animations they are controlling and call play / pause etc. on them.

This seems good to me. We can track any relevant state within the AnimationTrigger rather than the Animation. In line with your suggestions in the doc, we can scrap Animation.trigger and just have AnimationTrigger.arm(Animation) and the AnimationTrigger will be aware of which animations its associated with.

  • An animation can be controlled by multiple triggers and, provided they have a well-defined evaluation order, the result is just whatever you'd get from calling play / pause / etc. on them in that order.

This seems good to me.

  • Animation's have no notion of whether they are being controlled by a trigger or not but they retain a list of triggers associated with them purely for convenience so that you can navigate from a CSSAnimation to any trigger targeting it. (Although even that might not be necessary? If the only use case is disassociating triggers then we could just let cancel() do that?)

Agreed. I've realized we can move all state tracking to the trigger. I'm okay with not even having that convenience. At the very least, if it's something that authors think they'd find useful and want, we can always add it. Maybe we can also think about an AnimationTrigger.getAnimations() API?.. and perhaps a document.getAnimationTriggers() API?

  • Triggers are armed when they are constructed and there is no default trigger.

Sounds good to me. With option 1 we really don't need a default trigger for explicitly constructed Animations. However I think CSS animations should still get triggers by default. Like we're currently trying to specify, animation-trigger defaults to a trigger that would play the animation immediately - so no change to existing animations - and custom animation-triggers also just work, neither being treated as a special case?

  • cancel() disassociates any triggers from the animation.

Yes. Further, explicit calls to play(), pause(), finish(), reverse(), setting currentTime/startTime also disassociate any triggers from the animation.

Sorry for the delay and for where I've surely overlooked important behaviors. I'm just trying to wrap my head around this and see if there's a simpler, less-coupled approach that lets us avoid adding more boolean state parameters to the already-complicated Web Animations model.

No worries, your input has been very helpful! I think we're converging on what the API should look like.

@flackr
Copy link
Contributor

flackr commented May 27, 2025

allow a flag to be passed in to the pause procedure (at a spec level, not the API level) that allows overriding the endpoint behavior whilst paused.

Yup, this sounds good and matches my thinking as well, to add at a spec level a start point exclusive paused state.

(Could scroll animations use something like this too?)

Scroll animations are endpoint inclusive in a running state, however, if we make this endpoint state modification apply while running as well then we could use this.

we can scrap Animation.trigger and just have AnimationTrigger.arm(Animation)

In this sort of a model I would just call it add/remove. This could either be AnimationTrigger.add/removeAnimation (similar to Element.add/removeEventListener) or AnimationTrigger.animations.add/remove (similar to Element.classList.

We should also consider whether we really need to have multiple animations per trigger. If instead we had an animation attribute or setter on the trigger we could say that setting it to a different animation disassociates the trigger from the original animation and might simplify some of the state management. If developers want to use the same trigger for multiple animations there just needs to be an easy way to construct a trigger with the same options as an existing trigger.

@ydaniv
Copy link
Contributor

ydaniv commented May 27, 2025

I'm generally fine with the above direction, with the following questions/reservations:

  • Using same trigger for multiple animations is probably more common, just like you can reuse a ViewTimeilne for multiple animations. If it still makes the flow too complicated then I'm willing to let this go.
  • Having a trigger property on an Animation constructor or as property for Element.animate() is a very ergonomic API. Could we have that defined as another way for simply invoking the AnimationTrigger.animations.add(animation) procedure?
  • Explicitly calling playback methods will also disassociate any triggers from the animation, so, this will still force a use of some internal flag to run the corresponding procedure, to denote the disassociation, right? Though this is probably better than maintaining a state.
  • I like the idea of constructed Animation's simply not having a trigger and just keep work as is, while CSS and .animate() ones get a new instance of the default trigger. Does that summarize this point correctly?

@DavMila
Copy link
Contributor Author

DavMila commented May 29, 2025

The doc Brian's last comment referred to is bit.ly/animation-trigger.

I've modified it (leaving the previous stuff crossed out) to reflect the simpler model and API we're currently thinking about.

@birtles
Copy link
Contributor

birtles commented May 30, 2025

Replying to @DavMila's comment:

  • We introduce means of temporarily overriding the interval endpoint behavior to Web Animations. We already need this in [web-animations-1] Make commitStyles use an endpoint-inclusive active interval #11881 so perhaps in a similar vein we allow a flag to be passed in to the pause procedure (at a spec level, not the API level) that allows overriding the endpoint behavior whilst paused. (Could scroll animations use something like this too?)

Yes. I believe this works just fine conceptually for AnimationTriggers. The slight difference with the way that PR is currently written is that AnimationTrigger will want to be in the before phase for a forward-playing animation when the local time is at the before-active boundary time. But I guess those are just details and using a/this flag is conceptually fine... just thought I'd mention the detail in case there is anything about that that is unexpected (which I doubt).

Awesome. Yes, that's a good detail to be aware of.

  • Animation's have no notion of whether they are being controlled by a trigger or not but they retain a list of triggers associated with them purely for convenience so that you can navigate from a CSSAnimation to any trigger targeting it. (Although even that might not be necessary? If the only use case is disassociating triggers then we could just let cancel() do that?)

Agreed. I've realized we can move all state tracking to the trigger. I'm okay with not even having that convenience. At the very least, if it's something that authors think they'd find useful and want, we can always add it. Maybe we can also think about an AnimationTrigger.getAnimations() API?.. and perhaps a document.getAnimationTriggers() API?

Yes, I think those approaches would work too.

  • Triggers are armed when they are constructed and there is no default trigger.

Sounds good to me. With option 1 we really don't need a default trigger for explicitly constructed Animations. However I think CSS animations should still get triggers by default. Like we're currently trying to specify, animation-trigger defaults to a trigger that would play the animation immediately - so no change to existing animations - and custom animation-triggers also just work, neither being treated as a special case?

From what I understand that seems fine. I think it would be mostly unobservable too unless we expose Animation.triggers or AnimationTrigger.getAnimations() etc.

  • cancel() disassociates any triggers from the animation.

Yes. Further, explicit calls to play(), pause(), finish(), reverse(), setting currentTime/startTime also disassociate any triggers from the animation.

I was thinking about this and I actually wonder if it's even necessary. For example, would it be so bad if cancel() just made the animation become idle and didn't tell the trigger?

Sorry for the delay and for where I've surely overlooked important behaviors. I'm just trying to wrap my head around this and see if there's a simpler, less-coupled approach that lets us avoid adding more boolean state parameters to the already-complicated Web Animations model.

No worries, your input has been very helpful! I think we're converging on what the API should look like.

Thank you so much for your understanding!

@birtles
Copy link
Contributor

birtles commented May 30, 2025

Replying to @flackr's comment:

(Could scroll animations use something like this too?)

Scroll animations are endpoint inclusive in a running state, however, if we make this endpoint state modification apply while running as well then we could use this.

Good point. This will take a little investigation. From what I understand:

  • For commitStyles we only need to tweak the endpoint behavior instantaneously.
  • For animation triggers we want to tweak the endpoint behavior while we're in the paused state such that it resets when we leave the paused state.
  • For scroll animations we want to tweak the endpoint behavior while we're in the running state.

We'll have to think about the cleanest way to cover those cases.

we can scrap Animation.trigger and just have AnimationTrigger.arm(Animation)

In this sort of a model I would just call it add/remove. This could either be AnimationTrigger.add/removeAnimation (similar to Element.add/removeEventListener) or AnimationTrigger.animations.add/remove (similar to Element.classList.

Yes, that sounds better.

We should also consider whether we really need to have multiple animations per trigger. If instead we had an animation attribute or setter on the trigger we could say that setting it to a different animation disassociates the trigger from the original animation and might simplify some of the state management. If developers want to use the same trigger for multiple animations there just needs to be an easy way to construct a trigger with the same options as an existing trigger.

Interesting. If that's the case, then I suppose you wouldn't even need add/remove—you could just pass the target to the constructor and then expose a target attribute for re-targeting purposes.

@birtles
Copy link
Contributor

birtles commented May 30, 2025

Replying to @ydaniv's comment:

  • Using same trigger for multiple animations is probably more common, just like you can reuse a ViewTimeilne for multiple animations. If it still makes the flow too complicated then I'm willing to let this go.

Good point. I think you have a much better understanding of the ergonomics and use cases of this feature than me.

  • Having a trigger property on an Animation constructor or as property for Element.animate() is a very ergonomic API. Could we have that defined as another way for simply invoking the AnimationTrigger.animations.add(animation) procedure?

I agree.

  • Explicitly calling playback methods will also disassociate any triggers from the animation, so, this will still force a use of some internal flag to run the corresponding procedure, to denote the disassociation, right? Though this is probably better than maintaining a state.

This is a really good point. I was thinking about this after making my proposal and it got me thinking: why do playback methods (including cancel()) even need to disassociate from the trigger?

If the trigger is acting at an arm's length and simply invoking play(), pause() etc. and the user interferes by calling pause() / play() etc. then the result is whatever you'd get if you called play() / pause() in that same sequence. If the user wants the trigger to no longer apply, they can disassociate it.

My original concern about cancel() not cancelling things would still be addressed in this model since cancel() would work as usual. The only caveat is that if the user later scrolled etc. the animation might start whereas if cancel() disassociated triggers, that would no longer happen.

I'm not sure but I think having less magic and less coupling might be preferable.

  • I like the idea of constructed Animation's simply not having a trigger and just keep work as is, while CSS and .animate() ones get a new instance of the default trigger. Does that summarize this point correctly?

I'm not sure that animate() needs a default trigger. I'm not sure CSS does either unless that makes the mapping to CSS default property values simpler.

@flackr
Copy link
Contributor

flackr commented May 30, 2025

My original concern about cancel() not cancelling things would still be addressed in this model since cancel() would work as usual. The only caveat is that if the user later scrolled etc. the animation might start whereas if cancel() disassociated triggers, that would no longer happen.

I still think cancel should result in an animation no longer being triggered. When we add an animation to a trigger it sits in a paused state waiting for the trigger, and that explains why it is "relevant" and returned by getAnimations. When you cancel, the animation would no longer be paused right? It would be strange to have two states that a triggered animation could be in.

@ydaniv
Copy link
Contributor

ydaniv commented Jun 1, 2025

Explicitly calling playback methods will also disassociate any triggers from the animation, so, this will still force a use of some internal flag to run the corresponding procedure, to denote the disassociation, right? Though this is probably better than maintaining a state.

This is a really good point. I was thinking about this after making my proposal and it got me thinking: why do playback methods (including cancel()) even need to disassociate from the trigger?

If the trigger is acting at an arm's length and simply invoking play(), pause() etc. and the user interferes by calling pause() / play() etc. then the result is whatever you'd get if you called play() / pause() in that same sequence. If the user wants the trigger to no longer apply, they can disassociate it.

Yeah, we've been going about this back and forth, and I see that we still have different opinions about it. I suppose after we moved the state from the animation over to the trigger probably solved most of the issues we had around state. So perhaps now both options are plausible.

My original concern about cancel() not cancelling things would still be addressed in this model since cancel() would work as usual. The only caveat is that if the user later scrolled etc. the animation might start whereas if cancel() disassociated triggers, that would no longer happen.

I still think cancel should result in an animation no longer being triggered. When we add an animation to a trigger it sits in a paused state waiting for the trigger, and that explains why it is "relevant" and returned by getAnimations. When you cancel, the animation would no longer be paused right? It would be strange to have two states that a triggered animation could be in.

I also agree that cancel() should leave the animation in a final state that can not apply any effect unless explicitly reset by the author.


Regarding @birtles' suggestion:

An animation can be controlled by multiple triggers and, provided they have a well-defined evaluation order, the result is just whatever you'd get from calling play / pause / etc. on them in that order.

How should that actually work in the following scenarios:

  • One trigger is tripped to primary and then a second trigger trips to primary. Should that re-play the animation? Or should the second trip be ignored?
  • One alternating trigger is set to inverse and then another alternating trigger is set to inverse. Should the animation be reversed twice so it now plays forward? Or should we change the definition of alternate?

It seems to me like both cases above force us to make this behavior stateful again.
I could see having both a click and view triggers be a possible use-case, but not sure about multiple view triggers.
I think this behavior needs more work.


I like the idea of constructed Animation's simply not having a trigger and just keep work as is, while CSS and .animate() ones get a new instance of the default trigger. Does that summarize this point correctly?

I'm not sure that animate() needs a default trigger. I'm not sure CSS does either unless that makes the mapping to CSS default property values simpler.

So far defaulting to a trigger with the DocumentTimeline worked well as the default behavior. We also need to keep in mind that timeline values also include none and auto, and that is how we modeled that part to be consistent.
From the author's perspective this is somewhat transparent. If they set the value to auto in CSS or read it then it seems consistent with the default triggering. Besides that, I suppose how this works under the hood is still open to interpretation.

@birtles
Copy link
Contributor

birtles commented Jun 2, 2025

My original concern about cancel() not cancelling things would still be addressed in this model since cancel() would work as usual. The only caveat is that if the user later scrolled etc. the animation might start whereas if cancel() disassociated triggers, that would no longer happen.

I still think cancel should result in an animation no longer being triggered. When we add an animation to a trigger it sits in a paused state waiting for the trigger, and that explains why it is "relevant" and returned by getAnimations. When you cancel, the animation would no longer be paused right? It would be strange to have two states that a triggered animation could be in.

Right, it would be in the idle state. As a result, there would be two different states: "paused by trigger" and "idle with a trigger". From an author's point of view, the only way to get back from "idle with a trigger" to "paused by a trigger" would be to re-associate with the trigger. So I think that's a pretty good argument for having cancel() disassociate from any triggers.

I think that's a bit unfortunate since it introduces hidden coupling between animations and triggers but perhaps in future it could be explained by having animations emit events and letting triggers listen to them (we've discussed animation mutation observers in other issues before).

Regarding @birtles' suggestion:

An animation can be controlled by multiple triggers and, provided they have a well-defined evaluation order, the result is just whatever you'd get from calling play / pause / etc. on them in that order.

How should that actually work in the following scenarios:

  • One trigger is tripped to primary and then a second trigger trips to primary. Should that re-play the animation? Or should the second trip be ignored?

It would do whatever primary triggering is defined to do. If triggering calls play(), it calls play() twice.

  • One alternating trigger is set to inverse and then another alternating trigger is set to inverse. Should the animation be reversed twice so it now plays forward? Or should we change the definition of alternate?

Again, it simply does whatever an inverse trigger is defined to do. So if that calls reverse() then it will be called twice and presumably will end up playing forward.

It seems to me like both cases above force us to make this behavior stateful again. I could see having both a click and view triggers be a possible use-case, but not sure about multiple view triggers. I think this behavior needs more work.

I'm afraid I'm not following why we'd need to make the behavior stateful. Could you elaborate?

I like the idea of constructed Animation's simply not having a trigger and just keep work as is, while CSS and .animate() ones get a new instance of the default trigger. Does that summarize this point correctly?

I'm not sure that animate() needs a default trigger. I'm not sure CSS does either unless that makes the mapping to CSS default property values simpler.

So far defaulting to a trigger with the DocumentTimeline worked well as the default behavior. We also need to keep in mind that timeline values also include none and auto, and that is how we modeled that part to be consistent.

Sorry for being dense here, but I'm not quite following this part. Could you elaborate on what "worked well" means here? And how the "none" and "auto" values play into this? Thank you!

@ydaniv
Copy link
Contributor

ydaniv commented Jun 2, 2025

My original concern about cancel() not cancelling things would still be addressed in this model since cancel() would work as usual. The only caveat is that if the user later scrolled etc. the animation might start whereas if cancel() disassociated triggers, that would no longer happen.

I still think cancel should result in an animation no longer being triggered. When we add an animation to a trigger it sits in a paused state waiting for the trigger, and that explains why it is "relevant" and returned by getAnimations. When you cancel, the animation would no longer be paused right? It would be strange to have two states that a triggered animation could be in.

Right, it would be in the idle state. As a result, there would be two different states: "paused by trigger" and "idle with a trigger". From an author's point of view, the only way to get back from "idle with a trigger" to "paused by a trigger" would be to re-associate with the trigger. So I think that's a pretty good argument for having cancel() disassociate from any triggers.

Yes, it would be nice to have a way to have a way to revert that state back to the initial paused state.

I think that's a bit unfortunate since it introduces hidden coupling between animations and triggers

I agree it's not optimal, but I think otherwise we'll need something like animation.reset() or trigger.enable(animation).

One trigger is tripped to primary and then a second trigger trips to primary. Should that re-play the animation? Or should the second trip be ignored?

It would do whatever primary triggering is defined to do. If triggering calls play(), it calls play() twice.

That sounds fine. What if both triggers are of type once? I suppose each trigger would call play() once if that's what the author chose. We could also argue the expected behavior would be to have the animation be triggered at most once. It's fine to say such behavior is only possible with some additional scripting, but still it's an edge case to be aware of.

One alternating trigger is set to inverse and then another alternating trigger is set to inverse. Should the animation be reversed twice so it now plays forward? Or should we change the definition of alternate?

Again, it simply does whatever an inverse trigger is defined to do. So if that calls reverse() then it will be called twice and presumably will end up playing forward.

Currently alternate behavior is defined to call play() only on first tripping and then it only calls reverse() on every enter/exit. In this way 2 overlapping triggers may cause the state of the playback direction and the trigger to be flipped, again, unless that was the intent.
Or, we could change the definition of alternate to be specifically to play forwards on entry, and backwards on exit, and then we'd have the re-play() behavior again.
Well, can't say I can think of a good use-case of either :P

It seems to me like both cases above force us to make this behavior stateful again. I could see having both a click and view triggers be a possible use-case, but not sure about multiple view triggers. I think this behavior needs more work.

I'm afraid I'm not following why we'd need to make the behavior stateful. Could you elaborate?

So exactly the options I explained above. For example, if you want 2 once triggers to play at most once.

So far defaulting to a trigger with the DocumentTimeline worked well as the default behavior. We also need to keep in mind that timeline values also include none and auto, and that is how we modeled that part to be consistent.

Sorry for being dense here, but I'm not quite following this part. Could you elaborate on what "worked well" means here? And how the "none" and "auto" values play into this? Thank you!

No worries, by "worked well" I meant matching the value of auto, which is a valid value for a timeline, explain the default behavior of autoplaying an animation, and also be the default value for the trigger's timeline.
So if we scrap the notion of "default trigger" we say CSS animations and Element.animate() animations simply automatically call the play animation procedure. But we still need an initial value for animation-trigger, and to define what animation-trigger-timeline: auto means. That is what the default trigger managed to tie together in a coherent way.

@DavMila
Copy link
Contributor Author

DavMila commented Jun 2, 2025

Right, it would be in the idle state. As a result, there would be two different states: "paused by trigger" and "idle with a trigger". From an author's point of view, the only way to get back from "idle with a trigger" to "paused by a trigger" would be to re-associate with the trigger. So I think that's a pretty good argument for having cancel() disassociate from any triggers.

+1 to having cancel() disassociate the trigger.

For other playback APIs, I don't have a particularly strong sense of what the right thing to do is, tbh. I like the idea that pause() has the impact of suppressing the trigger(s), but I wouldn't want to treat pause() as an exception (though cancel() is perhaps arguably already an exception) so I'm inclined to think all the other APIs should have the same disassociation effect, though I can see how the exact opposite makes sense too, i.e. none of them have a disassociation effect.

At a (somewhat hand-wavy) higher level, I've been thinking that we are giving authors 2 "modes" of controlling their animations' playback. "Manual" mode in which they need to invoke the playback APIs explicitly and "autopilot" mode in which they set up the trigger conditions and let that take care of controlling the animations' playback. And if, for whatever reason, the author invokes one of the playback APIs, they're "switching into manual mode" (like taking the wheel of a self-driving car :) ) and the trigger(s) will relinquish control of playback.

I wondered whether it would help with making a choice here to think about which choice would be less disruptive to revert but it feels to me like the remedy for either choice is the same. If we auto-disassociate now, we could have some optional param, e.g. play(disassociateTriggers=true) in the future to allow authors avoid the disassociation. The remedy for not auto disassociating would be equivalent: play(disassociateTriggers=false). Or perhaps some Animation.apisDisassociateAllTriggers boolean property.

I think we've resolved the main question at the heart of this issue. I had filed 11914 earlier about the question of playback APIs disabling/disassociating triggers, So perhaps we could discuss it there?

Sorry for being dense here, but I'm not quite following this part. Could you elaborate on what "worked well" means here? And how the "none" and "auto" values play into this? Thank you!

No worries, by "worked well" I meant matching the value of auto, which is a valid value for a timeline, explain the default behavior of autoplaying an animation, and also be the default value for the trigger's timeline. So if we scrap the notion of "default trigger" we say CSS animations and Element.animate() animations simply automatically call the play animation procedure. But we still need an initial value for animation-trigger, and to define what animation-trigger-timeline: auto means. That is what the default trigger managed to tie together in a coherent way.

+1. I had a similar feeling that giving CSS animations a trigger by default makes for a cleaner story of how they are played. A bit of trifling case perhaps but if an author specified animation-trigger so as to get the initial/default value then supplying the default trigger (rather than no trigger at all) makes sense?

@DavMila
Copy link
Contributor Author

DavMila commented Jun 2, 2025

I've update my proposal to reflect the current thinking.

The two lingering questions seem to be:

  • should playback APIs disassociate triggers? and
  • should CSS animations always get triggers?

I think that the main components in the proposal will stay pretty much the same regardless of how we answer those questions. If we think the proposal is a good representation of our current position, I'd like to put this issue back on the agenda so the working group can give their go/no-go. thanks!

aarongable pushed a commit to chromium/chromium that referenced this issue Jun 4, 2025
The discussion about how AnimationTrigger should work is ongoing. This
comment[1] captures the main points about how triggers should work.

The hope is to have a simple implementation which we can help guide what
the edge cases are, which ones we care about, and what to do about the
ones we care about.

A few points about the behavior worth highlighting are:

1. We automatically call addAnimation for CSS animations.

2. When animation-play-state is "paused" and isn't being ignored, the
   trigger should not play the animation.

3. Trigger-related state tracking is done only in the
   AnimationTrigger. However, we keep
   Animation::AnimationTriggerData::css_play_state just so the trigger
   can know not to play animations for which `animation-play-state`
   is paused.

A few notes about some other changes made in this patch.

1. inspector-protocol/tracing/animation-expected.txt is adjusted to
   expect "paused" instead of running because when we call
   addAnimation on the CSS animation, we'll pause it and Pause will
   be the first function to supply data to the
   inspector_animation_agent via NotifyProbe.

2. ElementAnimations::SetCompositedClipPathStatus is modified in a way
   that reflects the intention that kNotComposited and kNoAnimation
   clear clip_path_paint_worklet_candidate_, even if either of those
   was already recorded as the composited_clip_path_status_. The test
   case in clip_path_paint_definition_test.cc revealed (by
   unconditionally calling pause when the trigger is enabled) that
   ElementAnimations::RecalcCompositedStatus might set
   clip_path_paint_worklet_candidate_ and try to set the status to
   kNotComposited and kNoAnimation, but, because there is no change in
   the status, fail to clear clip_path_paint_worklet_candidate_.

[1]
w3c/csswg-drafts#12119 (comment)

Bug: 390314945
Change-Id: I5bd41e2edbf874ce8e2f5662709f31143be8f1c5
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6605724
Reviewed-by: Kevin Ellis 
Commit-Queue: David Awogbemila 
Cr-Commit-Position: refs/heads/main@{#1469239}
chromium-wpt-export-bot pushed a commit to web-platform-tests/wpt that referenced this issue Jun 4, 2025
The discussion about how AnimationTrigger should work is ongoing. This
comment[1] captures the main points about how triggers should work.

The hope is to have a simple implementation which we can help guide what
the edge cases are, which ones we care about, and what to do about the
ones we care about.

A few points about the behavior worth highlighting are:

1. We automatically call addAnimation for CSS animations.

2. When animation-play-state is "paused" and isn't being ignored, the
   trigger should not play the animation.

3. Trigger-related state tracking is done only in the
   AnimationTrigger. However, we keep
   Animation::AnimationTriggerData::css_play_state just so the trigger
   can know not to play animations for which `animation-play-state`
   is paused.

A few notes about some other changes made in this patch.

1. inspector-protocol/tracing/animation-expected.txt is adjusted to
   expect "paused" instead of running because when we call
   addAnimation on the CSS animation, we'll pause it and Pause will
   be the first function to supply data to the
   inspector_animation_agent via NotifyProbe.

2. ElementAnimations::SetCompositedClipPathStatus is modified in a way
   that reflects the intention that kNotComposited and kNoAnimation
   clear clip_path_paint_worklet_candidate_, even if either of those
   was already recorded as the composited_clip_path_status_. The test
   case in clip_path_paint_definition_test.cc revealed (by
   unconditionally calling pause when the trigger is enabled) that
   ElementAnimations::RecalcCompositedStatus might set
   clip_path_paint_worklet_candidate_ and try to set the status to
   kNotComposited and kNoAnimation, but, because there is no change in
   the status, fail to clear clip_path_paint_worklet_candidate_.

[1]
w3c/csswg-drafts#12119 (comment)

Bug: 390314945
Change-Id: I5bd41e2edbf874ce8e2f5662709f31143be8f1c5
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6605724
Reviewed-by: Kevin Ellis 
Commit-Queue: David Awogbemila 
Cr-Commit-Position: refs/heads/main@{#1469239}
chromium-wpt-export-bot pushed a commit to web-platform-tests/wpt that referenced this issue Jun 4, 2025
The discussion about how AnimationTrigger should work is ongoing. This
comment[1] captures the main points about how triggers should work.

The hope is to have a simple implementation which we can help guide what
the edge cases are, which ones we care about, and what to do about the
ones we care about.

A few points about the behavior worth highlighting are:

1. We automatically call addAnimation for CSS animations.

2. When animation-play-state is "paused" and isn't being ignored, the
   trigger should not play the animation.

3. Trigger-related state tracking is done only in the
   AnimationTrigger. However, we keep
   Animation::AnimationTriggerData::css_play_state just so the trigger
   can know not to play animations for which `animation-play-state`
   is paused.

A few notes about some other changes made in this patch.

1. inspector-protocol/tracing/animation-expected.txt is adjusted to
   expect "paused" instead of running because when we call
   addAnimation on the CSS animation, we'll pause it and Pause will
   be the first function to supply data to the
   inspector_animation_agent via NotifyProbe.

2. ElementAnimations::SetCompositedClipPathStatus is modified in a way
   that reflects the intention that kNotComposited and kNoAnimation
   clear clip_path_paint_worklet_candidate_, even if either of those
   was already recorded as the composited_clip_path_status_. The test
   case in clip_path_paint_definition_test.cc revealed (by
   unconditionally calling pause when the trigger is enabled) that
   ElementAnimations::RecalcCompositedStatus might set
   clip_path_paint_worklet_candidate_ and try to set the status to
   kNotComposited and kNoAnimation, but, because there is no change in
   the status, fail to clear clip_path_paint_worklet_candidate_.

[1]
w3c/csswg-drafts#12119 (comment)

Bug: 390314945
Change-Id: I5bd41e2edbf874ce8e2f5662709f31143be8f1c5
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6605724
Reviewed-by: Kevin Ellis 
Commit-Queue: David Awogbemila 
Cr-Commit-Position: refs/heads/main@{#1469239}
@DavMila
Copy link
Contributor Author

DavMila commented Jun 9, 2025

We have an experimental implementation of the behavior described in the proposal currently in Chrome Canary. It can be enabled by navigating to "chrome://flags/#enable-experimental-web-platform-features" and switching the setting to "Enabled". I've uploaded a demo with an example where you can interact with the animation by scrolling as well as by clicking buttons that invoke the playback APIs.

It's still not clear to me how we should answer issue 11914 but I think with the agreement we have, we can put this issue on the agenda and get a resolution on the initial question in my first comment above:

Should animation triggers function by invoking existing playback methods (play(), pause(), reverse()) or by being armed/disarmed by these methods? Our current agreement is for the former behavior (invoking playback methods.)

We could also resolve on adding the AnimationTrigger methods described in the proposal: AnimationTrigger.addAnimation(Animation) and AnimationTrigger.removeAnimation(Animation).

@DavMila DavMila added the Agenda+ label Jun 9, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

6 participants