Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Interpolate values between breakpoints #6245

Open
scottkellum opened this issue Apr 27, 2021 · 63 comments
Open

Interpolate values between breakpoints #6245

scottkellum opened this issue Apr 27, 2021 · 63 comments

Comments

@scottkellum
Copy link

scottkellum commented Apr 27, 2021

Allow interpolation between both viewport and element breakpoints.

The problems with clamp(), min(), and max() is that you can only interpolate length values on a single property between two points. You may want to interpolate rulesets across multiple breakpoints. You may also want to interpolate things like variable font settings, color, etc. Additionally it would be nice to be able to ease how breakpoints are interpolated as rates at which things scale across different screen or element sizes can often be variable.

Update Dec. 7, 2022

I’ve created an explainer on this issue with a more detailed proposal: https://css.typetura.com/ruleset-interpolation/explainer/

This includes a more specific and detailed proposal as well as a companion proposal to expand scroll timeline. Expanding scroll timeline is likely the easiest solution at the moment but it would make it more user friendly to allow length values as keyframes. This can always be added at a later date though.

@mirisuzanne
Copy link
Contributor

Yeah, this would be real useful for something like "intrinsic typography" - but I can imagine other use-cases as well. It's interesting to think about media/container "breakpoints" as keyframes in an animation, which we can then interpolate (with easing).

My immediate association is scroll-timeline. I wonder if there would be a way to adapt something similar to get, basically, a container-timeline.

@fantasai
Copy link
Collaborator

fantasai commented Sep 24, 2021

@mirisuzanne and I put together this proposal for defining and using query-linked timelines as part of rethinking various features for animation timelines and interpolation and how to fit them all together.

Query-linked Timelines

Query-linked interpolation uses a set of keyframes (minimally, two) to interpolate values along an easing curve based on the value of a query (such as a media query or container query). The timeline is therefore defined by the value of the query, and can be referenced by an interpolation function in individual property declarations.

Defining the Query Timeline

The @timeline rule defines a named timeline. It can be expanded later to define other types of timelines, but here we're defining only two types: media query timelines and container query timelines.

@timeline NAME {
  type: media | container;
  feature: <media-feature-name> | <container-feature-name>;
  from: <value>; /* 0% of the timeline */
  to: <value>; /* 100% of the timeline */
  container: <'container'>; /* only applies to container query timelines,
                               same seeking function as container queries */
}

A typical example might look like:

@timeline font-size-timeline {
  type: media;
  feature: width;
  from: 20em;
  to: 60em;
}

While query-linked timelines can be referenced in animation-timeline, it's not recommended to use this method in most cases because it would cause cascading problem: anyone using query-based interpolation via animation properties would override all affected properties at levels of the cascade.

They can, however, be referenced by an interpolation function within the affected property declarations, which allows the interpolated value to cascade the same as any other declared value.

Value Interpolation

Value interpolation uses a percentage value to indicate how close or far from the start/end points to calculate the interpolated value. Interpolation is interpreted through an easing curve, and the input percentage can be selected based on the current position on a timeline such as a query timeline.

Timeline-based Value Interpolation

This extends the generic interpolation function adopted (but as yet unnamed ;) in #581

  mix( [ <timeline> && [ by <easing-function> ]? ] ; <start-value> ; <end-value>)

