csswg-drafts icon indicating copy to clipboard operation
csswg-drafts copied to clipboard

[web-animations-2][css-animations-2] How should unspecified trigger range boundaries be resolved?

Open DavMila opened this issue 10 months ago • 26 comments

In the web animations spec, animation-trigger-range-start, animation-trigger-range-end, animation-trigger-exit-range-start and animation-trigger-exit-range-end specify the boundaries of the ranges in which an AnimationTrigger will play, pause, reverse, etc. an associated animation. Valid values for these properties are similar to animation-range, e.g. “contain 10%”, “cover 100%”, etc. How should these properties be resolved when not specified?

Some options we can consider are:

  1. Using the boundaries of the named range (which defaults to “cover” for view timelines). E.g. “contain 10%” resolves to “contain 10% contain 100%”. This is what’s done for animation-range.

  2. Using the boundaries of the scroll range (instead of the view timeline range). E.g. “contain 10%” resolves to “contain 10% scroll 100%”

Option 2 is useful for scenarios in which an author essentially wants a single point beyond which scrolling would trigger an animation. The author could just write, e.g., “contain 0%”. OTOH if they want to match the “contain” boundary then they have to set “contain 0% contain 100%".

With option 1, the tradeoff is the converse: they can simply specify “contain 0%” to mean “contain 0% contain 100%” and if they mean to refer to the entire scroll range then they have to use the scroll keyword, e.g. “contain 0% scroll 100%”.

I suggest going with option 1 which aligns with animation-range so developers don’t have to remember that the named ranges keywords expand one way for animation-range and a different way for animation-trigger-*range.

DavMila avatar Mar 13 '25 13:03 DavMila

I think option 1 is what authors would more likely expect when using view timelines. Also, like you said, it's nice if we can have a consistent resolving for ranges across different properties.

Also notice that exit range properties allow an auto value as default which falls back to the corresponding value of the default range, and not to normal.

I suppose if you set only contain 10% for animation-trigger-exit-range-start it will resolve the end similar to option 1 above.

ydaniv avatar Mar 13 '25 15:03 ydaniv

Also notice that exit range properties allow an auto value as default which falls back to the corresponding value of the default range, and not to normal.

Ah yes, perhaps another issue worth resolving on: I agree that it would be nice to have a default value for the exit range boundaries that says "match the default range" but I know there's been some concern (example) in the past about specifying auto in a way that doesn't mean "user agent should do what makes sense". This doesn't mean that user agents can't treat auto this way, but maybe is a reason to instead consider using normal to mean "match the default range."

This would mean normal for animation-trigger-exit-range is different from normal for animation-range but I think this wouldn't be too problematic a difference for developers and may even be the more meaningful option if we establish some relationship between the default and exit ranges like 11910 discusses.

It would also mean that an author that means to specify "cover" range (which "normal" would have meant) as the exit range but not as the default range in the shorthand (animation-trigger) would need to specify the first 2 longhands, animation-trigger-range-start and animation-trigger-range-end, and "cover", i.e. they couldn't write animation-trigger: contain 0% or animation-trigger: contain 0% contain 100%. They would have to write animation-trigger: contain 0% contain 100% cover... but this would be the same with auto if the exit-range properties default to auto.

DavMila avatar Mar 13 '25 17:03 DavMila

Ah yes, perhaps another issue worth resolving on: I agree that it would be nice to have a default value for the exit range boundaries that says "match the default range" but I know there's been some concern (https://github.com/w3ctag/design-reviews/issues/1011#issuecomment-2460300421) in the past about specifying auto in a way that doesn't mean "user agent should do what makes sense". This doesn't mean that user agents can't treat auto this way, but maybe is a reason to instead consider using normal to mean "match the default range."

That is why I added the auto value, since normal is already specified to have a specific meaning and I did not want to overload that meaning depending on the context. So instead I figured that auto in this context means exactly that: "the UA should do what makes sense, which is match the corresponding value from the default range".

