w3c / csswg-drafts

CSS Working Group Editor Drafts
https://drafts.csswg.org/
Other
4.44k stars 657 forks source link

[css-animations] The "via" keyframe selector #6151

Open vrugtehagel opened 3 years ago

vrugtehagel commented 3 years ago

Proposal: the via keyframe selector

Problem

When we write CSS keyframe animations, especially large ones, it is not uncommon that we need to add or remove a keyframe. In a lot of cases, the keyframe selectors are evenly-spaced, so adding a keyframe requires us to edit every keyframe selector in that animation. For example, say we wrote:

@keyframes rainbowText {
    from { color: red; }
    25% { color: orange; }
    50% { color: yellow; }
    75% { color: green; }
    to { color: blue; }
}

Now, we test it and see the orange is a bit ugly, and we want to remove it. We rewrite the above to

@keyframes rainbowText {
    from { color: red; }
    33.333% { color: yellow; }
    66.667% { color: green; }
    to { color: blue; }
}

We now notice it's not very rainbow-ey, so want to try to re-add orange, but add purple too this time... We rewrite all the keyframes, etcetera, you get the point.

There's two issues here:

Proposal

Here's my solution to this: the via keyframe selector. Essentially, it would linearly interpolate between the closest bounding absolutely specified keyframe selectors. Let me demonstrate by example.

Example usage

The original rainbowText animation would end up looking like

@keyframes rainbowText {
    from { color: red; }
    via { color: orange; }
    via { color: yellow; }
    via { color: green; }
    to { color: blue; }
}

Now, adding a keyframe is a breeze, because we don't need to worry about the keyframe selectors for the other keyframes. A little more of a complex example, showing how you can mix via and absolute keyframe selectors:

@keyframes fadeInRainbowTextFadeOut {
    from { opacity: 0; }
    25% { opacity: 1; color: red; }
    via { color: orange; }
    via { color: yellow; }
    via { color: green; }
    50% { opacity: 1; color: blue; }
    to { opacity: 0; }
}

The via keywords interpolate between the bounding absolute keyframe selectors (25% and 50%), so it would be equivalent to

@keyframes fadeInRainbowTextFadeOutSlow {
    from { opacity: 0; }
    25% { opacity: 1; color: red; }
    31.25% { color: orange; }
    37.5% { color: yellow; }
    43.75% { color: green; }
    50% { opacity: 1; color: blue; }
    to { opacity: 0; }
}