By naming a timeline instead of giving a percentage directly (see percentage mixes in #581 (comment)), the author can use progress along a timeline as the progress percentage. Any value valid for animation-timeline or any timeline name defined via @timeline is valid, which allows the mix() to respond to query-linked timelines and scroll-linked timelines.

Value Interpolation with Keyframes

For more complex interpolation curves, the <start-value> and <end-value> can be replaced by a reference to a named set of keyframes.

  mix( [ <timeline> && [ by <easing-function> ]? && of <animation-name> ] )

Note: Using keyword markers (as in gradients) allows the arguments to be reordered, so that authors don't have to memorize positions of arguments.

@bramus
Copy link
Contributor

bramus commented Sep 24, 2021

A typical example might look like:

@timeline font-size-timeline {
  type: media;
  feature: width;
  from: 20em;
  to: 60em;
}

As an addendum: for CSS @scroll-timeline — as specced in the Scroll-Linked Animations spec — the authors explicitly moved away from using start (here named from) and end (there named to) as descriptors.

They replaced it with one descriptor named scroll-offsets (which here could be named offsets), that accepts an array of values.

As per spec:

Scroll timeline offsets determine the effective scroll offsets in the direction specified by orientation that constitute the equally-distanced in progress intervals in which the timeline is active.

That way they allow more than two offset to be used.

Relevant Issue: #4912, specifically this comment.

@mirisuzanne
Copy link
Contributor

@bramus If I understand right, we're thinking about timelines a bit differently here. They seem to be establishing the number and placement of keyframes in the timeline description, and that seems to me like the wrong location for that information.

In our proposal, the timeline doesn't establish the available keyframes, just the distance that we travel from 0% complete to 100% complete. Then authors can attach that to an animation or interpolation function with as many keyframes as they need. The individual offset concerns of each animation ("reveal, "unreveal", etc) are handled in keyframes, rather than in the timeline itself. So you have a single timeline (x to y), and then the ability to offset keyframes within that timeline. "Reveal" might happen between 20%-40% of the timeline, and "unreveal" happens from 60%-80%, each one using as many keyframes as it wants.

The number and placement of keyframes is controlled by the animation/interpolation rather than by the timeline.

But maybe I'm misunderstanding something there?

@bramus
Copy link
Contributor

bramus commented Sep 26, 2021

@mirisuzanne You understand correctly there. Simply wanted to point out that “this move in the other direction” was made before, and that it perhaps could be relevant to take into account ;)

@css-meeting-bot
Copy link
Member

The CSS Working Group just discussed interpolating values between breakpoints, and agreed to the following:

  • RESOLVED: Accept mix() function into Values 5