You think it's less appropriate here?

This would mean normal for animation-trigger-exit-range is different from normal for animation-range but I think this wouldn't be too problematic a difference for developers and may even be the more meaningful option if we establish some relationship between the default and exit ranges

In this case I'd prefer not having to parse normal based on the context. OTOH, I think auto is a value that can be parsed differently based on context, so would prefer that value to do a lookup on another property's value. But I'm open to hear otherwise.

It would also mean that an author that means to specify "cover" range (which "normal" would have meant) as the exit range but not as the default range in the shorthand (animation-trigger) would need to specify the first 2 longhands, animation-trigger-range-start and animation-trigger-range-end, and "cover", i.e. they couldn't write animation-trigger-range: contain 0% or animation-trigger-range: contain 0% contain 100%. They would have to write animation-trigger-range: contain 0% contain 100% cover... but this would be the same with auto if the exit-range properties default to auto.

That is a separate issue I briefly raised here. I think I'll open a separate issue for that.

ydaniv avatar Mar 17 '25 17:03 ydaniv

@DavMila please see https://github.com/w3c/csswg-drafts/issues/11948

ydaniv avatar Mar 17 '25 17:03 ydaniv

While re-reading the thread that sparked scroll-triggered animations, I noticed this question was also covered in that thread.

See https://github.com/w3c/csswg-drafts/issues/8942#issuecomment-1721492499 and the next few comments.

My first interpretation was that animation-trigger: view() alternate entry 100%; would resolve to animation-trigger: view() alternate entry 100% contain 100%;.

But then @flackr clarified that my interpretation was incorrect and that we should expand to “infinite” behavior instead of contain 100%.

So using scroll 100% here comes closest to this. As this is for the default trigger range, I would assume an omitted -end value for the exit trigger range would be scroll 0%

bramus avatar Mar 25 '25 09:03 bramus

That is only if you specifically want the single triggering behavior, like the comment says:

If we want to support a single trigger point that is active for any position after it we need to have different behavior from the default animation range single value.

In that case you need that infinite behavior. So if you want a repeating/alternating triggering with a single point you need to specify something like: entry 100% scroll 100%.

But omitting a range boundary is not the same as trying to specify a single boundary. Currently the spec says that default ranges are resolved similar animation-range, and omitted exit range boundaries default to their corresponding default range boundaries - being auto.

ydaniv avatar Mar 25 '25 20:03 ydaniv

But omitting a range boundary is not the same as trying to specify a single boundary.

I guess this comes down to something like bikeshedding because both approaches (expanding similar to animation-range or expanding to scroll 100%) give authors the same capabilities. I'm nudged towards having the default range expand to scroll 100% because that lets an author who wants to specify a single point specify a single value :)

DavMila avatar Mar 26 '25 16:03 DavMila

I guess this comes down to something like bikeshedding because both approaches (expanding similar to animation-range or expanding to scroll 100%) give authors the same capabilities. I'm nudged towards having the default range expand to scroll 100% because that lets an author who wants to specify a single point specify a single value :)

So what you're saying is not really bikeshedding, but rather resolve on a different default value for animation-trigger-range-end, right? Or otherwise have normal resolve to scroll 100% instead of cover 100%?

Well we should also take into account not specifying anything, keeping both start and end as normal would result as scroll 0% 100%, right? IMO having these as defaults kind of defeats the purpose, right? Also, I'd prefer to keep it consistent with animation-range-end and normal resolving as well, being cover 100%.

ydaniv avatar Mar 26 '25 17:03 ydaniv

Thinking about this a bit more: since the capabilities are the same either way, making it necessary for an author to have to specify, for example, entry 100% scroll 100% for the single point case really doesn't seem so bad, especially since it means we can stay consistent with animation-range by defaulting to normal and also preserve the meaning of normal.