This would also allow for keyframe selectors like (I'll list them out here rather than making up a long animation to demonstrate) [from, 10%, via, via, 40%, 52%, 70%, via, 80%, to], which would be equivalent to [0%, 10%, 20%, 30%, 40%, 52%, 70%, 75%, 80%, 100%].

Links

Original discourse post (also by me): https://discourse.wicg.io/t/proposal-css-keyframes-via-keyword/5219 Relevant part of the spec: https://drafts.csswg.org/css-animations/#typedef-keyframe-selector

SebastianZ commented 3 years ago

One thing to consider is that at the moment the order of the keyframe rules doesn't matter. When a keyword like via is introduced, their order gets relevant.

Also, the keyframe selector takes a comma-separated list, so that also applies in that case. So the following case

@keyframes x {
    from { color: blue; }
    via, via, 30%, via { color: yellow; }
    to { color: lime; }
}

would then be equivalent to

@keyframes x {
    0% { color: blue; }
    10% { color: yellow; }
    20% { color: yellow; }
    30% { color: yellow; }
    65% { color: yellow; }
    100% { color: lime; }
}

Sebastian

vrugtehagel commented 3 years ago

I must admit, I did not consider the order as I rarely ever make use of this feature, but in the case that the "absolute" keyframes selectors aren't ordered properly, there are two things we could logically do.

So let's say we have

@keyframes blink {
    90% { opacity: 1; }
    via { opacity: 0; }
    0% { opacity: 1; }
}

Then the first rule would say "this via keyframe selector is invalid", dropping that keyframe block. The second would instead say "this is fine" and take the via to mean 45%. I'm not sure which of these is preferable to the general public, but I'd prefer the second for flexibility and consistency. All in all the order of the keyframes doesn't seem too much of an issue.

Loirooriol commented 3 years ago

We need to calculate the new keyframe selectors and approximate decimal values (e.g. the 66.667% in the above example)

Another possible solution could be the ability to specify the ending percentage, defaulting to 100%. So you would write

@keyframes rainbowText {
    @end 4%;
    0% { color: red; }
    1% { color: orange; }
    2% { color: yellow; }
    3% { color: green; }
    4% { color: blue; }
}

Then remove orange


@keyframes rainbowText {
    @end 3%;
    0% { color: red; }
    1% { color: yellow; }
    2% { color: green; }
    3% { color: blue; }
}
vrugtehagel commented 3 years ago

@Loirooriol while that does solve the decimals, it does not address the issue that you need to edit multiple keyframe selectors upon adding or removing one (e.g. if I want to remove 1% { color: orange; } in your first example, I still need to edit the 2%, 3% and 4%, as well as the defined @end). It also feels a bit odd since percentages by definition work on a scale from 0-100, and this modifies that scale so they technically wouldn't be percentages anymore.

SebastianZ commented 3 years ago

I agree with @vrugtehagel about your proposal, @Loirooriol. Instead of bending the meaning of the percentages that way, integers would make much more sense in this case. Then the keyframes would be sorted by them, ascending. By that I mean that the integer values would only define the order of the keyframes but not have any meaning on where exactly the keyframe is positioned, i.e. they would be evenly spread across 0% and 100%.

So, instead of

@keyframes rainbowText {
    @end 4%;
    0% { color: red; }
    1% { color: orange; }
    2% { color: yellow; }
    3% { color: green; }
    4% { color: blue; }
}

you'd write something like

@keyframes rainbowText {
    0 { color: red; }
    10 { color: orange; }
    20 { color: yellow; }
    30 { color: green; }
    40 { color: blue; }
}

or

@keyframes rainbowText {
    0 { color: red; }
    5 { color: orange; }
    10 { color: yellow; }
    15 { color: green; }
    20 { color: blue; }
}

Both of them would be equivalent to

@keyframes rainbowText {
    0% { color: red; }
    25% { color: orange; }
    50% { color: yellow; }
    75% { color: green; }
    100% { color: blue; }
}

Then removing one keyframe or adding one in between would be less of a problem. Though one disadvantage would be that you don't have any influence on the exact positioning of the keyframes as they are evenly spread. And if you added more keyframes in between two keyframes, you'd still have to adjust one of them. Furthermore, @vrugtehagel's suggested syntax is less disruptive and integrates better with the exising @keyframes syntax.

@vrugtehagel Note that an @keyframes rule could also look like this:

@keyframes x {
    90% { opacity: 1; }
    via { opacity: 0; }
    0% { opacity: 1; }
    50% { opacity: 0.5; }
}

(Not saying, anybody should actually write a rule in such a chaotic order. 😄) So, a third solution for the order issue would be to make the whole @keyframes rule invalid when the order is mixed and includes one or more via keyframes. Having said that, I probably wouldn't prefer that solution but rather one that handles this situation gracefully. Though I don't have a strong opinion for one of the two you mentioned. I just wanted to point out that this case needs to be handled. And there might also be other ways to handle them.

Another case that needs to be specified is what happens when a via keyframe is placed at the beginning or the end of an @keyframes rule.
Possible solutions I see here are

  1. making the whole @keyframes rule invalid. (Again, not my favorite, rather mentioning it for completeness.)
  2. letting via resolve to 0% at the beginning and 100% at the end of the @keyframes rule.
  3. letting via keyframe rules interpolate between 0% and the first percentage keyframe.
  4. making the via keyframe rule invalid.

In this case, I'd prefer option 3, as it is the nearest to what was suggested.

Sebastian

vrugtehagel commented 3 years ago

I indeed agree with option 3 there. We can already leave out the start and end of a keyframe animation; the specs clearly state

If a 0% or from keyframe is not specified, then the user agent constructs a 0% keyframe using the computed values of the properties being animated. If a 100% or to keyframe is not specified, then the user agent constructs a 100% keyframe using the computed values of the properties being animated.`

I would not like to change this; via should not be able to "replace" from and to. Writing a keyframe animation like so:

@keyframes hop {
    via { transform: translateY(-10px); }
}

would mean it resolves to 50%, and, as the specs currently specify, 0% and 100% will be constructed using the computed values.

I think the biggest ambiguity we have is what happens when, for example, someone writes

@keyframes fadeOutIn {
    0%, 90% { opacity: 1; }
    via { opacity: 0; }
    50% { opacity: 0.5; }
}

Even without the via keyword, this is hard to read, but I admit the via keyword adds some complexity as I now have to think about what it represents. Note that, while writing this is possible, it should be encouraged that authors write their keyframe selectors in order when using via to increase readability. Either way, the above would logically be equivalent to

@keyframes fadeOutIn {
    0% { opacity: 1; }
    50% { opacity: 0.5; }
    70% { opacity: 0; }
    90% { opacity: 1; }
}

That is, even when using keyframe selector lists (such as 0%, 90%) the order will be relevant to how via will behave (should it follow, be included in, or precede such list).

markhicken commented 2 years ago

Can anyone think of a case where you would actually want to build your @keyframes out of order? It seems odd to me that, we would need to support that. I'm sure some people are doing it because it's been allowed in the past, but it seems like a very strange practice to me. The animation has to execute in a specific order so why not expect the definition/input in the same order?

I would suggest having a look at how GSAP and AnimJs handle keyframes. These are just a few examples. Having used many different animation libraries over the last 20 years, I'd really love to see CSS come closer to their syntax. It's way more flexible for defining where you want different easing types and durations to occur, how long your overall animation should run and for interrupting animations and changing the motion paths to a new animation midway through.

I don't want to blow up the scope of this issue. I think the via keyword is a great idea, but it leads me to think that the current %-based syntax is less than ideal. Perhaps we need a completely separate @sequential-keyframes syntax?

tabatkins commented 2 years ago

Yes, the most obvious case is something like 0%, 100% {...} 50% {...}.

vrugtehagel commented 2 years ago

Writing your keyframes out of order can be useful. Specifically, people tend to do this whenever blocks to different keyframes are the same; it allows you to entirely omit one of the blocks. Tabatkins shows a simple example, but it extends to bigger animations as well. This is a good point, though; if you have big animations, shortened by grouping some of the keyframes into lists of keyframes, then via becomes somewhat useless. You'd have to choose to either write them in order, and repeat the blocks, or not use via at all.

However, I still think via would be helpful. The above case is, as far as I know, pretty rare. Markhicken's doubt that anyone writes keyframes out of order would further support that. However, I decided to write an HTTP archive query to further back this statement.

Turns out, about 89.96% of all keyframe definitions are defined in the proper order. Tabatkins' example is, funnily enough, only the second most common case with 0.8%. The most common (at 1.3%) non-ordered animation is this:

@keyframes mostCommonWeirdlyOrderedAnimation {
    0%, 60%, 75%, 90%, 100% { ... }
    0% { ... }
    60% { ... }
    75% { ... }
    90% { ... }
    100% { ... }
}

which looks like it could be bounceInDown from animate.css (though that's just a guess). These keyframe selectors are technically not in order (as there's a 0% following a 100%), but via would still be useful in cases like these.

Either way, just by the fact that only 10% of the keyframe animations are not in the correct order, I'd say via would still be a good addition to CSS. Even amongst the 10% there are still cases like the above case where via could be useful.

As far as a separate rule like @sequential-keyframes goes - it would indeed be blowing this out of scope a little. This proposal is intended to make the use of @keyframes more fluent, not to extend its possibilities. via is handy even for smaller, more straightforward animations. If you have ideas for a non-percentage based keyframe syntax, that should probably be its own proposal. Even then, it probably won't completely replace @keyframes so a little boost to @keyframes' usability will still make CSS better.

LeaVerou commented 2 years ago

YES let's please address the use case! As an author I have very frequently been frustrated with this.

Out of the ideas proposed above, the one with <number> made the most sense to me.

I’ll add another idea to the pile, possibly bad but it may help brainstorming.

What if we could invert the grouping of keyframes and properties, and we could just list the values a given property will go through and have the UA automatically figure out the keyframes, akin to how gradient color stops work?

I.e.

@keyframes rainbowText {
    color {
        values: red; orange; yellow; green; blue;
    }
}

Note that this would allow us to do things like:

@keyframes rainbowText {
    color {
        values: red; orange; yellow; green; blue;
    }

    opacity {
        values: 0; 1; .5;
    }
}

without all the annoying math this would require to do with keyframe percentages.

birtles commented 2 years ago

What if we could invert the grouping of keyframes and properties, and we could just list the values a given property will go through and have the UA automatically figure out the keyframes, akin to how gradient color stops work?

The Web Animations API allows this. That is, you can provide a list of keyframes or a list of values per property. See the examples at: https://drafts.csswg.org/web-animations-1/#processing-a-keyframes-argument

It also allows omitting the keyframe offsets in either case. Whatever we do here should probably align with that to some extent.

vrugtehagel commented 2 years ago

@LeaVerou that's a great idea! The syntax looks a bit weird to me, but I suppose we can't really accomplish that idea without new syntax. I thought first that separating the values with / rather than ; would be nice, but that wouldn't actually work for properties that already use it (e.g. animating border-radius that has values containing slashes). Then I thought a function could work, like opacity: animate(0, .7); but that causes issues when you're animating values that already use commas.

@birtles makes a good point as well. We can define a Keyframe object in JavaScript in such a way already, and so it would be nice to align CSS with this. This proposal, as birtles observed, is a step in that direction, though just does not deal with the "inverted" way of writing keyframes. Specifically, via addresses the following JavaScript Keyframe feature (from MDN's page on Keyframe formats):

It is not necessary to specify an offset for every keyframe. Keyframes without a specified offset will be evenly spaced between adjacent keyframes.

The only difference here is that in JavaScript, your keyframes must be in the right order whereas CSS allows you to define them in any order you want.

Anyway, while I do really like the concept of being able to "invert" the keyframe definition as LeaVerou suggested, I would again lean towards that being a separate proposal. That and this proposal are similar and obviously related but are simply different features, and solve slightly different use cases. Let me illustrate this with an example:

@keyframes wiggleThenRainbow {
    0% { left: 0; }
    10% { left: -10px; }
    20% { left: 10px; }
    30% { left: -10px; }
    40% { left: 0; color: black; }
    55% { color: red; }
    70% { color: blue; }
    85% { color: yellow; }
    100% { color: green; }
}

This animation is hard to write with inverted notation (if even possible at all) but would benefit from via. In particular, 10%, 20% 30%, 55%, 70% and 85% could be replaced by via. Then, you could just change the 40% to adjust where the wiggling ends and where the rainbow starts. It also allows you to much more easily add or remove a wiggle or rainbow color.

However the inverted syntax would be very useful for cases like this:

@keyframes wiggleAndRainbow {
    0% { left: 0; color: black; }
    20% { color: red; }
    25% { left: -10px; }
    40% { color: blue; }
    50% { left: 10px; }
    60% { color: yellow; }
    75% { left: -10px; }
    80% { color: green; }
    100% { left: 0; color: black; }
}

You could, in this case, use via if you wanted, and write

@keyframes wiggleAndRainbow {
    from {color: black; }
    via { color: red; }
    via { color: blue; }
    via { color: yellow; }
    via { color: green; }
    to { color: black; }

    from { left: 0; }
    via { left: -10px; }
    via { left: 10px; }
    via { left: -10px; }
    to { left: 0; }
}

That would, similar to the previous example, simplify adding or removing a wiggle or rainbow color, though the inverted syntax would most definitely make more sense than via here. Either way, these two examples demonstrate the difference in use case for via and the inverted syntax.

SebastianZ commented 2 years ago

Anyway, while I do really like the concept of being able to "invert" the keyframe definition as LeaVerou suggested, I would again lean towards that being a separate proposal.

I agree with that.

For what it's worth, a keyword could also be used for that syntax instead of the semicolon, e.g.

@keyframes rainbowText {
    color {
        values: red to orange to yellow to green to blue;
    }

    opacity {
        values: 0 to 1 to .5;
    }
}

That's probably not as readable as using an existing separator in some cases but wouldn't conflict with existing syntax. And it needs a keyword that is currently not used in any syntax. to is just an example and is already used, I think.

@keyframes wiggleAndRainbow {
    from {color: black; }
    via { color: red; }
    via { color: blue; }
    via { color: yellow; }
    via { color: green; }
    to { color: black; }

    from { left: 0; }
    via { left: -10px; }
    via { left: 10px; }
    via { left: -10px; }
    to { left: 0; }
}

Having multiple animations within one @keyframes rule is also out of scope for this proposal, I'd say. Also, it isn't needed. The current logic allows to combine multiple animations using the animation property. So you would split both animations into separate rules like this:

> @keyframes rainbow {
>     from {color: black; }
>     via { color: red; }
>     via { color: blue; }
>     via { color: yellow; }
>     via { color: green; }
>     to { color: black; }
> }
>
> @keyframes wiggle {
>     from { left: 0; }
>     via { left: -10px; }
>     via { left: 10px; }
>     via { left: -10px; }
>     to { left: 0; }
> }

And then animate them individually within animation. Furthermore, with animation-composition they can be combined the way you suggest in your example with the percentages.

Sebastian

vrugtehagel commented 2 years ago

@SebastianZ the last example I gave, wiggleAndRainbow, is, apart from the via keyword, already possible. It's not multiple animations within one @keyframes, it's still just one animation, the properties are just split (in an admittedly weird way). Either way, it was just an example to demonstrate that you can still utilize via even if you have more than one property that you want to linearly interpolate between. In this case, you can indeed just split them, but you may not always want to; for example, if you will only ever use them together and want to only specify one animation in the animation property.

LeaVerou commented 2 years ago

We recently discussed which separators we can use for arbitrary values, as it was needed for mix() and last-supported(), and concluded that ; was the only one that is guaranteed to not be used by any property value.