The full IRC log of that discussion <TabAtkins> Topic: interpolating values between breakpoints
<TabAtkins> github: https://github.com//issues/6245
<fantasai> https://github.com//issues/6245#issuecomment-926351855
<TabAtkins> miriam: This is building on that same idea, but creating timelines out of MQ/CQs
<TabAtkins> miriam: In this caes you're more often doing interpolated values based on the timeline, not animations specifically
<flackr> q+
<TabAtkins> miriam: We want to be able to create timelines off the size of the container
<TabAtkins> miriam: So for defining the query timeline, we have an @timeline syntax.
<smfr> q+
<TabAtkins> miriam: Give it a name, say what we're querying, what feature we're querying
<TabAtkins> miriam: And give it a from/to value to offset that range
<TabAtkins> miriam: So interp between a container being 100px and 40em to define the timeline
<TabAtkins> miriam: If it's a CQ we give the name of the container
<TabAtkins> miriam: If there are multiple CQs with that name this'll apply to all of them
<TabAtkins> miriam: Kids will look at their appropriate ancestor container
<TabAtkins> miriam: And we can use the timeline name in an animation-timeline
<TabAtkins> miriam: But more often we'll want a value that interps in the cascade instead, so we can override it if we need to
<TabAtkins> miriam: A generic interpolate() function has been discussed for a long time
<TabAtkins> miriam: We called in mix() here, named TBD
<TabAtkins> miriam: Idea is it could be generic, taking a %, or take a timeline which resolves to a %. Could invoke scroll timelines too, etc.
<TabAtkins> miriam: And then it takes an easing function and two values to interp between
<TabAtkins> miriam: In some cases this'll get more complex with multiple values, maybe get quite long
<TabAtkins> miriam: Wondered if mix() could ref keyframes
<TabAtkins> miriam: So you could pull out the value details into keyframes for more detailed control
<TabAtkins> fantasai: I wanted to point out the cascade effects
<chris> q+ to wonder about once more doing piecewise functions with no continuity
<TabAtkins> fantasai: We considered putting query-based timelines as a value of aniamtion-timeline
<TabAtkins> fantasai: That ends up applyin all the props at once, and at an overriding level of the cascade
<TabAtkins> fantasai: Usually you don't want that, you just want to specify a value at a normal cascade spot, but *based on* a timeline
<TabAtkins> fantasai: So my font-size timeline can just spec an interpolated normal font size, and then have an overriding rule setting the font-size to a specific value as normal.
<Rossen_> ack fantasai
<Zakim> fantasai, you wanted to react to flackr to point out cascade effects
<Rossen_> ack flackr
<fantasai> s/as normal/as usual in the cascade/
<TabAtkins> flackr: I think what fantasai just said might change my q...
<TabAtkins> flackr: So this isn't an animation timeline, it only exists for the mix() function?
<TabAtkins> fantasai: We were debating that.
<TabAtkins> fantasai: We definitely want it for mix(). Whether it's available for animation-timeline is an open question
<TabAtkins> fantasai: We've asked brian for feedback and he pointed out there were a lot of complexities, so we might not want to do it
<TabAtkins> fantasai: Not the most important; mix() is the primary case
<TabAtkins> flackr: Yeah was gonna raise the same complexities; if it's animation, we have to have the animation progress update in the middle of the cascade.
<TabAtkins> flackr: Anders said it would be a huge technical burden to have anims update as part of the cascade due to the cascade
<TabAtkins> smfr: This feels like calc() to me
<Rossen_> ack smfr
<TabAtkins> smfr: We could have one that interps with easing funcs
<TabAtkins> smfr: Missing piece is input from media features, could come in as env()
<TabAtkins> smfr: And so with a calc easing function thing
<fantasai> TabAtkins: not quite, implies only doing calc()-able things
<fantasai> TabAtkins: not all things that can be interpolated
<fantasai> TabAtkins: which includes colors, etc.
<fantasai> smfr: Can we make calc() accept these things?
<fantasai> TabAtkins: I don't want to but we can talk about it?
<Rossen_> ack chris
<Zakim> chris, you wanted to wonder about once more doing piecewise functions with no continuity
<TabAtkins> chris: So this is unpopular
<TabAtkins> chris: We start by lerping two values
<TabAtkins> chris: Then we add more values and lerp them
<TabAtkins> chris: And if you draw that it's jaggy on a graph because slopes are different
<TabAtkins> chris: And then we add easings, and you can maybe fake it to look continuous
<TabAtkins> chris: But we never get to a thing that smoothly interpolates thru N values
<TabAtkins> chris: Is that something we want to do or just continue keeping it pairwise?
<TabAtkins> flackr: Is this not having easing on the mix function?
<TabAtkins> chris: That requires the author to figure out C1 continuity on their own
<TabAtkins> fantasai: This seems compatible with what keyframes do right now, we could default to smooth interp
<TabAtkins> TabAtkins: So chris's request is for the abilty to spec an animation with N values and have it automatically smoothly interp, rather than only having pairwise interp control that needs manual adjustment
<TabAtkins> chris: yes
<TabAtkins> TabAtkins: c1 continuity, to be specific
<fantasai> fantasai: We specced multi-stop animations using @Keyframes, see last section of proposal
<Rossen_> q?
<fantasai> TabAtkins: I suspect that's something we can handle at a higher level
<fantasai> TabAtkins: we have a default for pairwise interpolation, default to ?
<fantasai> TabAtkins: could do smarter things in animations
<fantasai> TabAtkins: fits within existing syntax structure of animations
<fantasai> flackr: It will be challenging, though
<fantasai> flackr: easing function per keyframe is just between those endpoints
<fantasai> flackr: easin function on animation is just input time to output time
<fantasai> TabAtkins: animation-easing-function is the default between frames
<fantasai> flackr: that's correct for CSS. Web Animations also adds an easing curve to the timeline
<fantasai> TabAtkins: you're easing time into massaged version, that's separate from this
<Rossen_> ack fantasai
<TabAtkins> fantasai: i think we could easily have a "tweak the time"-based version, we could add that into the rule as well
<fantasai> s/rule/@timeline rule/
<fantasai> fantasai: Intention of mix() argument was the default easing between frames
<fantasai> fantasai: If we want to default to doing continuous magic, or adding a keyword to opt into it, that's fine
<TabAtkins> flackr: Yeah it would be like combining adjacent pairs that have the same value into one continuous timing function
<TabAtkins> fantasai: I want to point out we dont' ahve a resolution on the form of the generic interp function
<TabAtkins> fantasai: So our proposal is to have it accept %s and two values
<fantasai> https://github.com//issues/581#issuecomment-926353789
<TabAtkins> fantasai: So this would be a function that replaces the % with a timeline that computes to a %
<TabAtkins> fantasai: We have a resolution to *add* a mix() function but didn't settle on the syntax
<TabAtkins> Rossen_: So what can we resolve on?
<fantasai> s/this would be/this proposal is/
<TabAtkins> fantasai: resolution the first: generic interpolate function is called mix(). Takes %, then start value, then end value. Values are separated with semicolons to avoid ambiguity with comma-containing values
<TabAtkins> (you can interp a comma-separated list, for example)
<TabAtkins> TabAtkins: Simon had some thoughts about this in calc(), do you want to continue talkinga bout this?
<TabAtkins> smfr: I'm not quite sold on @timeline yet, but I don't want to stall this
<fantasai> https://github.com//issues/581
<TabAtkins> fantasai: Right now it's just mix()
<TabAtkins> smfr: Would this be like a calc()?
<TabAtkins> fantasai: Like, but wider.
<fantasai> fantasai: It has to be able to interpolate every possible computed value in the entire space of CSS
<TabAtkins> smfr: It requires UAs to have a parallel version of calc trees, for every possible value
<TabAtkins> fantasai: You kinda already have that since everything can interp
<TabAtkins> fantasai: Like, how do you interp between currentcolor and blue? No way to represent that right now. (color-mix() is coming, but this is a wider issue)
<TabAtkins> fantasai: So we have lots of places where we want to interp things that don't have intermediate values
<TabAtkins> smfr: That makes sense, we also invented cross-fade() to hit the image case
<TabAtkins> smfr: I'd like to hear from other impls about their thoughts on impl complexity, and whether it makes sense to think of it in terms of calc()
<fantasai> TabAtkins: I don't have problem of thinking about it in terms of calc(), can re-use machinery there
<fantasai> TabAtkins: but I think that's an internal detail
<TabAtkins> fantasai: Note that we *resolved* to add the function years ago but didn't resolve on the syntax
<fantasai> see also https://github.com//issues/2854
<TabAtkins> RESOLVED: Accept mix() function into Values 5
<Rossen_> s/Accept mix() function into Values 5/Accept mix() function into Values 4/
<TabAtkins> fantasai: So next is do we want mix() to accept a timeline+easing function instead of a %
<TabAtkins> fantasai: If no, I don't need to go into details. If yes, we'd use the @timeline rule discussed previously.
<TabAtkins> TabAtkins: This just got proposed last week, it's a little big. I'd like more time to review on it.
<TabAtkins> fantasai: And this would def go into level 5
<fantasai> ScribeNick: fantasai

