It all started a few days ago, when I was taking a look at Zeus, a CSS framework my friend Stavros Lazaris had just released. One idea in there sent me down a rabbit hole that ended with an npm package and a bit of embarrassment.
If you stay for the storytime, you will get some very usefull css tips as well.
Here is the whole chronicle.
The idea
Zeus has a fluid engine. Every spacing and type token is a clamp() that scales between a min and a max as the container grows. The part that got me is the unit: it keys on cqi, the container query inline unit, with container-type: inline-size on body. Tokens follow the container they live in. Put the same card in a sidebar and in a main column and each one sizes itself.
The formula behind it is the one everybody uses, from Utopia to every fluid typography tutorial out there:
font-size: clamp(1rem, 0.667rem + 1.667cqi, 2rem);
Read it as: 16px at a 320px container, 32px at 1280px, a straight line between, pinned at both ends. The two odd numbers are an intercept and a slope, worked out once by a preprocessor and pasted in.
I have seen this elegant trick before, for font-size, padding, gap, border-radius. I had never once thought about where it stops working.
Where it stops working
Take a side drawer whose width and slide distance are fluid, 18rem on a phone up to 31rem on a desktop. Give it one fixed duration and the wide version moves 1.7 times faster than the narrow one, which feels wrong every time you open it. The duration should follow the distance. Same formula, different unit:
transition-duration: clamp(0.14s, -0.18s + 0.1cqi, 1.1s);
That is a type error. cqi is a length. You cannot add a length to a time, so the browser drops the whole declaration and the drawer snaps open with no transition at all.
The formula fails the same way for every value that has no length in it. font-weight as a bare number. opacity. scale. Anything in degrees. The slope carries cqi, so the result is a length, and a length is only ever welcome in a length property.
What people actually do is pick one value and live with it. One duration that is tolerable at both ends, one weight, one angle. If it bothers you enough you split it once with a media query, phone and desktop. Anything smoother than that means JavaScript and a ResizeObserver.
(And yes, I also wanted a button that leans seven degrees on a narrow card and stands up straight on a wide one. No reason, I was just playing and that's how i hit the wall.)
Swap the order of operations
The fix is a reordering. Divide first, multiply second.
Dividing one length by another gives a plain number. calc() has accepted that since 2023 (CSS Values 4: Chrome 119, Safari 17, Firefox 116). So:
--t: clamp(0, (100cqi - 320px) / 960px, 1);
--t is 0 at a 320px container, 1 at 1280px, a fraction between, and it has no unit. It is progress along the range, and the browser recomputes it on every layout.
Now any property that takes calc() can use it, in its own unit:
rotate: calc(-7deg + 14deg * var(--t));
transition-duration: calc(0.14s + 0.96s * var(--t));
font-weight: calc(200 + 700 * var(--t));
letter-spacing: calc(-0.02em + 0.37em * var(--t));
clip-path: polygon(calc(24% * var(--t)) 0, 100% 0, calc(100% - 24% * var(--t)) 100%, 0 100%);
One --t for all of them. Declare it on a container and every descendant reads it. Declare it again on an inner container with its own range and everything inside follows the inner width. The cqi inside the variable resolves against whichever container the value is used in, which is exactly what you want and slightly surprising the first time.
A bonus I had not planned is that descending ranges work! calc(26rem - 24rem * var(--t)) shrinks. The classic form cannot do that, because clamp(26rem, ..., 2rem) returns 26rem forever when min is bigger than max. My own first helper had that bug, and I only caught it because I measured the computed width and got 26rem at every size.
Wrapping it in Sass
I got tired of doing subtraction in my head, so I wrote a few functions:
@use "cq-lerp" as *;
.card {
@include cq-track(240, 560);
padding: fluid-on(0.6rem, 1.4rem);
border-radius: fluid-on(0.4rem, 1rem);
rotate: fluid-on(-7deg, 7deg, var(--cq-te));
font-weight: fluid-on(400, 620, var(--cq-t), "");
}
cq-track declares two variables: --cq-t (linear) and --cq-te (run through a smoothstep, for things that should settle in gently). fluid-on takes a min and a max in whatever unit, works out the difference and the sign, and writes the calc(). fluid() is the standalone version for when you want one value with its own range baked in.
Then I built six buttons to push on it, each one leaning on a different unit family. Lengths that squash, degrees that tilt and skew, a bare font-weight that fattens from 200 to 900, percentages that round corners and turn up saturation, seconds that stretch a hover transition, a clip-path that morphs.
Then a whole app shell, twice. One stylesheet uses this. The other is what we all shipped until this year: clamp() with vw for every length, one fixed transition duration, and media queries stepping the font weight, which is about as far as anyone goes without JavaScript. A switch flips between them in place.
Live, resizable, no JavaScript: subamanis.github.io/cq-lerp
I was pretty pleased with myself at this point. One function, every unit, zero width breakpoints, zero keyframes. It felt like the scaling half of responsive design in one move.
Then I read the spec
Before publishing I wanted to check that nobody had done this before. Somebody had. The CSS Working Group.
--t: progress(100cqi, 320px, 1280px);
CSS Values 5. It returns where the first argument sits between the other two, as a number from 0 to 1, clamped. It is my --t line with the subtraction and division moved into the browser. Chrome 138, Safari 26, Firefox 155.
I had reinvented progress(). From first principles. With a Sass wrapper. I sat with that for a while.
While I was at it I found sass-lerp, which has done calc-based interpolation in Sass for years, and posts from MadeByMike and Typetura circling the same ground. The one piece I could still call mine was putting the halves together around a container unit and seeing that it opens every unit at once. I will take it.
Why I shipped it anyway
Two reasons, one boring and one that turned out to matter more.
The boring one is support. Those version numbers mean browsers from 2026 onward. Everything older gets nothing from progress(). The division trick reaches back to 2023. So the package declares the fallback and upgrades in place:
--cq-t: clamp(0, (100cqi - 320px) / 960px, 1);
@supports (opacity: progress(1px, 0px, 2px)) {
--cq-t: progress(100cqi, 320px, 1280px);
}
The second reason is that progress() hands you a 0 to 1 and stops there. The calc(min + (max - min) * t) is still yours to write, in the right unit, with the right sign. My Sass functions take that arithmetic off your hands. You write fluid-on(0.14s, 1.1s) and get the finished calc() back: the difference worked out, the sign right when the range runs downhill, the unit carried over, a rem mixed with a px caught at compile time, and a bare 0 turned into 0deg when the other end is an angle.
The part progress() cannot reach
progress() interpolates over whatever you give it. Give it 100vw and you have the viewport, same as the clamp()-and-vw genre. Give it 100cqi inside a container and the thing being measured changes.
That sidebar has a drag handle. The window never moves. Under cq-lerp the padding, the type size, the letter spacing and the weight of the links all follow the width of the sidebar itself. Under the conventional stylesheet nothing moves, because every viewport-based technique is reading a number that did not change.
Same story with the three cards on the demo page: identical markup in a 240px, a 380px and a 520px slot, on one screen, at the same time. Each card sizes itself by its slot. A media query has nothing to key off, since all three see the same viewport.
That is what stays useful no matter how far progress() support spreads, and it is why the package has cq in its name.
Links
- Demo: subamanis.github.io/cq-lerp
- Source: github.com/subamanis/cq-lerp
- Package:
npm i cq-lerp, with a plain CSS entry atcq-lerp/cssif you do not use Sass - Zeus, where the container-based fluid engine idea comes from: zeuscss.com


Top comments (0)