I think the alternate example in @flackr 's demo demonstrates a common pattern where the element slides in and out at the bottom and top of the viewport, corresponding (roughly) to the cover range. I think it makes sense for the default setting, where a view() timeline has been declared, to cater to this use case (all the author needs write is contain or cover). If we default to scroll 100% for the end of the default range, the author has to write contain 0% contain 100%, otherwise the slide out at the top of the viewport won't happen.

I guess one other option is to make the default range dependent on the type of trigger, e.g. once defaults to scroll 0% scroll 100%, alternate, repeat and state default to cover 0% cover 100%. In which case maybe an auto keyword for the default range might make sense. But this, or a similar scheme, seems a bit developer-unfriendly to me as they'd have to keep track of which type maps to which default range.

Also, for the exit range I think using auto as the default and having it mean "match the default range" sounds good. That way the meaning of normal is fully preserved.

DavMila avatar Mar 27 '25 17:03 DavMila

Great, so IIUC this means close as no change for now?

ydaniv avatar Mar 27 '25 17:03 ydaniv

Great, so IIUC this means close as no change for now?

I thought this, along with other issues #11971, 11915, 11914, would be good to put on the WG's agenda so we can come to a formal resolution on the expected behavior. Maybe agenda+?

DavMila avatar Mar 28 '25 18:03 DavMila

Thought I'd post one detail that occurred to me about this:

animation-trigger-exit-range: auto normal should mean the same as animation-trigger-exit-range: auto auto which means "match the trigger range."

and

animation-trigger-exit-range: normal auto should mean the same as animation-trigger-exit-range: normal normal which refers to the cover range.

The first case represents a slight expansion of the meaning of normal so that normal is defined in the newly introduced context of auto.

DavMila avatar Apr 02 '25 20:04 DavMila

animation-trigger-exit-range: auto normal should mean the same as animation-trigger-exit-range: auto auto which means "match the trigger range."

I don't think I understood this. auto is always resolved to match the corresponding value of the default range, and normal is resolved to match the corresponding edge of the full range. So for: animation-trigger-range-start: entry 20%; animation-trigger-exit-range-start: auto You'll get: animation-trigger-range-start: entry 20%; animation-trigger-exit-range-start: entry 20%.

And for: animation-trigger-range-start: entry 20%; animation-trigger-exit-range-start: normal You'll get: animation-trigger-range-start: entry 20%; animation-trigger-exit-range-start: cover 0%.

The point is that in the same range -start and -end don't affect each other in case of having the auto value. They're always resolved against the default range, as in animation-trigger-range.

ydaniv avatar Apr 06 '25 08:04 ydaniv

And for: animation-trigger-range-start: entry 20%; animation-trigger-exit-range-start: normal You'll get: animation-trigger-range-start: entry 20%; animation-trigger-exit-range-start: cover 0%.

Right, I was mistaken about the meaning of normal. I thought contain normal in the animation-range context was contain contain but it is contain cover, matching your example.

So for: animation-trigger-range-start: entry 20%; animation-trigger-exit-range-start: auto You'll get: animation-trigger-range-start: entry 20%; animation-trigger-exit-range-start: entry 20%.

Because of the way I thought normal worked, I thought auto should try to do something like that too. But since that isn't the case, I think it makes sense that auto simply resolves as the default range. Though now I wonder if this simpler definition calls for a more precise keyword than auto since the behavior is always the same. Maybe default.. match?

DavMila avatar Apr 08 '25 14:04 DavMila

Because of the way I thought normal worked, I thought auto should try to do something like that too. But since that isn't the case, I think it makes sense that auto simply resolves as the default range. Though now I wonder if this simpler definition calls for a more precise keyword than auto since the behavior is always the same. Maybe default.. match?

Sure, I'm open to suggestions if we wish to bikeshed the auto value.


I'll agenda+ this issue with issue: Q: how shall we name the value for exit range which resolves to matching the corresponding default range? Proposal: auto