@scottkellum
Copy link
Author

I have updated this issue with a more detailed explainer containing a proposal. Here is the explainer.

@danielsakhapov
Copy link
Contributor

Hello, everyone!

Since we now have scroll- and view-timelines, maybe it makes sense to do something similar for this problem?
Like, container-timelines? They will allow to drive a regular CSS/Web animation using the size of a query container’s content-box.

A container timeline is created similarly to how a scroll/view-timeline is created:

#container {
  container: mycontainer inline-size;
  container-timeline: mytimeline inline-size;
}

And then the animation is set up as you would set up a scroll/view-timeline:

#target {
  animation: anim auto;
  animation-timeline: mytimeline;
}

And keyframe offsets will accept <length> values. The following defines an animation that takes place between 40em and 800px:

@keyframes anim {
  40em {
    color: green;
  }
  800px {
    color: red;
  }
}

So, overall:

container-timeline-name: #<dashed-ident>
container-timeline-axis: #[ block | inline | x | y ]
container-timeline-range: #[ <length> <length> ]
container-timeline: #[ <container-timeline-name> [ <container-timeline-axis>? || <container-timeline-range> ] ]

With container-timeline-range being min and max lengths for the progress of the animation.

@bramus
Copy link
Contributor

bramus commented Sep 5, 2023

