croll-Driven State Transfer

In my fourth article about scroll-driven animations, I explore how we can transfer the state of one element to a completely different place on a page by connecting them with a unique identifier in CSS via a timeline-scope.

ntroduction

Today’s technique is a variation of an effect that I previously demonstrated a few times:

The gist of the effect I’m talking about is an ability to mirror a particular state of some element — for example, hovered or focused — to an element in a different place on the page without a common or unique ancestral element that could have been used to deliver that stateGo to a sidenote.

The minimal API that I want in the end is to be able to connect two elements via a shared unique identifier without modifying the global CSS stylesheet — without adding new selectors and rules.

Today, as you could have guessed from the article name, I’ll implement this effect with scroll-driven animations.

isclaimers

  1. At the moment of writing, scroll-driven animations are implemented only in Chromium-based browsers, but I am providing videos for all the examples, allowing you to see how they work. However, for the best experience, try opening the article in Chrome, Edge or Opera; it can be fun to play with the examples there!

  2. This is my fourth articleGo to a sidenote on the topic of Scroll-Driven Animations. While the method I’m talking about today does not directly follow what I wrote before, I would skip a lot of the links to this CSS feature’s specs and assume you know what they are in general.

  3. In the examples, I’m using this effect to connect the elements in various ways, but that connection is only visual. If that technique is to be used in production (which I do not recommend until it lands in all browsers), then based on the use case, you’d need to think about how that connection could be conveyed in non-visual ways, like by using the aria-describedby and alike. If you know how this can be done properly and are willing to share it with me, anything would be welcome, as I’m not an accessibility expert.

    There is a chance that in the future we would be able to use the values from the HTML attributes as parts of the idents in CSS (see this CSSWG issue by Bramus), in which case we could use the values of the id, aria-describedby and other attributes to construct the idents in CSS and connect the elements without using additional custom properties.

he Core Technique

Let me take the Cross-Referencing example from my article about anchor positioning and replace its method with scroll-driven animations instead:

Which property corresponds with which value?

  1. display
  2. visibility
  3. opacity
  1. hidden
  2. 0
  3. none

The HTML for that example is exactly the same as for the anchor-positioning example: the only thing we need in order to connect our elements are --is and --for custom properties with the dashed idents as values.

And here is the whole CSS that is responsible for the technique in that example:

@keyframes --is-active--example1 {
  entry 0%, exit 100% {
    --is-active: initial;
  }
}

.example1 [style*='--is:'] {
  animation: --is-active--example1;
  animation-timeline: var(--is);

  --is-active: ;
  outline: var(--is-active, 4px solid hotpink);
}

.example1 [style*='--for:']:is(:hover, :focus-visible) {
  view-timeline: var(--for);
}

.example1 {
  timeline-scope: --property, --value, --none, --display,
    --hidden, --visibility, --zero, --opacity;
}

It is a bit more involved than the anchor-positioning one, so let’s go through it step by step.

amed Timeline Range Keyframe Selectors

Let’s start from the @keyframesGo to a sidenote:

@keyframes --is-active--example1 {
  entry 0%, exit 100% {
    --is-active: initial;
  }
}

This was the first time I played with Named Timeline Range Keyframe Selectors.

They are a handy way of specifying the timeline range right in the @keyframes, which gives us the benefit of no longer having to write the explicitGo to a sidenote animation-range: entry 0% exit 100% whenever we use these keyframes, making it easier to reuse them.

The entry 0%, exit 100% value covers the whole distance the element can be in inside its scrollport.

The main difference from the regular animation-range: we can only use percentages here, so we can’t use values in px, calc() etc.

The declaration that we set is --is-active: initial, which might seem weird, but it is just the “space toggle” in action. A bit more on this later.

pplying the Animation and Using the State

.example1 [style*='--is:'] {
  animation: --is-active--example1; /* 1 */
  animation-timeline: var(--is);    /* 2 */

  --is-active: ; /* 3 */
  outline: var(--is-active, 4px solid hotpink); /* 4 */
}
  1. We’re using the animation shorthand to mention these @keyframes we defined previously. It doesn’t matter if we use the shorthand or just an animation-name here.
  2. The important part is applying the timeline from the --is CSS variable. Here we are basically “subscribing” to a particular named timeline. More on this later.
  3. The initial state for our --is-active space toggleGo to a sidenote.
  4. Using the space toggle: by default the outline would be just an empty value, but as soon as the animation would be applied, the --is-active would become initial, and the fallback value (4px solid hotpink) would be used.

