This proposal is a design sketch by the Blink Interactions team to describe the problem below and solicit feedback on the proposed solution. It has not been approved to ship in Chrome.
- Blink Interactions Team
 
- Use cases
 - Proposed Solution
 - Detailed design discussion
 - Considered alternatives
 - Stakeholder Feedback / Opposition
 - References & acknowledgements
 
A common user interaction design pattern among web pages is to make the playback of an animation tied to the scroll position of a scrollable element. This often involves playing, pausing, reversing or resetting the animation based on whether an element within the scrollable element enters or exits the scrollport (the viewport of the scrollable element) during scrolling.
To accomplish this today, authors have to rely on some form of JavaScript, e.g. scroll event listeners or IntersectionObservers, to detect the condition under which they would like to take action on their animation. This means that the logic to control their animations' playback lives in the same thread where the rest of their application logic (and other user agent work) lives, the "main thread." This arrangement makes scroll-position-controlled animations susceptible to being delayed by unrelated main thread work.
This proposal allows scroll-position-controlled animations to be set up declaratively. A declarative setup allows web authors create seamless scroll-triggered animation experiences by giving the user agent information that lets it offload the control of such animations to a dedicated thread rather than running on the main thread.
This project aims to enable seamless scroll-position-controlled web animations.
The declarative API put forward by this proposal aims to reduce the possibility of lag between a user's scrolling action and an animation an author has constructed to be tied to that action. The following are use cases that would typically be affected by such lag.
A common example of scroll-position-controlled animations animates the entry and the exit of elements within the scrollport of a scrollable element. When the element, e.g. a section of a page, is scrolled into view, it is introduced to the user via a smooth translation and/or opacity animation and when being scrolled out of view the animation is played in reverse. If the animation is delayed by other application work, the user is subjected to a visual delay, resulting in a degraded web experience.
Authors also often tie the visiblity of one element to an animation on a separate element, i.e. an element not within the same scrollport. For example, a page could have two halves: scrolling content on its right half and a gallery of images on its left. The images in the gallery are connected to sections within the scrolling content such that when the section comes into view the appropriate image in the gallery is animated into view. As with the previous example, lag between the scrolling and the animation makes for a bad web experience.
This proposal introduces an animation-trigger CSS property which, in coordination with the existing
animation property, allows authors declaratively specify playback control of their animations.
This proposal also introduces a CSS property timeline-trigger which defines the conditions under
which "enter" and "exit" are considered to have occured.
timeline-trigger builds on the existing concepts behind the
animation-timeline and
animation-range properties which provide extensive syntax for expressing scroll-related
information.
timeline-trigger is a shorthand for the following CSS properties (also introduced by this
proposal):
timeline-trigger-nametimeline-trigger-sourcetimeline-trigger-range-starttimeline-trigger-range-endtimeline-trigger-exit-range-starttimeline-trigger-exit-range-end
timeline-trigger-name names the trigger, allowing it to be referred to by animation-trigger.
timeline-trigger-source specifies the
AnimationTimeline within which
the trigger will evaluate whether its trigger or exit conditions have been met.
timeline-trigger-range-* specify the boundaries of the timeline-trigger-source that define the
trigger's "enter" and "exit" conditions.
Here is an example of HTML & CSS that could be used to implement the Section Slide-In example described above.
CSS:
@keyframes fadein {
  from { transform: translateX(-50px); opacity: 0 }
  to { transform: translateX(0px); opacity: 1 }
}
#section {
  animation: fadein 0.5s both;
  timeline-trigger: --trigger view() contain 0% contain 100%;
  animation-trigger: --trigger play-forwards play-backwards;
}HTML:
<div id="scroll-container">
  <div>Lots of other content.</div>
  <div id="section"></div>
  <div>Lots of other content.</div>