I’m very much sold on the idea.

Two remarks though:

  1. For ScrollTimeline/ViewTimeline the range is not part of the Timeline; there are no view-timeline-range or scroll-timeline-range properties. Instead, the range part of the animation, using the animation-range property. That way one can re-use one timeline instance for multiple animations with different ranges.

    I would suggest to do the same for container timelines.

  2. ScrollTimeline and ViewTimeline also have anonymous timelines, via the scroll() and view() functional notations. Ideally there should also be an anonymous container timeline.

    Strawperson proposal is to name it container(), with this syntax:

    <container()> = container( <axis>? )
    <axis> = block | inline | x | y
    

    An anonymous container timeline would always look up the nearest container in the ancestor tree. The default value for <axis> is block.

@andruud
Copy link
Member

andruud commented Sep 6, 2023

For ScrollTimeline/ViewTimeline the range is not part of the Timeline;

It very much is, it's just that it's set automatically from min/max scroll (etc) for those. The animation attachment range is not the same thing as the timeline range itself. Notice how animation-range-start/end:normal refers to the start/end of the timeline. Without container-timeline-range, we'd need another way of understanding the start/end.

@bramus
Copy link
Contributor

bramus commented Sep 6, 2023

Ah yes, I see now why one would need to define the container-timeline-range, because unlike scroll-timeline and view-timeline it cannot be automatically determined for containers. Thanks, @andruud.

@danielsakhapov
Copy link
Contributor

Sorry, I forgot about the functional notation. Container timeline can be created directly with a functional notation.
The following creates a timeline from the nearest inline-size container:

#target {
  animation: anim auto;
  animation-timeline: container(inline-size);
}

@andruud
Copy link
Member

andruud commented Dec 7, 2023

From #9343 (comment):

[@tabatkins]: The "extract a value from particular progress along a keyframe'd animation" idea is completely unrelated to mix(); it was discussed around the same time, but it's not in any way a "mixing" function. I'm annoyed it got folded into the spec; it needs to be a completely different function. I suggest ignoring it; I'm pinging Elika right now about killing it (and potentially reviving it in a dedicated function instead).

@tabatkins Is your objection mostly towards the re-use of the name mix(), or it is something more significant? We're considering a prototype of some of the primitives needed for this use-case, so it would be useful to know how deep this objection goes.

@tabatkins
Copy link
Member

Just the name. I have no particular opinion on the functionality - last time it was discussed I acknowledged it as having reasonable use-cases.

@kizu
Copy link
Member

kizu commented Dec 11, 2023

I did play a bit with the current prototype implementation in Canary, and stumbled upon an issue: https://bugs.chromium.org/p/chromium/issues/detail?id=1503730#c6

It seems, that the current formula in the spec does contradict the intended purpose of the function:

The value returned by a valid progress() notation is progress value / (end value - start value),

vs

returns a value representing the position of one calculation (the progress value) between two other calculations (the progress start value and progress end value).

With my example being progress(75px from 50px to 100px) — we want to know where the 75px lies between 100px and 50px. Per current formula it ends up being 75 / (100 - 50) === 1.5, but, I think, the intended formula was:

(progress value - start value) / (end value - start value)

Which, for the given values, will be (75 - 50) / (100 - 50) === '0.5', which will mean that 75px is half-way between 50px and 100px.

So, I think, the spec should be updated?

@mirisuzanne
Copy link
Contributor

Yeah, that looks like a typo in CSS Values 5.