elivering the State

.example1 [style*='--for:']:is(:hover, :focus-visible) {
  view-timeline: var(--for);
}

Whenever we want to apply our state — in this case, when we hover or focus over our element with the --for variable defined inline — we can apply this variable as view-timeline, and that’s it! Oh, wait, no, it isn’t. We forgot the most important part:

ifting the State Up with the Timeline Scope

.example1 {
  timeline-scope: --property, --value, --none, --display,
    --hidden, --visibility, --zero, --opacity;
}

In order for this technique to work, we have to explicitly define the scope within which the timelines could be used. This is the biggest limitation of this method, as we have to list all the values we’d be using for our timelines; otherwise, we couldn’t define and reuse them on completely different elements in our scope’s subtree.

Because this requires only modifying one value of one CSS propertyGo to a sidenote, this can be done inline in HTML, so it does not require creating new rules in the stylesheets.

There is good news: there is a chance we would get an all keyword possible for the timeline-scope property, which would allow us to just get everything and not care about listing all the values explicitly. You can subscribe to this CSSWG issue if you’d like to follow any developments of this feature. When we have this built-in, this technique will become so much more powerful.

Now, that’s really it. For the basic technique.

ariations

ne to Many

With anchor positioning, we initially had a limitationGo to a sidenote where we couldn’t apply multiple anchor names to a single element. With timelines, we don’t have this problem, so it was very easy for me to modify our example above to allow a single element to target multiple others:

Which property corresponds with which value?

  1. display
  2. visibility
  3. opacity
  1. hidden
  2. 0
  3. none

Here we didn’t touch the CSS and only modified the HTML, re-shuffling our idents so one element now contains multiple names in a --for variable:

<em style="
  --is: --property;
  --for: --display, --visibility, --opacity;
">

It works the same: the --for variable is delivered to the view-timeline property, which would happily accept any number of comma-separated timeline names; we don’t need to do anything special in addition to this.

any to One

Ok, so we can pass multiple values to the --for, but what about the --is? It won’t work as we would expect it to. Here is a broken example:

What is a property which has hidden as a possible value?

What is a value which can be assigned to a display property?

  1. none
  2. !important
  3. visibility

We kept the CSS the same, but the HTML for the above example contains this for the list items:

<li>
  <code style="--is: --property, --none">
    visibility
  </code>
</li>
<li>
  <code style="--is: --none, --value, --display">
    none
  </code>
</li>

We can see that when we pass multiple comma-separated values to the --is, only the first one works, making it so one using --property works but nothing else does.

Why is that? Can we fix it? We can!

What is a property which has hidden as a possible value?

What is a value which can be assigned to a display property?

  1. none
  2. !important
  3. visibility

The fix is not perfect and can look a bit weird:

.example1-fixed [style*='--is:'] {
  animation-name:
    --is-active--example1,
    --is-active--example1,
    --is-active--example1;
}

Yes, we did repeat the same animation-nameGo to a sidenote three times. Unlike other animation sub-properties, the animation-name is never repeated by itself.

When we provide multiple comma-separated values to animation-timeline, it does not create new animations. We can think of animation-name as the leading sub-property; all others are followers. If we have only one name, only one animation is applied. So we cannot apply any of the values animation-timeline which go to the non-existent animations. But if we define the name three times, we could “enable” each of the “slots” with our technique.

It’s not very convenient, but it works.

ingle Connecting Property

Before, we did use two different properties: --is and --for to connect our elements. This is just one of the many ways we could implement this; different needs might require different methods. One other way we could do it is to use a single property, especially when we want the connection to go both directions, like with the sidenotes in my blog.

If we don’t want to have groups of elements, we can simplify the first example by using only one custom property:

  1. display
  2. visibility
  3. opacity
  1. hidden
  2. 0
  3. none

Here is the complete CSS responsible for this second example:

@keyframes --is-active--example2 {
  entry 0%, exit 100% {
    --is-active: initial;
  }
}

.example2 [style*='--property:'] {
  animation: --is-active--example2;
  animation-timeline: var(--property);

  --is-active: ;
  outline: var(--is-active, 4px solid hotpink);
}

.example2 [style*='--property:']:is(:hover, :focus-visible) {
  view-timeline: var(--property);
}

.example2 {
  timeline-scope: --display, --visibility, --opacity;
}

If I wanted to replicate the first example more closely, I could have added : not (: hover, : focus-visible) to the rule with the animation, but I found the behavior where we highlight both elements each time even more useful.

