Skip to content

[css-animations-2][web-animations-2] How should animation-play-state interact with animation-trigger? #12064

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
ydaniv opened this issue Apr 9, 2025 · 23 comments

Comments

@ydaniv
Copy link
Contributor

ydaniv commented Apr 9, 2025

Before the addition of animation-trigger it was possible to control the playback state of an animation using the animation-play-state property or using the Web Animations API via the .play()/.pause()/.reverse() methods or setting the animation's playbackRate.

A Trigger applies an effect on its associated animation based on its type and state, and these effects are currently defined by the playing, pausing, and reversing an animation procedures. That is now yet another method to control playback state.

To resolve conflicts between properties specified in CSS and state changes by usage of the WAAPI, it is specified how the latter takes precedence over changes to corresponding properties in CSS.

But now we have a new CSS property, animation-trigger, that may conflict with another property, animation-play-state, and it may not be trivial to determine how these two properties interact with each other and what should be the playback state when their both specified to non default values.

With the default values for animation-trigger being once auto we may already have an existing definition of this interaction:

  • If the animation-play-state is set to running then the trigger behaves normally as specified.
  • If the animation-play-state is set to paused then the trigger's behavior is overridden and the animation's playback remains paused.
  • Notice that animation events must still trigger, if the trigger's timeline has active and inside the attachment range, just as a newly created animation with a play-state set to paused still triggers the animationstart event. However, we may say that animations with a trigger with a view timeilne must only trigger animation events according to their progress inside their attachment range. So for example, animation with animation-trigger: view() repeat will only trigger its animationstart event once it enters its 0-100% range.

Additionally, changes to playback state via WAAPI continue overriding animation-play-state as specified, but don't override changes to animation-trigger.

Proposal

  • animation-trigger does not override the behavior of animation-play-state.
  • If animation-play-state is set to running then the associated trigger's effects continue as specified.
  • If the animation-play-state is set to paused then the associated trigger's effects are overridden and the animation remains paused.
  • Animation events still trigger normally when paused but only when entering/exiting the trigger's attachment range.

/cc @flackr @DavMila @birtles

@DavMila
Copy link
Contributor

DavMila commented Apr 9, 2025

Thanks for filing this!

Proposal

  • animation-trigger does not override the behavior of animation-play-state.
  • If animation-play-state is set to running then the associated trigger's effects continue as specified.
  • If the animation-play-state is set to paused then the associated trigger's effects are overridden and the animation remains paused.

I think I agree with the proposal overall with one (perhaps significant) difference: I wouldn't say that a trigger's effects are overridden by animation-play-state. I think of it more as the condition for advancing an animation (forwards or backwards, so that it reflects its timeline time) now requires, in addition to animation-play-state, whether its trigger condition has been met.

I think where this really makes a difference is when we consider what happens when we have a scroll-based trigger (no time-based triggers for now) and the exit condition is met. With animation-play-state: paused should a repeat trigger still reset the animation? should an alternate trigger still reverse the animation? I think these answers should be yes - which means the trigger isn't really overridden, but in all cases (assuming a waapi call hasn't stopped us from listening to animation-play-state) we would still need animation-play-state to be running to make progress on the animation with time.

A related question I was thinking about was: when an animation with a state trigger is paused by its trigger upon exiting the exit range, if its animation-play-state is then toggled from running to paused and then back to running should it continue to make progress? I think so.

@birtles
Copy link
Contributor

birtles commented Apr 10, 2025

I haven't thought about the detail here but broadly speaking the layering makes sense to me. At very least, WAAPI needs to be able to override the trigger mechanism so that DevTools etc. can inspect triggered animations.

@ydaniv
Copy link
Contributor Author

ydaniv commented Apr 10, 2025

I think I agree with the proposal overall with one (perhaps significant) difference: I wouldn't say that a trigger's effects are overridden by animation-play-state. I think of it more as the condition for advancing an animation (forwards or backwards, so that it reflects its timeline time) now requires, in addition to animation-play-state, whether its trigger condition has been met.

Yes, that's gist of it. I guess "overridden" is less accurate here.