(I'm willing to help edit this spec, if that's useful - since I've been working on it anyway. Not sure about the process for that.)

@tabatkins
Copy link
Member

Feel free to fix obvious typos regardless ^_^

@mirisuzanne
Copy link
Contributor

Progression calculation fixed in 921e2c0

@tabatkins
Copy link
Member

tabatkins commented Nov 11, 2024

@fantasai and I were reviewing this issue to see if the progress()/mix() functions adopted into the CSS Values L5 FPWD actually resolves it, and realized some of the important use-cases @scottkellum brought up still aren't solved by the current spec. Notably, providing multiple (more than two) breakpoints doesn't really work (except possibly via some very verbose/clumsy hacks). And interpolations of multiple properties is still awkwardly redundant.

For example, if you wanted to change 'color' from red to yellow as the container height went from 0px to 100px, and then from yellow to green as it went from 100px to 50em, that's not easily possible.

Currently, this is the best you could do:

/* using the keyframes feature to map progress to multiple stops */
@keyframes stoplight-colors {
    0% { color: red; }
    50% { color: yellow; }
    100% { color: green; }
}
el { 
    --progress: calc(
        clamp(0, container-progress(height, 0px, 100px) * .5), .5) 
        + clamp(0, container-progress(100px, 50em) * .5, .5));
    color: mix(var(--progress) of stoplight-colors);
}

/* using @container conditionals to emulate multiple stops */
el {
  color: green;
  @container (100px <= height < 50em) {
    color: color-mix(container-progress(height, 100px, 50em), yellow, green);
  }
  @container (height < 100px) {
    color: color-mix(container-progress(height, 0px, 100px), red, yellow);
  }
}

Neither of these are great, and the first one doesn't work with any of the *-mix() functions, only mix() itself.

Two areas we can explore further to solve these problems are:

  • Adopting some variant of @scottkellum's suggestion to use keyframes with or other dimensional value indices, and passing in an index function such as container-progress(height).
  • Extending the progress()/mix() notations to multiple stops... though because matching values by index between two functions becomes hard to read, means we probably want a combined function which groups stops with values similar to linear() and linear-gradient().

A keyframes reference more straightforwardly packages multiple properties across multiple breakpoints; an inline function allows easier re-use of a given breakpoint-value interpolation mapping across properties. So it's possible we might want to adopt both approaches.

@scottkellum
Copy link
Author

@tabatkins @fantasai Here is how I was planning on using this property to realize all my needs:

h1 {
  animation: h1 1s ease-in-out both paused 1;
  --from: 20rem;
  --to: 60rem;

  /* What I currently use (Safari only, I have another fallback for other browsers) */
  animation-delay: calc(-1s * (100cqi - var(--from)) / (var(--to) - var(--from)));

  /* What I will use with this new function */
  animation-delay: calc(-1s * container-progress(inline-size, var(--from), var(--to)));
}
@keyframes h1 {
  0% {
    font-size: 1rem;
    line-height: 1.2;
    color: lime;
  }
  50% {
    color: aqua;
  }
  100% {
    font-size: 5rem;
    line-height: 1;
    color: hotpink;
  }
}

This allows for multiple breakpoints, easing, and interpolating multiple properties at once as I rarely interpolate just a single property. Probably still more of a hack than most people are comfortable with, but it works well and the complexity of it is easily abstracted.

@mirisuzanne
Copy link
Contributor

There was also some discussion about providing a built-in mixin that would shortcut something like this - maybe without relying on the animation origin. Maybe something like…

h1 {
  @apply keyframes(h1, container-progress(inline-size, 20rem, 60rem));
}

@scottkellum
Copy link
Author

@mirisuzanne In this example, is easing also available?

h1 {
  @apply keyframes(h1, container-progress(inline-size, 20rem, 60rem), ease-in-out);
}

@mirisuzanne
Copy link
Contributor

I would expect easing, yes. I don't think anyone has worked on a full proposal for this yet.

@tabatkins
Copy link
Member

After talking thru the problem space and existing functions again, @fantasai and I have come up with an amended proposal for the *-mix() family of functions. As the *-mix() stuff hasn't been implemented yet, ripping it all out should be fine. ^_^

