1. Introduction
This specification defines mechanisms for triggering the start and end of an animation based on the scroll progress of a scroll container, as well as driving the progress of an animation based on the scroll progress of a scroll container.
1.1. Relationship to other specifications
Web Animations [WEB-ANIMATIONS-1] defines an abstract conceptual model for animations on the Web platform, with elements of the model including animations and their timelines, and associated programming interfaces.
This specification extends this model in two ways: by defining a new concept, that of an animation timeline trigger, which can optionally be associated with an animation timeline; and by defining a new type of animation timeline: a scroll timeline.
This specification defines both programming interfaces for interacting with these concepts, as well as CSS markup which applies these concepts to CSS Animations [CSS3-ANIMATIONS].
The behavior of the CSS markup is described in terms of the programming interfaces. User-agents that do not support script may still implement the CSS markup provided it behaves as if the underlying programming interfaces were in place.
2. Use cases
This section is non-normative
Note: Based on this curated list of use cases.
2.1. Scroll-triggered animations
2.1.1. Navigation bar shrinking effect
It is common to trigger an animation to run when the scroll position reaches a certain point. For example, a navigation bar may shrink once the user begins to scroll a page.
The left figure shows the navigation bar before scrolling with a large menu bar.
The right figure shows the shrunken navigation bar after scrolling.
Using the CSS markup defined in this specification, we can achieve this effect as follows:
div.menu { animation: shrink-effect 0.5s forwards; animation-trigger: scroll(element(#body), vertical, 1px); } @keyframes shrink-effect { to { transform: scale(0.5) } }
Is there anyway to use function notation and make the order of the arguments free so we can omit ‘element(#body)’ and ‘vertical’ above?
Alternatively, using the programming interface in this specification, we can write this as:
var animation = menuDiv.animate({ transform: 'scale(0.5)' }, { duration: 500, fill: 'both' }); animation.timeline = new DocumentTimeline({ trigger: new ScrollTrigger({ scrollOffset: '1px' }) });
We can make the animation apply in reverse by using CSS transitions and the @trigger syntax as follows:
div.menu { transition: transform 0.5s; } @trigger scroll(element(#body), vertical, 1px) { div.menu { transform: scale(0.5); } }
2.1.2. Navigation highlight effect
Similarly, it is common to trigger an animation at certain fixed points in a element’s scroll range. For example, a navigation bar that changes highlight based on the reader’s position within the document.
We need to find better syntax for covering this use case.
On the left, the “Abstract” section is scrolled into view and hence the abstract menu item is highlighted.
After scrolling down to the “Background” section (right), the background menu item fades in while the abstract menu item fades out.
Using the CSS markup defined in this specification, we can achieve this effect as follows:
@keyframes menu-effect { from { opacity: 0.5 } to { opacity: 1.0 } } .menu-item { opacity: 0.5; transition: opacity 0.5s; } @trigger scroll(element(#body), vertical, 0%, 20%) { #abstract { opacity: 1 } } @trigger scroll(element(#body), vertical, 20%, 40%) { #background { opacity: 1 } }
Specifying scroll offsets as percentages for this use case is not good. In the past we’ve talked about using snap points semantics but how exactly would that work here?
2.2. Scroll-triggered style changes
Need a different use case here, the previous one could be done with ‘position: sticky’.
2.3. Scroll-linked animations
2.3.1. Scrollable picture-story show
Another pattern is an animation that tells a story where the user controls the progress of the animation by scrolling or some other gesture. This may be because the animation contains a lot of textual information which the user may wish to peruse more slowly, it may be for accessibility considerations to accommodate users who are uncomfortable with rapid animation, or it may simply be to allow the user to easily return to previous parts of the story such as a story that introduces a product where the user wishes to review previous information.
The following (simplified) example shows two balls colliding. The animation is controlled by scroll position allowing the user to easily rewind and replay the interaction.
The left figure shows the initial position of the balls
The right figure shows them after they have collided.
Using the CSS markup:
div.circle { animation-duration: 1s; animation-timing-function: linear; animation-trigger: scroll(element(#container), vertical, "200px", "300px"); animation-timeline: scroll(); } #left-circle { animation-name: left-circle; } #right-circle { animation-name: right-circle; } #union-circle { animation-name: union-circle; animation-trigger: scroll(element(#container), vertical, "250px", "300px"); } @keyframes left-circle { to { transform: translate(300px) } } @keyframes right-circle { to { transform: translate(350px) } } @keyframes union-circle { to { opacity: 1 } }
Using the programming interface, we might write this as:
var circleTimeline = new ScrollTimeline({ trigger: new ScrollTrigger({ scrollSource: scrollableElement, scrollOffset: '200px', endScrollOffset: '300px'}) }); var left = leftCircle.animate({ transform: 'translate(300px)' }, 1000); left.timeline = circleTimeline; var right = leftCircle.animate({ transform: 'translate(350px)' }, 1000); right.timeline = circleTimeline; var union = unionCircle.animate({ opacity: 1 }, 1000); union.timeline = new ScrollTimeline({ trigger: new ScrollTrigger({ scrollSource: scrollableElement, scrollOffset: '250px', endScrollOffset: '300px'}) });
2.3.2. The content progress bar
Another common example of an animation that tracks scroll position is a progress bar that is used to indicate the reader’s position in a long article.
The left figure shows the initial state before scrolling.
The right figure shows the progress bar is half-filled in since the user has scrolled half way through the article.
Typically, the scroll bar provides this visual indication but applications may wish to hide the scroll bar for aesthetic or useability reasons.
Using the animation-timeline property, this example could be written as follows:
@keyframes progress { to { width: 100%; } } #progress { width: 0px; height: 30px; background: red; animation: progress 1s linear; animation-trigger: scroll(element(#body)); animation-timeline: scroll(); }
If we use this API for this case, the example code will be as follow:
var animation = div.animate({ width: '100%' }, 1000); animation.timeline = new ScrollTimeline( { trigger: new ScrollTrigger({ scrollOffset: '0px' }) } );
2.4. Combination scroll and time-base animations
2.4.1. Photo viewer
We are currently reworking this use case
3. Triggering animations
3.1. The AnimationTimelineTrigger
interface
(Brian) I am no longer entirely certain we need this concept in the API.
Initially we added it since we thought it’s important to be able to run triggers on the compositor in order to avoid visual gaps caused by performing updates on the main thread. However, it seems like one of the most common use cases is to trigger transitions based on scroll-offset and this API does not allow you to do that.
Should we just drop this for now, merge the relevant members into ScrollTimeline
and use IntersectionObserver to trigger changes on
the main thread?
For those use cases that are best suited to transitions, the alternative
would be to simply have two animations and two ScrollTrigger
objects.
interface AnimationTimelineTrigger {
};
An animation timeline trigger is an object that can be in one of two states: active and inactive. A trigger starts off as inactive, and can subsequently be activated or deactivated by the user-agent depending on the specific type of trigger.
A trigger cannot be explicitly activated or deactivated from script, only by the user-agent.
3.2. Extensions to the AnimationTimeline
interface
partial interface AnimationTimeline { readonly attribute AnimationTimelineTrigger? trigger; };
If a timeline has a specified trigger, the timeline is only active when its trigger is active.
That is, a timeline with a trigger only becomes active when its trigger becomes active and all the other criteria for the timeline becoming active are met. When the trigger becomes inactive, the timeline becomes inactive as well.
3.3. Extensions to the DocumentTimeline
interface
partial dictionary DocumentTimelineOptions { AnimationTimelineTrigger trigger; }; [Constructor(optional DocumentTimelineOptions options)] partial interface DocumentTimeline { // trigger attribute inherited from AnimationTimeline };
3.4. Scroll Triggers
3.4.1. The ScrollDirection
enumeration
enum ScrollDirection {
"auto",
"block",
"inline",
"horizontal",
"vertical"
};
The ScrollDirection
enumeration specifies a direction of scroll of a
scrollable element.
-
auto
-
If only one direction is scrollable, selects that direction. Otherwise selects the direction along the block axis.
-
block
-
Selects the direction along the block axis.
-
inline
-
Selects the direction along the inline axis.
-
horizontal
-
Selects the horizontal direction.
-
vertical
-
Selects the vertical direction.
Should the physical directions ("horizontal" and "vertical") be removed, leaving only the logical directions ("block" and "inline")?
What about a value that means, "the longest scroll direction"? That would be more reliable than "auto" for the case where layout differences could mean that, although normally you only expect the inline direction to be scrollable, on some devices you end up with a small scrollable range in the block direction too.
3.4.2. The ScrollTriggerKind
enumeration
enum ScrollTriggerKind {
"offset",
"range"
};
The ScrollTriggerKind
enumeration specifies the kind of a ScrollTrigger
.
-
offset
-
The scroll trigger is activated when a scroll offset is reached, and never subsequently deactivated.
Do we actually have use cases for this? I think in most cases we cancel the animation if we go back past the
scrollOffset
? I’d be glad to be proven wrong, however. -
range
-
The scroll trigger is active whenever the scroll offset is inside a particular range.
3.4.3. The ScrollTrigger
interface
dictionary ScrollTriggerOptions { Element scrollSource; ScrollTriggerKind kind = "offset"; ScrollDirection orientation = "auto"; DOMString scrollOffset = "auto"; DOMString endScrollOffset = "auto"; }; [Constructor(optional ScrollTriggerOptions options)] interface ScrollTrigger : AnimationTimelineTrigger { readonly attribute Element scrollSource; readonly attribute ScrollTriggerKind kind; readonly attribute ScrollDirection orientation; readonly attribute DOMString scrollOffset; readonly attribute DOMString endScrollOffset; };
ScrollTrigger
is an AnimationTimelineTrigger
associated with a scrollable
element.
-
scrollSource, of type Element, readonly
-
The scrollable element whose scrolling activates and deactivates the trigger.
If this is not specified, the document element is used.
-
kind, of type ScrollTriggerKind, readonly
-
Determines the way in which scrolling
scrollSource
activates and deactivates the trigger.The values have the following behavior:
-
offset
-
The trigger is activated when
scrollSource
's scroll offset inorientation
reachesscrollOffset
, and never subsequently deactivated.endScrollOffset
is ignored. -
range
-
The trigger is activated when
scrollSource
's scroll offset inorientation
enters the interval [scrollOffset
,endScrollOffset
], and deactivated when the scroll offset exits that interval.
-
-
orientation, of type ScrollDirection, readonly
-
Determines the direction of scrolling which drives the activation and deactivation of the trigger.
-
scrollOffset, of type DOMString, readonly
-
The scroll offset, in the direction specified by
orientation
, that triggers activation of the trigger.Recognized values are defined by the following grammar:
auto | <length> | <percentage>
The meaning of each value is as follows:
-
auto
-
The beginning of
scrollSource
's scroll range inorientation
. -
An absolute distance along
scrollSource
's scroll range inorientation
. -
A percentage distance along
scrollSource
's scroll range inorientation
.
The way in which the trigger’s activation depends on this offset is determined by the trigger’s
kind
. -
-
endScrollOffset, of type DOMString, readonly
-
A scroll offset that constitutes the end of a range in which the trigger is activated.
Recognized values are defined by the following grammar:
auto | <length> | <percentage>
The meaning of each value is as follows:
-
auto
-
The end of
scrollSource
's scroll range inorientation
. -
An absolute distance along
scrollSource
's scroll range inorientation
. -
A percentage distance along
scrollSource
's scroll range inorientation
.
-
3.5. The animation-trigger property
Animation timeline triggers can be applied to animations defined using CSS Animations [CSS3-ANIMATIONS] with the animation-trigger property.
Name: | animation-trigger |
---|---|
Value: | <single-animation-trigger># |
Initial: | none |
Applies to: | all elements, ::before and ::after pseudo-elements |
Inherited: | none |
Percentages: | N/A |
Media: | interactive |
Computed value: | As specified |
Canonical order: | per grammar |
Animatable: | no |
<single-animation-trigger> = none | <scroll-trigger>
<scroll-trigger> = scroll([element(<id-selector>), [,<scroll-direction> [, <scroll-offset> [, <scroll-offset>]]]])
<scroll-direction> = auto | horizontal | vertical
<scroll-offset> = <length> | <percentage> | auto
The animation-trigger property is similar to properties like animation-duration and animation-timing-function in that it can have one or more values, each one imparting additional behavior to a corresponding animation on the element, with the triggers matched up with animations as described here.
Each value has type <single-animation-trigger>, whose possible values have the following effects:
-
none
-
The animation’s timeline has a
ScrollTrigger
.The trigger’s
scrollSource
is the scroll container identified by the <id-selector>, defaulting to the element’s nearest scrollable ancestor.The <scroll-direction>, if provided, determines the trigger’s
orientation
.The first <scroll-offset>, if provided, determines the trigger’s
scrollOffset
.The second <scroll-offset>, if provided, determines the trigger’s
endScrollOffset
.Should we allow overriding the kind to
offset
?
3.6. @trigger rules
The @trigger at-rule allows conditioning the application of CSS rules on the scroll progress of a scroll container. It is defined as follows:
@trigger = @trigger <scroll-trigger> { <rule-list> }
The <scroll-trigger> defines a ScrollTrigger
, in a similar fashion to when it appears
as a value for animation-trigger.
The <rule-list> inside of @trigger can contain any rules. The rules in the <rule-list> only apply when the ScrollTrigger
is active.
As a special case, if one of the rules in the <rule-list> defines an animation using the animation-name property, the timeline of that animation is associated with the ScrollTrigger
,
as if using the animation-trigger property.
The syntax is designed to be extensible to other types of triggers in the future.
Do we need animation-trigger at all, or is @trigger sufficient?
Should this be called ‘@scroll’ perhaps? Or integrated with media queries somehow?
3.7. Examples
let spinner = document.getElementById("spinner"); let effect = new KeyframeEffect( spinner, [ { transform: 'rotate(0)' }, { transform: 'rotate(1turn)' } ], { duration: 300, fill: 'both', easing: 'linear', iterations: Infinity }); let timeline = new DocumentTimeline({ trigger: new ScrollTrigger({ scrollSource: document.documentElement, orientation: "vertical", kind: "range", scrollOffset: "500px", endScrollOffset: "1000px" }); }); let animation = new Animation(effect, timeline); animation.play();
@keyframes spin { from { transform: rotate(0); } to { transform: rotate(1turn); } } #spinner { animation-name: spin; animation-duration: 300ms; animation-fill-mode: both; animation-iteration-count: infinite; animation-timing-function: linear; /* Assume the HTML element has id 'root' */ animation-trigger: scroll(element(#root), vertical, 500px, 1000px); }
@keyframes spin { from { transform: rotate(0); } to { transform: rotate(1turn); } } /* Assume the HTML element has id 'root' */ @trigger scroll(element(#root), vertical, 500px, 1000px) { #spinner { animation-name: spin; animation-duration: 300ms; animation-fill-mode: both; animation-iteration-count: infinite; animation-timing-function: linear; } }
/* Elements with the class 'elusive' are only displayed while the scroll offset is in the range [200, 300]. Note that 'display' can’t be animated normally. Assume the HTML element has id 'root' */ .elusive { display: none; } @trigger scroll(element(#root), vertical, 200px, 300px) { .elusive { display: block; } }
4. Controlling animation playback
4.1. The ScrollTimeline
interface
enum ScrollTimelineAutoKeyword { "auto" }; dictionary ScrollTimelineOptions { required ScrollTrigger trigger; (double or ScrollTimelineAutoKeyword) timeRange = "auto"; FillMode fill = "none"; }; [Constructor(ScrollTimelineOptions options)] interface ScrollTimeline : AnimationTimeline { attribute (double or ScrollTimelineAutoKeyword) timeRange; attribute FillMode fill; };
A scroll timeline is an AnimationTimeline
whose time values are determined
not by wall-clock time, but by the progress of scrolling in a scroll container.
A ScrollTimeline
must have a trigger
, it must be of type ScrollTrigger
, and the trigger’s kind
must be range
. If these criteria
are not met, a TypeError is thrown from the constructor.
The scroll container whose scrolling drives the timeline is the trigger’s scrollSource
. The direction of scrolling that drives the timeline is the
trigger’s orientation
.
-
timeRange, of type
(double or ScrollTimelineAutoKeyword)
-
A time duration that allows mapping between a distance scrolled, and quantities specified in time units, such as an animation’s duration and start delay.
Conceptually,
timeRange
represents the number of milliseconds to map to the scroll range defined bytrigger
. As a result, this value does have a correspondence to wall-clock time.This value is used to compute the timeline’s effective time range, and the mapping is then defined by mapping the scroll distance from
trigger
.scrollOffset
totrigger
.endScrollOffset
, to the effective time range. -
fill, of type FillMode
-
Determines whether the timeline is active even when the scroll offset is outside the range defined by [
scrollOffset
,endScrollOffset
].Possible values are:
-
none
-
The timeline is inactive when the scroll offset is less than
scrollOffset
or greater thanendScrollOffset
. -
forwards
-
When the scroll offset is less than
scrollOffset
, the timeline’s current time is 0. When the scroll offset is greater thanendScrollOffset
, the timeline is inactive. -
backwards
-
When the scroll offset is less than
scrollOffset
, the timeline is inactive. When the scroll offset is greater thanendScrollOffset
, the timeline’s current time is its effective time range. -
both
-
When the scroll offset is less than
scrollOffset
, the timeline’s current time is 0. When the scroll offset is greater thanendScrollOffset
, the timeline’s current time is its effective time range. -
auto
-
Behaves the same as
none
.
A
ScrollTrigger
is only active when the scroll offset is within the range, and a timeline is inactive when its trigger is inactive. How can we reconcile this will fill modes, which require an active timeline outside the range in some situations? -
4.1.1. The effective time range of a ScrollTimeline
The effective time range of a ScrollTimeline
is calculated as follows:
-
If the
timeRange
has the value"auto"
, -
The effective time range is the maximum value of the target effect end of all animations directly associated with this timeline.
If any animation directly associated with the timeline has a target effect end of infinity, the behavior is unspecified.
-
Otherwise,
-
The effective time range is the
ScrollTimeline
'stimeRange
.
4.1.2. The current time of a ScrollTimeline
The current time of a ScrollTimeline
is calculated
as follows:
-
Let current scroll offset be the current scroll offset of
scrollSource
in the direction specified byorientation
. -
If current scroll offset is less than
scrollOffset
, return an unresolved time value iffill
isnone
orbackwards
, or 0 otherwise. -
If current scroll offset is greater than or equal to
endScrollOffset
, return an unresolved time value iffill
isnone
orforwards
, or the effective time range otherwise. -
Return the result of evaluating the following expression:
(current scroll offset -
scrollOffset
) / (endScrollOffset
-scrollOffset
) × effective time range
4.2. The animation-timeline property
A ScrollTimeline
may be applied to a CSS Animation [CSS3-ANIMATIONS] using
the animation-timeline property.
Name: | animation-timeline |
---|---|
Value: | <single-animation-timeline># |
Initial: | auto |
Applies to: | all elements, ::before and ::after pseudo-elements |
Inherited: | none |
Percentages: | N/A |
Media: | interactive |
Computed value: | As specified |
Canonical order: | per grammar |
Animatable: | no |
<single-animation-timeline> = auto | scroll([<time> [, <single-animation-fill-mode>]])
The animation-timeline property is similar to properties like animation-duration and animation-timing-function in that it can have one or more values, each one imparting additional behavior to a corresponding animation on the element, with the timelines matched up with animations as described here.
Each value has type <single-animation-timeline>, whose possible values have the following effects:
-
auto
-
The animation’s timeline is a
DocumentTimeline
If animation-trigger is none, the default document timeline is used; otherwise, a new
DocumentTimeline
with the appropriatetrigger
is generated.Do we re-use
DocumentTimeline
objects when the trigger is the same? -
scroll([<time> [, <single-animation-fill-mode>]])
-
The animation’s timeline is a
ScrollTimeline
.The <time> value, if specified, determines the timeline’s
timeRange
.The <single-animation-fill-mode> value, if specified, determines the timeline’s
fill
.If a
ScrollTrigger
isn’t specified using the animation-trigger property, the animation’s timeline is given a defaultScrollTrigger
, as if viaanimation-trigger: scroll()
.
4.3. Examples
#progress { position: fixed; top: 0; width: 0; height: 2px; background-color: red; }
let progress = document.getElementById("progress"); let effect = new KeyframeEffect( progress, [ { width: "0vw" }, { width: "100vw" } ], { duration: 1000, easing: "linear" }); let timeline = new ScrollTimeline({ trigger: new ScrollTrigger({ scrollSource: document.documentElement, orientation: "vertical", kind: "range" }) }); let animation = new Animation(effect, timeline); animation.play();
@keyframes progress { from { width: 0vw; } to { width: 100vw; } } #progress { position: fixed; top: 0; width: 0; height: 2px; background-color: red; animation-name: progress; animation-duration: 1s; animation-timing-function: linear; /* Assume the HTML element has id 'root' */ animation-trigger: scroll(element(#root), vertical); animation-timeline: scroll(); }
@keyframes progress { from { width: 0vw; } to { width: 100vw; } } #progress { position: fixed; top: 0; width: 0; height: 2px; background-color: red; } /* Assume the HTML element has id 'root' */ @trigger scroll(element(#root), vertical) { #progress { animation-name: progress; animation-duration: 1s; animation-timing-function: linear; animation-timeline: scroll(); } }
5. Avoiding cycles with layout
The ability for scrolling to drive the progress of an animation, gives rise to the possibility of layout cycles, where a change to a scroll offset causes an animation’s effect to update, which in turn causes a new change to the scroll offset.
To avoid such cycles, animations with a ScrollTimeline
are sampled once
per frame, after scrolling in response to input events has taken place, but
before requestAnimationFrame()
callbacks are run. If the sampling of such an
animation causes a change to a scroll offset, the animation will not be
re-sampled to reflect the new offset until the next frame.
The implication of this is that in some situations, in a given frame, the rendered scroll offset of a scroll container may not be consistent with the state of an animation driven by scrolling that scroll container. However, this will only occur in situations where the animation’s effect changes the scroll offset of that same scroll container (in other words, in situations where the animation’s author is asking for trouble). In normal situations, including - importantly - when scrolling happens in response to input events, the rendered scroll offset and the state of scroll-driven animations will be consistent in each frame.