ydaniv avatar Apr 08 '25 14:04 ydaniv

So, I took Scroll-Triggered Animations for a spin in Chrome Canary with flags and the thing described in the issue right here tripped me up, especially when used with the play-once behavior (or even play-forwards play-backwards).

DEMO: https://codepen.io/bramus/pen/VYazoNb/34c1da5a6127d22c25ace478b1b07b2b

CSS used:

.grid img {
  animation: reveal 1s linear both;
  timeline-trigger: --t view() contain 250px;
  animation-trigger: --t play-once;
}

The enter-range is set to contain 250px which, as per this issue, expands to contain 250px cover 100%.

This range is then also used for the exit-range as per spec.

auto: The start (for timeline-trigger-exit-range-start) or end (for timeline-trigger-exit-range-end) is equal to the start/end of the timeline trigger’s enter range.

The issue I am encountering is that when slowly scrolling it works as expected, but when flinging the page, some items get skipped because they cross over into the exit-range, so the animation never gets triggered:

https://github.com/user-attachments/assets/506d707f-38b7-442d-844c-c8cb5657af5b

Here, I don’t think that scrolling slow vs scrolling fast should make a difference. Both ways of scrolling should have the same end result.

Now you could say that Chrome should evaluatie the trigger active range faster, but that doesn’t solve everything. Things not getting activated also becomes problematic when reloading the page:

https://github.com/user-attachments/assets/474a4fe6-e7b5-4905-89db-7496f326d88c

I would have expected that all items above the scroll position after reload would have already animated in, but that is not the case.

The fix here for authors is to set the timeline-trigger-range to contain 250px scroll 100% … which leads me to this issue because if using contain 250px for the range did that by default, then both of these problems I am having would not exist in the first place.

Thing I built in the demo is, I believe, the most common use-case for STA: trigger an animation once when crossing a line. Therefore, I think we should make that really easy for authors to do. Setting the end boundary to scroll 100% when omitted would allow that.

(This suggested behavior would, however, differ from how animation-range handles omitted animation-range-end values, though. But maybe that’s fine.)

bramus avatar Nov 21 '25 15:11 bramus

Agenda+’ing to seek a resolution to get the basic use-case detailed above right.

I think this means that:

  • The initial value of normal for <timeline-trigger-range-start> results in scroll 0%.
  • The initial value of normal for <timeline-trigger-range-end> results in scroll 100%.
  • When <timeline-trigger-range-end> is omitted in the <timeline-trigger-range>, it is set to normal.

/cc @DavMila @flackr

bramus avatar Nov 24 '25 08:11 bramus

The issue I am encountering is that when slowly scrolling it works as expected, but when flinging the page, some items get skipped because they cross over into the exit-range, so the animation never gets triggered:

That is not an issue, it's by design. Your example here is very specific. In this case, if you scroll beyond a certain line you expect content to have animated already. In most cases this is not desired, but rather, to only animate if the element is in view can the user can visually observe the animation.

The goal of the feature is to "animate when in view", and not "animate beyond a certain line". But you can specify that if you wish to.

I would have expected that all items above the scroll position after reload would have already animated in, but that is not the case.

This is a very specific use-case, and I'd argue it's not the most common one. You'll get the same if you navigate to a page with a fragment to an anchor in a lower part of the page, and then scroll back up.

Or even imagine an element that's hidden, user scrolls passed it, then scroll backs up, interacts with the page and reveals it. It should animate only there and then.

Thing I built in the demo is, I believe, the most common use-case for STA: trigger an animation once when crossing a line. Therefore, I think we should make that really easy for authors to do. Setting the end boundary to scroll 100% when omitted would allow that.

I disagree about this being the "most common use-case", I think the most common use-case is "animate once when visually visible to the user". For example, imagine a section in a site with content animating in. Author probably wants this scene to be played visually to the user regardless of point of entry into the view, i.e. top/bottom.