I think where this really makes a difference is when we consider what happens when we have a scroll-based trigger (no time-based triggers for now) and the exit condition is met. With animation-play-state: paused should a repeat trigger still reset the animation? should an alternate trigger still reverse the animation? I think these answers should be yes - which means the trigger isn't really overridden, but in all cases (assuming a waapi call hasn't stopped us from listening to animation-play-state) we would still need animation-play-state to be running to make progress on the animation with time.

Yes, agreed. With alternate the direction is still flipped, but playback still remains paused and current time remains fixed on the same hold time.

A related question I was thinking about was: when an animation with a state trigger is paused by its trigger upon exiting the exit range, if its animation-play-state is then toggled from running to paused and then back to running should it continue to make progress? I think so.

Here I think is where the animation-play-state is more sort of overriding the behavior of the trigger. I think the playback state should remain paused if it's set that way in CSS, regardless of the trigger's effect. If it's set to running then the trigger's effect starts affecting the playback state.

@ydaniv
Copy link
Contributor Author

ydaniv commented Apr 10, 2025

@birtles:

I haven't thought about the detail here but broadly speaking the layering makes sense to me. At very least, WAAPI needs to be able to override the trigger mechanism so that DevTools etc. can inspect triggered animations.

Do you mean overriding animation-trigger with animation.trigger = ...? Or overriding by calling play()/pause()/reverse()/etc?
Note that we agreed on the interaction of the latter in this issue.

@birtles
Copy link
Contributor

birtles commented Apr 10, 2025

Do you mean overriding animation-trigger with animation.trigger = ...? Or overriding by calling play()/pause()/reverse()/etc? Note that we agreed on the interaction of the latter in this issue.

Yes, the latter.

@ydaniv
Copy link
Contributor Author

ydaniv commented Apr 11, 2025

@birtles so the question is what do you mean by "override"?
In the above mentioned issue we proposed that if a user invoked some method of the WAAPI it still applies its effect, but does not prevent the trigger from continuing to function afterwards.

@birtles
Copy link
Contributor

birtles commented Apr 14, 2025

so the question is what do you mean by "override"?
In the above mentioned issue we proposed that if a user invoked some method of the WAAPI it still applies its effect, but does not prevent the trigger from continuing to function afterwards.

@ydaniv That sounds fine but it makes me wonder, does cancel() still cause the animation to have no effect (including the "put it in the before phase with a zero time" behavior)?