The fundamental disconnect is that there are two distinct notions of "mixing" being employed in these conversations, which look basically identical when you only have two values but are cleary distinct when you have 3+: are you "mixing" multiple values into one according to varying percentages, or are you "interpolating" (we'll use the term "mapping") between multiple breakpoints, with only two values being mixed at any given time? These two require completely different approaches when there are 3+ values. You can emulate either behavior with the other, but it's somewhere between annoying and untenably complicated.

Mixing Functions

The existing color-mix() and cross-fade() functions are the former: an unbounded number of values all combined with varying weights. As these are already implemented, we chose to leave them alone, and let the more general category of "functions like this" be called *-mix().

The general *-mix() grammars will follow the pattern:

foo-mix( <foo-specific-options>, [ <output-value> && <percentage>? ]# )

<foo-specific-option> are things like <color-interpolation-method> for color-mix(). Not all functions will have that. The <output-value> is specific to the function too; for color-mix() it's <color>. If the value type isn't distinguishable from <percentage> (like in calc-mix()), then we remove the &&, requiring the percentage to be second.

If any percentages are missing, they equally distribute the leftover percentage needed to add to 100% (floored at 0%); if the they add up to more than 100%, we rescale them to equal 100%; if they add to less than 100% we imply an additional "neutral" value with the remaining %. (For color-mix() it's transparent; for cross-fade() it's a transparent image with no natural dimensions; for calc-mix() it would be zero in the appropriate unit; etc)

Mapping Functions

For the "multiple breakpoints" case, we propose to introduce a *-map() family of functions. The existing gradient functions and linear() provide examples of this concept, but to accomodate more arbitrary values we have to generalize it a bit:

foo-map(<progress-and-top-level-options>, <stop>, [ [ <interpolation-options>, ]? <stop> ]#?)

expanding these out it looks like this:

foo-map(<progress-source> && [by <easing-function>]? && [each <easing-function> ]? && <foo-specific-option>?,
   <progress-value>{1,2} : <output-value> ,
  [ 
    [ <easing-function> || <foo-specific-option> , ]?
    <progress-value>{1,2} : <output-value> ,
  ]#?
)

where for the top-level options:

  • <foo-specific-option> is same as for *-mix(): <color-interpolation-method> for color-map(), etc
  • <progress-source> is a <number> or dimension, or a literal percentage (treated as an equivalent number)
  • by <easing-function> eases raw progress-source value directly, using the first/last stops to define the [0,1] range it's eased over (linear, if omitted)
  • each <easing-function> gives a default for the easing function between stops (linear, if omitted) so you don't have to repeat it each time

and for the stop list:

  • an <easing-function> between stops gives the easing behavior just between those stops (and can have function-specific options, too, like a longer hue just between two stops while the rest use the default)
  • <progress-value> is of the same type as the <progress-source>. Literal percentages are allowed (100% treated the same as 1).
  • two <progress-value>s in one clause are same as in gradients or linear(): they provide two stops with the same output value, creating a stretch with a constant value

To map across media/container queries, we introduce new media/container functions that return lengths/etc (in addition to the previously adopted media-progress()/etc, which turn the length/etc into a progress number 0-1):
* E.g. media(<media-feature>), container(<container-feature>)
* These allow using the start/end/stop values directly in *-map()'s stop lists, instead of requiring conversion to percentages up front.

Some use cases will prefer using the feature output directly, while others might prefer conversion to percentages using the -progress() variants. For example, both of these are valid/allowed:

color: color-map(media-progress(width, 0px, 2000px),
                 0%: red,
                 100%: blue); 
/* allows putting the percentage scale into a variable, 
   but awkward to read/write literally, especially with 3+ stops */


color: color-map(media(width),
                 0px: red,
                 2000px: blue); 
/* easier to read/write literally, 
   but can't extract the scale into a variable */

Regarding the generic whole-value mix() function, we're proposing to:

@tabatkins
Copy link
Member

@scottkellum's example could be written as:

h1 {
  font-size: map(container(inline-size) from h1 by ease-in-out);
  color: map(container(inline-size) from h1 by ease-in-out);
  line-height: map(container(inline-size) from h1 by ease-in-out);
  /* all these are identical, so it could be put in a variable to reduce duplication */
}

@keyframes h1 {
  20rem {
    font-size: 1rem;
    line-height: 1.2;
    color: lime;
  }
  40rem {
    color: aqua;
  }
  60rem {
    font-size: 5rem;
    line-height: 1;
    color: hotpink;
  }
}

/* alternately, can keep the %s in @keyframes, 
   and use `map(container-progress(...))` to allow setting the scale with variables */

Or, if you did want to write them inline rather than grouping into a keyframes:

h1 {
  --scale: container(inline-size) by ease-in-out;
  font-size: calc-map(var(--scale), 20rem: 1rem, 60rem: 5rem);
  color: color-map(var(--scale), 20rem: lime, 40rem: aqua, 60rem: hotpink);
  line-height: calc-map(var(--scale), 20rem: 1.2, 60rem: 1);
}

which is probably a lot more understandable anyway.

@bramus
Copy link
Contributor

bramus commented Feb 17, 2025

(#) After talking thru the problem space and existing functions again, @fantasai and I have come up with an amended proposal for the *-mix() family of functions.

I really like this proposal. Splitting it all in mix and map seems so obvious in hindsight.

As for the names: I see you are using calc-mix() and calc-map(). Naming the functions value-mix() and value-map() feels more natural to me, as you are mixing and mapping values. This aligns with the existing color-mix(), in which you mix colors.

@mirisuzanne
Copy link
Contributor

@bramus I'm confused: colors are also a kind of value, the way we use that word in CSS. I think the calc-* variants are limited to mixing calculations specifically, similar to color-mix being limited to colors. Neither one is generic for handling arbitrary CSS values of any type.

I like the proposal.

@bramus
Copy link
Contributor

bramus commented Feb 20, 2025

I'm confused: colors are also a kind of value

That‘s exactly my point.

It’s mentioned that color-mix() is a more specific version of the suggested *-mix() function, similar to how <color> is a specific type of value. So if colors get mixed with color-mix(), then (untyped) values gets mixed with … value-mix(), no?

@ydaniv
Copy link
Contributor

ydaniv commented Feb 20, 2025

I like this proposal, feels more intuitive.

Just not sure I understood the each <easing-function> part. IIUC by is for easing the entire range, and each for easing per breakpoint? Kind of like animation-easing and animation-timing-function?

@scottkellum
Copy link
Author

@tabatkins This proposal seems just about perfect. I’m curious if there are any ways to use map() everywhere and either pass in the unit type or infer it.

This still doesn’t feel quite right, but maybe something like:

h1 {
  --scale: container(inline-size) by ease-in-out;
  font-size: map(var(--scale), 20rem: 1rem, 60rem: 5rem, <length>);
  color: map(var(--scale), 20rem: lime, 40rem: aqua, 60rem: hotpink, <color>);
  line-height: map(var(--scale), 20rem: 1.2, 60rem: 1, <length>);
}

@tabatkins
Copy link
Member

@bramus

Naming the functions value-mix() and value-map() feels more natural to me, as you are mixing and mapping values.

When "value" is used in CSS, it refers to a much more generic concept, akin to what the generic map() function operates on. It's definitely not used as a specific term for numeric values, in general.

So if colors get mixed with color-mix(), then (untyped) values gets mixed with … value-mix(), no?

Hm, now I'm confused why you are connecting this to the calc-mix()/calc-map() functions. calc-mix/map() are about mixing/mapping numeric values, not generic values.

@tabatkins
Copy link
Member

@ydaniv

Just not sure I understood the each part. IIUC by is for easing the entire range, and each for easing per breakpoint? Kind of like animation-easing and animation-timing-function?

Exactly correct.

@tabatkins
Copy link
Member

@scottkellum

I’m curious if there are any ways to use map() everywhere and either pass in the unit type or infer it.

Can't infer the type; many values are ambiguous. The problem with passing in the type is that there are often type-specific controls you want (like in color-mix()), so we'll want specific functions anyway. The exact type also has some implications about the general grammar, like calc-mix() has to fix the order of the value and the %, while color-mix() can allow them in any order.

(Note as well that in your example, the line-height values are not lengths, they're just numbers. Exact CSS type can sometimes be a bit confusing in properties.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: Friday Morning
Development

No branches or pull requests