So, I propose to leave as is.

ydaniv avatar Nov 24 '25 09:11 ydaniv

This is a very specific use-case, and I'd argue it's not the most common one.

This is what GSAP (demo), AOS (demo), and Motion (demo) do by default, though. If you scroll all the way down (slow or fast) on those demos, all contents have animated in. And if you then refresh (which restores the scroll position) all contents above the scrollport are already in their animated-in end-state.

Looking at some real production sites that are a scroll-triggered-heavy website like Apple’s (e.g. https://www.apple.com/iphone/) it also behaves like that.

bramus avatar Nov 24 '25 12:11 bramus

Hmmm... well, the Motion demo doesn't have STAs, and the GSAP one doesn't have an STA either. Looks like AOS actually watches scroll event, and doesn't really check view entering, unfortunately.

And still, IMHO this is a design decision, not an expected behavior from a generic API based on view().

I think authors will expect the default to work similar to view timelines by default, or at least similar to how you'd polyfill this simply with IntersectionObserver.

ydaniv avatar Nov 24 '25 17:11 ydaniv

I think authors will expect the default to work similar to view timelines by default, or at least similar to how you'd polyfill this simply with IntersectionObserver.

There's a significant distinct difference between not specifying a second value for a scroll trigger, and not specifying the second value for a view timeline.

For a view timeline, an end value and corresponding distance are strictly required to compute progress,

For a scroll trigger, it's easy to simply ask the question have you passed the single declared point yet or not. I think that one-sided triggers are a very common and important use case that we should make as easy as possible, and this was explicitly identified as a goal in the original ideation issue #8942.

There are 2 cases where I see a downside to assuming a single specified point extends to infinity (or end of the timeline's possible range):

  1. Wanting to trigger an animation only when an element is in view. If the other specifies animation-trigger: --t view() contain that will today do this, but if we assume that is any offset >= contain 0% then they'd have to explicitly list the endpoint, e.g. animation-trigger: --t view() contain contain.
  2. It's biased to making it easy to trigger on values greater than a specified start point only.

Maybe there is some syntax we could add to represent the infinity or -infinity default for the first / last value in a range which could address both of these, while still being more ergonomic than having to specify scroll as the other point, e.g.

animation-trigger: --t view() contain 100px (forwards|backwards)?

or

animation-trigger: --t view() (before|after)? contain 100px

If you specify forwards/after then the single point would be the start, and the end would be effectively infinity. If you specify backwards/before, then the start would be effectively -infinity and the end would be your specified point. Open to bikeshedding on the specific keywords.

flackr avatar Dec 02 '25 20:12 flackr

I think that one-sided triggers are a very common and important use case that we should make as easy as possible, and this was explicitly identified as a goal in the original ideation issue https://github.com/w3c/csswg-drafts/issues/8942.

I don't quite agree here. All of the examples @bramus showed above either don't have a trigger, or they were implemented watching scroll positions for legacy reasons. I doubt how common and important that use-case really is. It surely exists, and we should solve it, and we have, sort of, since scroll only "breaks out" of cover, but not the nearest scroll container.

I checked a bit, anime.js just watches the scroll event, but replicates what view() does, and Motion uses IntersectionObserver.

I argue that the common and important use-case is to follow behavior that's closer to IntersectionObserver or even ViewTimeline.

Wanting to trigger an animation only when an element is in view. If the other specifies animation-trigger: --t view() contain that will today do this, but if we assume that is any offset >= contain 0% then they'd have to explicitly list the endpoint, e.g. animation-trigger: --t view() contain contain.

Exactly, and I don't think that the exception here justifies breaking consistency.

Maybe there is some syntax we could add to represent the infinity or -infinity default for the first / last value in a range which could address both of these, while still being more ergonomic than having to specify scroll as the other point, e.g.

I'm totally fine with that if we have one. But we probably want something that escapes nearest scroll container, like root-start/root-end.

ydaniv avatar Dec 05 '25 14:12 ydaniv

~I think that to some extent we are going to have to deviate from animation-range regardless: otherwise, entry x% expands to entry x% entry 100% but I think we've been assuming it expands to entry x% normal(cover 100%) as entry x% entry 100% is likely a less-than-common use case.~

~I think that one-sided triggers are a very common and important use case that we should make as easy as possible, and this was explicitly identified as a goal in the original ideation issue #8942.~

~I don't quite agree here. All of the examples @bramus showed above either don't have a trigger, or they were implemented watching scroll positions for legacy reasons.~

~Hmm, even so I'd have thought we'd agree that the one-sided case is very common and important.~

~This seems to come down to personal preference as either choice offers the same functionality so I think putting it to a vote is a reasonable way to resolve the question. Hopefully the issue gets on the agenda this week and we can get a vote. I'll summarize the question: when only one end of the trigger range is specified, e.g. entry x% (as opposed to the 2 ends, e.g. entry x% exit y%), what should this imply the other end is?~

~1. normal (cover 100%), restricting the trigger range to when the element is in view (this is closer to the behavior of animation-range).~

~2. end of the timeline scroll 100%, making the trigger "one-sided" as the end of the timeline is a boundary that can't be crossed.~

~If we decide in favor of option 2, we will probably need a name to indicate this as we would likely want to avoid repurposing normal. Based on flackr's comment above, infinity is one option. I would add to that auto which would be my preference. scroll 100% is another option but might not be a good name for pointer timelines.~

DavMila avatar Dec 08 '25 21:12 DavMila

After chatting with @flackr , I realize I misunderstood the suggestion from his last comment. We are now aligned with @ydaniv on being consistent with animation-range while exploring the future possibility of supporting syntax that more explicitly caters to the "one-sided" case.

It would be good to get some formal consensus on this so I'll call for an async resolution:

Background

A timeline trigger defines a trigger range (or "enter range" as the spec calls it) which is the range within the underlying timeline in which the trigger will play the animation. The start and end of the range can be specified using the same keywords as animation-range, e.g. entry, contain, cover, etc. For example, the range can be specified as contain 10% contain 90%.

Question

The question we want to resolve on is this: when the end of the range is not specified, what should it default to?

The options we are considering are:

  1. same default as animation-range, i.e. contain expands to contain 0% contain 100%, entry expands to entry 0% entry 100%, etc.
  2. "end of the timeline", for a ScrollTimeline or ViewTimeline, this corresponds to "scroll 100%" (which breaks out of the default "view" range for a ViewTimeline)

The benefit of option 1 is consistency with animation-range. The drawback is that to construct a "one-side" trigger an author must explicitly specify both ends of the range , e.g. contain 10% scroll 100% (as opposed to just contain 10%.)

The benefit of option 2 is that for a one-sided trigger an author could specify the single trigger point, e.g. contain 10%. The drawbacks are that it breaks consistency with animation-range and it requires an author that wants to construct a trigger based on the view range of the subject to explicitly specify both ends of the range contain 10% contain 90% (as opposed to just contain 10% similar to animation-range).

DavMila avatar Dec 09 '25 17:12 DavMila

The proposed resolution is to adopt option 1 from my last comment: parse timeline-trigger-range the same as animation-range.

Tagging a few folks that might want to chime in: @tabatkins @fantasai @ydaniv @bramus @flackr

DavMila avatar Dec 15 '25 13:12 DavMila

The CSSWG will automatically accept this resolution one week from now if no objections are raised here. Anyone can add an emoji to this comment to express support. If you do not support this resolution, please add a new comment.

Proposed Resolution: timeline-trigger-range matches animation-range for omitted range ends

astearns avatar Dec 15 '25 20:12 astearns

RESOLVED: timeline-trigger-range matches animation-range for omitted range ends

astearns avatar Dec 22 '25 20:12 astearns