That is, can a user still do element.getAnimations().forEach(anim => anim.cancel() and be sure that the element is no longer affected by animations on it? (With the exception of animations on ancestors/descendants)

@ydaniv
Copy link
Contributor Author

ydaniv commented Apr 14, 2025

@ydaniv That sounds fine but it makes me wonder, does cancel() still cause the animation to have no effect (including the "put it in the before phase with a zero time" behavior)?

That is, can a user still do element.getAnimations().forEach(anim => anim.cancel() and be sure that the element is no longer affected by animations on it? (With the exception of animations on ancestors/descendants)

Sure, any method should apply its effect, so calling cancel() should terminate that animation and any effect it applies.

@birtles
Copy link
Contributor

birtles commented Apr 14, 2025

Sure, any method should apply its effect, so calling cancel() should terminate that animation and any effect it applies.

But haven't we defined that if a trigger is attached to an animation then it ends up being put in the before phase with a zero time? Meaning that if it has a backwards fill, that will take effect? So in order to remove that does cancel() need to disassociate from the trigger?

@ydaniv
Copy link
Contributor Author

ydaniv commented Apr 14, 2025

But haven't we defined that if a trigger is attached to an animation then it ends up being put in the before phase with a zero time? Meaning that if it has a backwards fill, that will take effect? So in order to remove that does cancel() need to disassociate from the trigger?

Currently, any animation has an initial value of animation-trigger: once auto, so it has a trigger, and calling cancel() on that animation should still set its currentTime to unresolved, so that animation's effect will be removed.
I think that, by itself, should render the associated trigger effectless.
Do you think we need to also explicitly disassociate the trigger?

@DavMila
Copy link
Contributor

DavMila commented Apr 14, 2025

Sure, any method should apply its effect, so calling cancel() should terminate that animation and any effect it applies.

But haven't we defined that if a trigger is attached to an animation then it ends up being put in the before phase with a zero time? Meaning that if it has a backwards fill, that will take effect? So in order to remove that does cancel() need to disassociate from the trigger?

This seems related to the issue @ydaniv linked earlier. There, our current proposal is that animation-trigger can still play, pause, etc an animation after it has been played, paused, etc by a WAAPI. It sounds like you think cancel should be different in that the trigger should no longer be able to do those things?

Currently, any animation has an initial value of animation-trigger: once auto, so it has a trigger, and calling cancel() on that animation should still set its currentTime to unresolved, so that animation's effect will be removed. I think that, by itself, should render the associated trigger effectless. Do you think we need to also explicitly disassociate the trigger?

If we don't want the trigger to be able to perform those actions on the animation after cancel() I think we would need to explicitly disassociate it since, for example, a repeat trigger can set the currentTime of the animation? (that link says "start time" currently but we intend to modify it to currentTime)
Otherwise, in the following instance the trigger could still cause the animation to have visual effect:

  • user scrolls into default range, causing animation (which has fill-mode: both) to play,
  • page calls cancel() on the animation, stopping the animation from taking visual effect,
  • user scrolls outside the exit range, causing the animation to be reset to its first keyframe / 0% progress.

@birtles
Copy link
Contributor

birtles commented Apr 14, 2025

Currently, any animation has an initial value of animation-trigger: once auto, so it has a trigger, and calling cancel() on that animation should still set its currentTime to unresolved, so that animation's effect will be removed.
I think that, by itself, should render the associated trigger effectless.
Do you think we need to also explicitly disassociate the trigger?

@ydaniv Maybe the problem is I don't understand how the following behavior is intended to be realised:

The animation effect associated animation remains in its before phase and stays at zero current time.

What part of the timing model is actually updated here?

cancel() doesn't set the currentTime to unresolved. currentTime is a calculated property, you can only affect it by updated the start time and hold time.

Depending on how an idle trigger interacts with the timing model, cancel() may no longer produce the result that the animation no longer affects its target(s).

@birtles
Copy link
Contributor

birtles commented Apr 14, 2025

This seems related to the issue @ydaniv linked earlier. There, our current proposal is that animation-trigger can still play, pause, etc an animation after it has been played, paused, etc by a WAAPI. It sounds like you think cancel should be different in that the trigger should no longer be able to do those things?

@DavMila Not really, I'm just trying to understand if cancel() can still clear animation effects.

@ydaniv
Copy link
Contributor Author

ydaniv commented Apr 15, 2025

@ydaniv Maybe the problem is I don't understand how the following behavior is intended to be realised:

The animation effect associated animation remains in its before phase and stays at zero current time.

What part of the timing model is actually updated here?

Perhaps this part does need a more precise definition.
The intent is that a trigger will prevent playback from starting and that the animation's effect will still produce output according to its fill-mode value.
Since we can't prevent the animationstart event from triggering, I wonder if it even makes any sense to keep the "in before phase" part?

Maybe it would have made more sense to set hold time to 0, but then playing an animation already does that.
So perhaps it's best that we don't specify anything here that may contradict with the model, and instead introduce a new flag, like hold playback (similar to how auto-rewindworks around it for a specific use-case). This can be initiallyfalse, and the trigger needs only to set it to true` when setting a trigger of an animation.

Then upon cancel() we could simply specify disassociating the trigger from the animation + setting hold playback to false. And the rest can stay as is.


a repeat trigger can set the currentTime of the animation? (that link says "start time" currently but we intend to #12013 to currentTime)

Ooh, yes, we need to change that as well.
I think more accurate would be to say that it should seek the animation back to hold time of 0.

@birtles does the above suggestions work better for you?

@DavMila
Copy link
Contributor

DavMila commented Apr 15, 2025

Currently, any animation has an initial value of animation-trigger: once auto, so it has a trigger, and calling cancel() on that animation should still set its currentTime to unresolved, so that animation's effect will be removed.
I think that, by itself, should render the associated trigger effectless.
Do you think we need to also explicitly disassociate the trigger?

@ydaniv Maybe the problem is I don't understand how the following behavior is intended to be realised:

The animation effect associated animation remains in its before phase and stays at zero current time.

What part of the timing model is actually updated here?

Maybe it makes more sense to have the currentTime of a yet-to-trigger animation be unresolved. Then, we can modify the before phase definition to account for animation triggers. Something like
"An animation effect is considered to be in its before phase if its local time is unresolved and its associated animation has a trigger which:

  1. has a non-monotonic timeline, and
  2. either is in the idle state or is in the inverse state and is a repeat trigger."

.. better worded perhaps and also doing something similar for a "backwards" direction animation and the after phase.

cancel() doesn't set the currentTime to unresolved. currentTime is a calculated property, you can only affect it by updated the start time and hold time.

Depending on how an idle trigger interacts with the timing model, cancel() may no longer produce the result that the animation no longer affects its target(s).

If we do what I mentioned just above, we would be introducing a state where you could be in the before phase and have an unresolved local time. If we change the active time calculation to first check whether local time is unresolved I think that means we can ensure that cancel still stops an animation with a trigger from affecting its target?

@DavMila
Copy link
Contributor

DavMila commented Apr 16, 2025

Ah, I see a problem with my proposal: cancel() stops an animation’s effect from being in effect by setting startTime to unresolved. This means currentTime is unresolved and so the effect’s local time is unresolved and the effect is not in effect. If yet-to-trigger animations also have an unresolved currentTime, their effect cannot be in effect prior to triggering, regardless of fill-mode, even without cancel(). So we have a conflict in interpreting an unresolved local time: not in effect because cancel() was called, or still being in effect because fill-mode allows and we are yet to trigger?

Perhaps we can:

  1. Define an awaiting trigger update algorithm for an animation animation as:
If animation’s trigger has a monotonic timeline: return false.

If animation’s trigger is in the`idle` state, return true.

If animation’s trigger is a repeat trigger in the `inverse` state, return true.

Return false.
  1. Have the animation’s currentTime be the before-active boundary time of its effect (or the active-after boundary time in the case of a “backwards” animation).

  2. Update the definition of “in the before phase” to include:

…, or
the animation direction is “forwards” and running the awaiting trigger update algorithm on its associated animation returns true.

(Similar update for "in the after phase")

This way, cancel(), by making the effect’s local time unresolved, still stops the animation from being in effect regardless of the trigger. And, when cancel() has not been called and an animation has not been triggered, a forwards direction animation’s effect will be considered to be in its before phase and if it has fill-mode: both/backwards, it’ll have an active time and be in effect, otherwise it won’t.

@flackr
Copy link
Contributor

flackr commented Apr 16, 2025

If animation’s trigger has a monotonic timeline: return false.

Why can't a monotonic timeline be a trigger? I mean I know we don't have the ability to specify meaningful ranges today but it seems like this doesn't need a special case, all monotonic timeline triggers specifiable today will be in a permanently triggered state AFAICT.

This way, cancel(), by making the effect’s local time unresolved, still stops the animation from being in effect regardless of the trigger. And, when cancel() has not been called and an animation has not been triggered, a forwards direction animation’s effect will be considered to be in its before phase and if it has fill-mode: both/backwards, it’ll have an active time and be in effect, otherwise it won’t.

I think we need to stop the trigger if cancel is called, does your proposal do this? I'm not sure how you would re-arm the trigger, unless we decide that play on an animation with a trigger does not actually mean to play it but just to arm the trigger - which I think might provide a nice overall consistency to the way everything works.

@DavMila
Copy link
Contributor

DavMila commented Apr 16, 2025

If animation’s trigger has a monotonic timeline: return false.

Why can't a monotonic timeline be a trigger? I mean I know we don't have the ability to specify meaningful ranges today but it seems like this doesn't need a special case, all monotonic timeline triggers specifiable today will be in a permanently triggered state AFAICT.

Removing that check should be fine. We probably just need to check that its trigger isn't null.

I think we need to stop the trigger if cancel is called, does your proposal do this?

No, I'm not proposing we disable the trigger when cancel is called. I can see how that would make sense to a developer but are you suggesting that as a way of addressing the current issue we are discussing (preserving the effect/behavior of cancel) or are you suggesting that separately/generally (this would be related to #11914)? I don't think we need to disable the trigger to preserve the behavior of cancel.

@flackr
Copy link
Contributor

flackr commented Apr 16, 2025

The reason we are applying the before fill phase is because we have an animation that can trigger and animate from the 0% keyframe and the animation wants it to animate from the value it has at 0% rather than jumping to that value when the animation is triggered. As a developer I think the intent expressed by canceling the animation is not just to temporarily remove the fill: backwards but to completely skip the animation unless / until I explicitly play it.

If we make it so that "playing" an animation means to arm the trigger, this nicely explains how an animation constructed by element.animate or css animations with a trigger would not begin advancing immediately - they enter their before (or after depending on playback direction) phase until the trigger condition is met.

Note that this is not a specific way of addressing this particular issue but a broad framework for thinking about how to make sense of the impact of animation triggers on all of the interactions with other animation properties. I'm open to considering whether play should just be treated as an immediate trigger condition rather than arming the trigger, exploring some common use cases for using these apis would be helpful.

@birtles
Copy link
Contributor

birtles commented Apr 17, 2025

Just a quick comment to say I really like the broad thinking here. Thank you for all those suggestions. It would be nice, if possible, to avoid complicating the timing model algorithms with trigger-specific flags and conditions.

(If they do prove necessary, however, I hope we can abstract some general flag(s)—like the hold playback suggestion—that we can use to keep triggers at arm's length from the core timing calculations.)

@ydaniv
Copy link
Contributor Author

ydaniv commented Apr 20, 2025

I agree with @flackr on the behavior: calling cancel() on an animation with a trigger should cancel its effect and deactivate its trigger.

If play() is called on the same animation, while it still has a trigger associated to it, then it's probably expected for the trigger to be activated again. We could say that in this case the trigger is reset to idle state, and consequently rewind the animation.
I think that's what I'd expect as a developer, and it's compatible with the default-trigger behavior we have today.

If the developer wishes to remove the trigger they can always do so explicitly.
So for me that answers the question about: "whether to disassociate the trigger on cancel()?".

What still remains open is deciding how exactly this mechanism works.
How should calling cancel() on an animation with an associated trigger work so that it becomes completely deactivated?
How should calling play() on a cancelled animation with an associated trigger so that the trigger is reset to idle- which should in turn reset the animation and replay it?

If I'm not mistaken, we didn't change anything in Playing an animation procedure, and only updated how update animations and send events procedure works, to first update triggers.
We should probably change Cancelling an animation procedure to also account for the associated trigger. And then we can probably add the missing link when play() is called on a cancelled animation.

@ydaniv
Copy link
Contributor Author

ydaniv commented Apr 24, 2025

I have another direction at this:

  • Change the name of Trigger's state idle to something else, like pending. I think I chose a bad name here which doesn't correctly reflects the behavior of the state.
  • Add a new Trigger state named idle, which behaves as the name suggests, makes the Trigger effectless.
  • When running Cancelling an animation procedure we add an extra step that sets associated trigger's state to idle.
  • Add a step in Updating Animation Trigger State that checks if state is idle, and if so it aborts the procedure.
  • Add a step in Playing an animation that checks if the associated trigger's state is idle, and if so resets it to pending.

I think this solves the cancel()/play() issue and avoids adding any additional complications to the existing model.

@ydaniv
Copy link
Contributor Author

ydaniv commented Apr 27, 2025

Since this issue was originally on interaction of animation-trigger and animation-play-state and we hijacked it to interaction with cancel(), I moved that discussion into #11914, and I'll copy the original proposal here again:

Proposal

  • animation-trigger does not override the behavior of animation-play-state.
  • If animation-play-state is set to running then the associated trigger's effects continue as specified.
  • If the animation-play-state is set to paused then the associated trigger's effects are overridden and the animation remains paused.
  • As specified in CSS Animations, any successful call to play()/pause()/reverse() or setting of startTime still overrides animation-play-state.

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

4 participants