And the only changes are a shorter list for timeline-scope and that the same variable is used for the selector and variable name.

oolean Logic

As with any other space toggles, we can apply a limited subset of boolean logic to them, like doing NOT, but for the “not active” state, I prefer to add a second space toggle, as it makes things easier to use. For example, the sidenotes on this page use these styles:

@keyframes --is-active {
  entry 0%, exit 100% {
    --is-active: initial;
    --not-active: ;
  }
}

[style*='--sidenote:']:not(:hover, :focus-within) {
  animation: --is-active;
  animation-timeline: var(--sidenote);

  --is-active: ;
  --not-active: initial;
}

[style*='--sidenote:']:is(:hover, :focus-within) {
  view-timeline: var(--sidenote);
}

.Sidenote::before {
  opacity:
    var(--is-active,  1)
    var(--not-active, 0);
}

.Sidenote::after,
.Sidelink::after {
  background:
    var(--is-active,
      var(--LIGHT, rgba(255, 255,  0, 0.3))
      var(--DARK,  rgba(150, 140, 90, 0.3))
    )
    var(--not-active, transparent);
}

Having two variables each time: --is-active and --not-active is much more convenient than having to define a separate temporary variable if we’d want to use the NOT condition.

We can see how it is very easy to nest the space toggle values: for the active state, we can apply different values for the light and dark themes, as they’re also implemented with space toggles!

Note that we could have still omitted the --not-active for the background, but I like to make things more explicit when possible.

ransitions

If you did manage to play with the sidenotes on this post, you could notice the transitions they have.

Here is a video of how they work:

A video of one of this article’s sidenotes, showing how hovering over the sidenote highlights its reference with a transition, and the other way around: hovering over the reference highlights the corresponding sidenote.

An interesting aspect of the properties set by animations is that we cannot use them for transitions on the same element due to the animation tainting.

However, what we can do is have transitions on the children of the elements with our animations. By using pseudo-elements, I’m able to toggle the background and opacity with a transition.

One important note I’d want to add is that this is more experimental than the scroll-driven animations themselves; I did test this behavior without them, and while it currently works in both Chrome and Safari, it does not work in Firefox yet. There is a known issue with custom properties toggled by animations, but in my testing, there is a difference even for regular inherited properties as well. I did open an issue in Mozilla’s bugzilla about that.

ultiple States

In the previous examples, we had only one state: combined hover and focus. But what if we’d like to have them separately and have three or more different states?

Here is the first example, but with added differentiation of the focus and hover states:

Which property corresponds with which value?

  1. display
  2. visibility
  3. opacity
  1. hidden
  2. 0
  3. none

There might be different ways this can be implemented. The one I choose for this example is not ideal, but it is the least intrusive: we have to add only one an additional timeline to the scope, then define the keyframes for it, using two states for on/off values:

@keyframes --is-focused {
  entry 0%, exit 100% {
    --is-focused: initial;
    --not-focused: ;
  }
}

And then, when using it, use nested space toggles:

.example3 [style*='--is:'] {
  animation: --is-active--example3, --is-focused;
  animation-timeline: var(--is), --is-focused;

  --is-active: ;
  --is-focused: ;
  --not-focused: initial;

  outline:
    var(--is-active,
      var(--not-focused, 2px solid pink)
      var(--is-focused, 4px solid hotpink)
    );
}

By doing this, we always know which element is currently hovered and focused and can differentiate which state it is based on the additional timeline we flip.

The downside of this method is that whenever we focus any of the items and then use hover without removing the focus first, our hover styles would be the same as focused, as the focus state timeline is universal.

One way to handle this would be to introduce two different timelines per value, which is a bit cumbersome, or to introduce helpers for every item and do some clever stuff with view timelines, where we could always have the same timeline but would modify it in a way that would “choose” the right position corresponding to the keyframe we want to use. I’ve already been working on this article for too longGo to a sidenote. If you want, you can treat this as your homework: go and play with this technique and try to improve it!

inal Words and Credits

That technique comes from my previous attempts at implementing it via anchor positioning, alongside a few other articles that were not directly related but still did contribute some inspiration and motivation:

And, once again, even though I did use this technique for the sidenotes on my site, I do not recommend using scroll-driven animations for production. Only for these tiny progressive enhancement purposes, where you double-check that nothing would break in browsers that do not support the technology yet.

I can’t wait for timeline-scope: all to become available, as it would make this technique so much more powerful, and for scroll-driven animations to come to other browsers.


Let me know what you think about this article on Mastodon!