</div>The above example sets up an animation that will slide #section from left (outside) of the screen
into the screen as soon as its scroll container is scrolls so that #section is fully (vertically)
contained within scroll-container's scrollport.
It accomplishes this by specifying view() as the timeline of the trigger. view() sets up a
ViewTimeline within which
"contain 0% contain 100%" marks the boundaries of scrolling that make #section fully
visible. This creates a scenario in which, once #section's non-animated position is fully in view
(vertically speaking) it is introduced into the scroll port and becomes visible to the user via a
smooth animation.
To accomplish a similar effect with a scroll event listener, an author would need to write script similar to the following:
function evaluate_in_viewport(element, scroll_container) {
  /* Logic evaluating whether element is within scroll_container's scrollport. */
}
const last_evaluated = false;
function setup_animation_trigger(element) {
  const scroll_container = document.getElementById("scroll-container");
  const animation = new Animation(new KeyframeEffect(element,
    [
      { transform: "translateX(-50px)", opacity: 0 },
      { transform: "translateX(0px)", opacity: 1 },
    ],
    {
      duration: 500,
    }
  ));
  scroll_container.addEventListener("scroll", () => {
    const in_viewport = evaluate_in_viewport(element, scroll_container);
    if (in_viewport != last_evaluated) {
      const playback_rate = Math.abs(animation.playbackRate);
      if (in_viewport) {
        animation.playbackRate = playback_rate;
      } else {
        animation.playbackRate = -playback_rate;
      }
      animation.play();
      last_evaluated = in_viewport;
    }
  });
}
document.onload = () => {
  /* Other application logic */
  /*            ...          */
  const section = document.getElementById("section");
  setup_animation_trigger(section);
}Thier reliance on script means their logic for detecting the trigger condition could be delayed by other main thread work.
Use cases of scroll-triggered animations typically expect the animating element to be in view when their animation is playing. Scrolling may happen so as to skip over the animating element entirely, e.g. if the page is loaded with a URL hash fragment such that the page instantaneously scrolls past the animation's target. In this intstance the author might not want the animation to play. As such, specifying the triggering portion of the timeline as a range rather than a single point gives the author the flexibility to opt into either behavior. A single point boundary can be accomplished by having the trigger range extend to the extent of the scroll range, effectively making the range unskippable.
A timeline-based trigger's entry condition is met when the timeline's "current time" value is within the range corresponding to the trigger's specified entry range. However, a timeline-based trigger's exit condition is met when said timeline's current time is outside the exit range.
In many cases, authors will want to have the same values for both ranges, but in some cases, it is useful to have different values. An example is with a "repeat" trigger. "repeat" triggers reset their animations in response to their exit condition being met. An author might not want their animation to play until their element is fully within the viewport. However, they might also not want the reset of the animation to occur until said element is fully out of view. In other words, they may not want the reset to happen while the element is still partially in view.
In this case, the boundary for playing the animation and the boundary for resetting the animation are not the same.
See the pictures below:
In the image above, the entry boundary is the same as the exit boundary: the point at which the animating box is fully within view. As soon as the box is no longer fully in view, the animation gets reset. This might not be desirable as the author might not want the reset to be visible to the user.
Being able to set a different exit boundary allows the author to create the situation below:
Here, the reset does not happen until the element is completely out of view.
In CSSWG Issue 12652, the CSS Working group
considered the question of whether animation-trigger should specify "enter" and "exit" (or UIEvents
in the case of event-trigger) along with the animation playback actions, i.e "play", "pause",
"reset", etc.
Under this syntax, the Slide-In example from earlier would be specified similarly, exept for the
animation-trigger declaration:
#section {
  animation: fadein 0.5s both;
  timeline-trigger: --trigger view() contain 0% contain 100%;
  animation-trigger: --trigger enter play-forwards exit play-backwards;
}The working group decided that a cleaner API would be to declare the events at the
source and the actions at the target, as is reflected in this proposal ("enter" and "exit" are
implicitly defined by timeline-trigger).
In CSSWG Issue 12119 the CSS working group
considered 2 higher level models for the relationship between triggers and Animations.
In the "arming" model, the trigger is a mechanism internal to how an animation works. This model
makes triggers an integral part of animations, i.e. an animation cannot play unless its trigger
condition has been met. This model redefines the methods of the Animation API. For example, play()
is defined as "arming" the trigger, putting it in a state where, when its condition is met, causes
the animation to make progress.
In the "controller" model, the trigger is an external actor that can invoke the methods of the
Animation interface, i.e. play(), pause(), etc, on an animation when its trigger condition is
met.
The working group decided that the controller model better matches developers' expectations. For
instance, a developer would expect that invoking play() would cause an animation to play
regardless of whether its trigger condition has been met.
Some thought was given to modifying
animation-play-state, an
existing CSS property, to incorporate the new capabilities. However animation-play-state serves a
different purpose altogether. It determines whether an animation is "active" or not whereas a
trigger is controlling when and whether an active animation is playing forwards or backwards, or is
to be reset or paused.
In this CSSWG Github Issue, some consideration was
given to leveraging a related proposal,
Declarative Interactions which also
builds on the animation-trigger property. The idea was to create viewenter() and viewleave()
events which could be declared by the event-trigger property. Roughly speaking viewenter() and
viewleave()would capture timeline-trigger's "enter" and "exit" concepts, respectively. This
approach was decided against
as event-triggers operate on a
fundamentally different model that doesn't capture some of the details of timeline-triggers.
To summarize, timeline-triggers operate based on concepts of "enter" and "exit" which are not
defined independently of each other whereas the events of an event-trigger are (some subset of)
independently defined UIEvents.
N/A at the moment. Will add when vendor positions requested.
Many thanks for valuable feedback and advice from:
- @ydaniv
 - @fantasai
 - @tabatkins
 - @flackr
 

