[csswg-drafts] Element-based start and end offsets (#4337)

majido has just created a new issue for https://github.com/w3c/csswg-drafts:

== Element-based start and end offsets  ==
A very common usage pattern for scroll timeline is animating items as they enter or exit the scrollport (or viewport). This was identified as a shortcoming of the current API and it is explained in details [here](https://github.com/WICG/scroll-animations/issues/39#issue-421067195)

This issue tries to propose an extension in the ScrollTimeline API to help address this shortcoming. This builds on top of our earlier proposal [here](https://github.com/WICG/scroll-animations/issues/39#issuecomment-472903076). 


# Proposed Design

Allow scroll timeline's start and end offsets to be declared in terms of elements on the page. More accurately as a single intersection threshold (with a similar semantic as IntersectionObserver) between scroll timeline's scroll source and another element. Note that while the target element is often the animation target itself but this is not necessary.

## Why Intersection Semantics and IntersectionObserver

We believe most common use cases can map easily to an intersection point which is simple to express and understand. There are several examples below that demonstrate this more concretely. Assuming intersection semantic is the right one then it is natural to use Intersection Observer which is a primitive that was designed for this very exact use case. By using Intersection Observer semantics which are well understood, specified and documented, we keep the platform consistent, make the feature potentially easier to implement, and also make it easier to polyfilling as well.

## Additions to the IntersectionObserver model

To make this work for ScrollTimeline we need a few additions to the Intersection Observer model but they seem to be reasonable and small.

**One Dimensional Intersection**: Intersection observer calculates intersection (and thus thresholds) on 2d plane but for scroll we want one dimensional intersection. I believe this is easy to define and introduce to intersection observer model.

**Edge Dependency**: Intersection observer does not differentiate between start and

end edges and produces intersection entries regardless of where the intersection occurs. The actual observer callback can then detect this based on the info that is available in the entry. For scroll timeline use cases we want to differentiate when something intersect at the start or end edge.  Again this seems likely to be a simple addition to Intersection Observer model.


# Proposed API


```webidl

interface ScrollTimeline : AnimationTimeline {
  readonly attribute (DOMString or IntersectionBasedOffset) startScrollOffset;
  readonly attribute (DOMString or IntersectionBasedOffset) endScrollOffset;

  // No change here
  readonly attribute Element scrollSource;
  readonly attribute ScrollDirection orientation;
  readonly attribute (double or ScrollTimelineAutoKeyword) timeRange;
  readonly attribute FillMode fill;
};

dictionary IntersectionBasedOffset {
  Element target;
  Edge edge = "start";
  double threshold = 0.0;
  DOMString rootMargin;
}

enum Edge {"start", "end"}
```


Semantic for intersection based offsets:

 - When an intersection based offset is used then the implementation must behave as if there exists this corresponding underlying intersection observer
   ```js
    const startObserver = new IntersectionObserver({
       root: timeline.scrollSource,
       rootMargin: timeline.startScrollOffset.rootMargin,
       thresholds: [timeline.startScrollOffset.threshold],
       edge: timeline.startScrollOffset.edge,
       mode: '1d'
     }).observe(timeline.startScrollOffset.target);

    // Similarly one exists for end offset.

   ```
   Note: `edge` and `mode` here represent the new addition to the Intersection Observer model.
 - When the corresponding start intersection observer would have notified its clients that an intersection at the threshold is reached then the scroll timeline should start ticking and thus its associated animations become active. The scroll offset at that moment is considered the _concrete start offset_. 
- Similarly when the end intersection observer would have notified its clients that an intersection at the threshold is reached then the scroll timeline should stops ticking and thus its associated animations become inactive. The scroll offset at that moment is considered the _concrete end offset_.
- The concrete start and end offsets will be used when calculating scroll timeline's current time.

**IMPORTANT:** It is actually not required for implementations to create an instance of intersection observer. In fact they can and should precompute the exact scroll offsets that would result in the given thresholds and use those as concrete offsets. Though such pre-computed offsets gets invalidated and need to be recomputed whenever intersection observer would have been invalidated.

Note that “0” threshold in IntersectionObserver signals signal transition from not-intersecting to intersecting if the target and root become edge-adjacent, even if the actual overlap area is zero pixels. This matches what we want as well. It may however be necessary to differentiate between the case when zero is “intersecting” or “non-intersecting” which is exposed by Intersection observer as [isIntersecting](https://w3c.github.io/IntersectionObserver/#dom-intersectionobserverentry-isintersecting) attribute.


## Optional scrollRange

We can also introduce `scrollRange` that could be used to declare one offset in terms of the other  using, `start + range = end`. This can be handy in some cases.


## CSS Syntax

We are assuming that we use and @rule based css syntax (See this [issue](https://github.com/WICG/scroll-animations/issues/49) for further details). In that case the intersection based offset can be implemented in the form of a css function:


```
<intersection()> = intersection(<target-selector>, <edge>, <threshold>, <root-margin>)
<target-selector> = <selector()>
<edge> = 'start' | 'end'
<threshold> = <number>
<root-margin> = [ <length> | <percentage> | auto ]{1,4}


<selector()> = element(<target-id>)
<target-id> = ':animation-target' | ':root' | '#'  <custom-ident>

```

Initially, it may be enough for `<element()>` to only support #ID selector and also a special syntax `element(:animaton-target)` that selects the animation target itself. Later this can be expanded to support more complex selectors.



Here is an example of how it can be used:


```css
@timeline first-scroll-timeline {
  timeline-type: scroll; 
  timeline-source: element(:root) ;
  scroll-direction: block;
  /* start when target has entered scrollport */
  scroll-offset-start: intersection(element(#myid), start);
  /* end when target has left scrollport */
  scroll-offset-end: intersection(element(#myid), end); 
}

@timeline second-scroll-timeline {
  timeline-type: scroll; 
  timeline-source: element(:root) ;
  scroll-direction: block;
  /* start when half the target is within the scrollport */
  scroll-offset-start: intersection(element(:animation-target), start, 50);
  /* end after 10rem of scrolling */
  scroll-range: 10rem; 
}

@keyframes reveal {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

#target {
  animation-name: reveal;
  animation-duration: 1s;
  animation-timeline: first-scroll-timeline;
}

```





# Examples

The following examples are meant to help demonstrate the ergonomics of the API is some common scenarios.


##  Example 1. Reveal / Unreveal

An image that goes from transparent to opaque when it enters scrollport and in reverse when it leaves scrollport.

html structure:

```
<scroller>
  <image>
```


reveal animation:

 - animation target: image
 - animation effect: opacity 0 -> 1
 - start offset: { target: $image, edge: 'start', threshold: 0}  // when element starts to enter scrollport
 - end offset: { target: $image, edge: 'start', threshold: 100}  // when element is fully within scrollport

Note: If we had scrollRanger we could specify scrollRange: 100% of image height instead.

unreveal animation:

 - animation target: image
 - animation effect: opacity 1 -> 0
 - start offset: { target: $image, edge: 'end', threshold: 100} // when element starts to leave scrollport
 - end offset: { target: $image, edge: 'end', threshold: 0 }

Note that if element is larger than scrollport the two animations may overlap. Animation composite can be used to determine how this works. 

Here is these effect expressed using web animation API in Javascript:


```js
const scroller = document.getElementById("scroller");
const image = document.getElementById("image");

const revealTimeline = new ScrollTimeline({
  startScrollOffset: { target: image, edge: 'start', threshold: 0 },
  endScrollOffset: { target: image, edge: 'start', threshold: 100 },
});

const revealEffect = new KeyframeEffect(
  image,
  { opacity: [0, 1]},
  { duration: 1000, fill: forwards }
);


const unrevealTimeline = new ScrollTimeline({
  // Finish the moment we completely leave the scrollport
  // end offset assumes intersections that involve the scroller "end" edge
  startScrollOffset:{ target: image, edge: 'end', threshold: 100}
  endScrollOffset:{ target: image, edge: 'end', threshold:0}
});


const unrevealEffect = new KeyframeEffect(
  image,
  { opacity: [1, 0]},
  { duration: 1000, fill: backwards }
);

let reveal = new Animation(revealEffect, revealTimeline);
let unreveal = new Animation(unrevealEffect, unrevealTimeline);

reveal.play();
unreveal.play();
```


Here is how the same thing expressed in CSS:


```css
@timeline reveal-scroll-timeline {
  timeline-type: scroll; 
  timeline-source: element(scroller) ;
  scroll-direction: block;
  /* start when target has entered scrollport */
  scroll-offset-start: intersection(element(:animation-target), start, 0);
  /* end when target is fully within scrollport */
  scroll-offset-end: intersection(element(:animation-target), start, 100); 
}

@timeline unreveal-scroll-timeline {
  timeline-type: scroll; 
  timeline-source: element(scroller) ;
  scroll-direction: block;
  scroll-offset-start: intersection(element(:animation-target), end, 100);
  scroll-offset-end: intersection(element(:animation-target), end, 0); 
}

@keyframes reveal {
 from { opacity: 0;}
 to { opacity: 1;}
}

@keyframes unreveal {
 from { opacity: 1;}
 to { opacity: 0;}
}


.image {
  animation-name: reveal, unreveal;
  animation-duration: 1000;
  animation-timeline: reveal-scroll-timeline, unreveal-scroll-timeline;
  animation-fill-mode: both;
}
```

Here is a diagram that demonstrates how the two timelines and their associated animations start and end.


![reveal-scroll-example](https://user-images.githubusercontent.com/944639/65015778-7040ed80-d95d-11e9-844d-7451b4e303f6.png)



##  Example 2. Progress bar left to right as we scroll a single page

Consider a document that consists of several sections and we want to show a simple progress bar that goes from 0 -> 100% when user scrolls through each section. This examples shows how intersection target may be different from animation target.

html structure:


```
<html>
  <page>
    <progressbar> - positioned sticky
  <page>
    <progressbar>
  <page>
    <progressbar>

```


Progress animation:

 - animation target: progress bar
 - animation effect: width 0 -> 100vw
 - start offset: when page enters the scrollport
 - end offset:   when page leaves the scrollport


```js
const scroller = document.scrollingElement;

for (page of scroller.querySelectorAll('.page')) {
  const progressbar = page.querySelector('.progressbar');

  const progressEffect = new ScrollTimeline({
    // just as we enter
    startScrollOffset: { target: page, edge: 'start', threshold: 0 },
    // just as we leave
    endScrollOffset:   { target: page, edge: 'end',   threshold: 100 },  
  });

  const progressEffect = new KeyframeEffect(
    progressbar,
    { width: ['0vw', '100vw']},
    { duration: 1000 }
  );

  const progressAnimation = new Animation(progressEffect, progressEffect);
  progressAnimation.play();
}
```


Here is how the same thing expressed in CSS:


```css
@timeline root-scroll-timeline {
  timeline-type: scroll; 
  timeline-source: element(:root) ;
  scroll-direction: block;
  scroll-offset-start: intersection(element(:animation-target), start, 0);
  scroll-offset-end: intersection(element(:animation-target), end, 100); 
}

@keyframes progressbar-effect {
 from { --pb-width: 0vw;} // --pb-width is a custom var with length type
 to { --pb-width: 100vw;}
}

.page {
  animation-name: progressbar-effect;
  animation-duration: 1000;
  animation-timeline: root-scroll-timeline;
}
.page > .progressbar {
  width: var(--pb-width);
  position: sticky;
}
```



## Example 3. Image scales up to be fully centered

An image that starts growing and reaches its maximum size once it is fully centered in the viewport. For example see [iphone 11 page](https://www.apple.com/iphone-11-pro/)
html structure:


```
<scroller>
  <image>
```


Scale animation:

- animation target: image
- animation effect: transform: scale(0.5) -> transform: scale(1)
- start offset: when element becomes 50% visible
- end offset: start offset + 50% of scroller height


```js
const scroller = document.getElementById("scroller");
const image = document.getElementById("image");

const timeline = new ScrollTimeline({
  scrollSource: scroller,
  startScrollOffset: { target: image, edge: 'start', threshold: 50 },
  scrollRange: getBCR(scroller).height / 2
});

const effect = new KeyframeEffect(
  image,
  { transform: ['scale(0.5)', 'scale(1)']},
  { duration: 1000, fill: both }
);

let scaleAnimation = new Animation(effect, timeline);
scaleAnimation.play();
```


**Note:** this is a case where `scrollRange` is useful. Here we want end offset to be relative to start offset but it is not easily specified as an intersection. With a simple addition of scrollRange we can define an intersection based start and then specify the scroll range for which the animation should remain for. It is perhaps possible to do a similar thing with an extra element that is positioned to be in the center of the page but that is not as ergonomic.

# Open Questions


##  Circularity

What happens if the animation moves the elements used in the target that could cause the scroll bounds to change which then alters the animation. This can lead to circular dependency between animation and its triggers. 

One way to avoid this circularity is to freeze start/end offset when timeline is active/inactive (and thus animating). Here is the change that can potentially achieve this:

- When a timeline is active has an active animation it ignores any start observer notifications and uses its existing *concrete start offset*. 

- When a timeline is inactive it ignores any end observer notifications and uses its existing concrete end offset.

TODO: Do we also need to assume that we calculate intersections based on last frame’s layout? Otherwise in the same frame we may have a situation where: 1) compute intersection -> 2)trigger animation which invalidates layout -> 3) in new layout we no longer intersect.


## Intersections are calculated based on previous frame

The operation of IntersectionObserver requires the document lifecycle is complete (clean layout/paint (?)). So its callbacks are invoked in the next frame after the fact. Is this sufficient for start/end offset for scroll-driven effects?

Note that to avoid circularity we already ignore the animation output itself. This puts additional restriction that we ignore anything that has occurred since the last frame (e.g., event handlers dirtying the layout, etc.).

I believe this a common problem for any element-based approach regardless of how the concept of intersection is declared.


## Dealing with offsets outside scroll range

Sometimes the start and end offsets are outside the scroll range. For example consider the case when the animation target is already visible when page loads at initial scroll offset (or perhaps its content partially outside the scroller when it is fully scroller).

Here are two ways to handle this situation: 

 1. animation starts mid-way. In this case, the concrete intersection  offset is computed as "negative" value so zero scroll offset provides a non-zero time value.

 2. adjust duration so animation plays faster. In this case the concrete intersection offset is clamped to (0, scroll max).

Seems like options 1 should be default but in future we can have extensions to Allow the clamp behavior.

# Prior art and alternatives considered

[Scroll timebase proposal](https://lists.w3.org/Archives/Public/www-style/2014Sep/0135.html) considered an element based trigger points as an important feature.


```
  NOTE2: there are so many ways to define the syntax here
  that we expect it to change a lot. The important features
  are:

  - starting an animation at a point in the page
  - a point is able to be specified in relation to an element's position
  - it's possible to specify an end point, which would stop the animation
    (although this could be split into two properties, so you
    could have on/off animations as you scroll).
```




Please view or discuss this issue at https://github.com/w3c/csswg-drafts/issues/4337 using your GitHub account

Received on Thursday, 19 September 2019 05:41:05 UTC