r/css 1d ago

Showcase Fluid CSS values for any unit (degrees, seconds, font-weight), keyed on container width, with a fallback for browsers without progress()

Or, alternative title: how I accidentally invented progress() but still shipped it anyway...

Dragging the sidebar. The window never moves, so every media query on the page is reading a number that did not change. Here is how that works, starting from the beginning.

How we do it today

We want a heading to be 24px on a phone and 40px on a desktop, so we write steps:

h1 { font-size: 24px; }
@media (min-width: 768px)  { h1 { font-size: 30px; } }
@media (min-width: 1200px) { h1 { font-size: 40px; } }

At 767px it is 24, at 768px it jumps to 30, and for every other property that should follow the screen we write the same set again.

Fluid

The known fix is one line that draws a straight line between the two ends:

h1 { font-size: clamp(1.5rem, 1.167rem + 1.667vw, 2.5rem); }

24px at 320, 40px at 1280, pinned at both ends. The two magic numbers are an intercept and a slope, worked out by a Sass function or a calculator. Utopia and most modern frameworks use it for their spacing and type tokens.

Where it stops

It only works for lengths. vw is a length, and degrees plus a length does not type-check, so rotate, transition-duration, font-weight, opacity, scale are all out. The browser drops the whole declaration. For those, we go back to steps or pick one value and live with it: the drawer that opens in 0.3s on a phone and on a desktop while travelling three times the distance.

The trick

Swap the order of operations. Divide two lengths first, which gives a plain number from 0 to 1, then multiply in whatever unit you want:

:root { --t: clamp(0, tan(atan2(100vw - 320px, 960px)), 1); }

font-size:           calc(24px + 16px * var(--t));
rotate:              calc(-7deg + 14deg * var(--t));
transition-duration: calc(0.14s + 0.96s * var(--t));
font-weight:         calc(400 + 220 * var(--t));

--t is 0 at 320px, 1 at 1280px, a fraction in between. Write it once, use it everywhere, no breakpoints, no keyframes, no JS. A bare / between two lengths is Chrome 140 and Safari 26 only, so the division is spelled tan(atan2(a, b)), the trick from Jane Ori, which has worked everywhere since early 2023. And it runs downhill too, which clamp(26rem, ..., 2rem) cannot, because min greater than max returns min forever.

The better trick

Change 100vw to 100cqi and --t measures the nearest container-type: inline-size ancestor:

.sidebar { container-type: inline-size; --t: clamp(0, tan(atan2(100cqi - 128px, 288px)), 1); }

Now a component follows the width of whatever it sits in. Same markup in a 240px column and a 520px column on one screen comes out different on its own, with no -small / -large variants. That is the GIF at the top.

The part where I found out

After building this I found out CSS already ships it: progress(100cqi, 320px, 1280px) does exactly that division. Chrome 138, Safari 26, and Firefox 155 as of September 1st. I had reinvented it from scratch, with a Sass wrapper.

Shipped it anyway as cq-lerp (npm i cq-lerp, npm), because the tan(atan2()) fallback works back to early 2023 and a 'supports' block upgrades to progress() where it exists:

--cq-t: clamp(0, tan(atan2(100cqi - 320px, 960px)), 1);

@supports (opacity: progress(1px, 0px, 2px)) {
  --cq-t: progress(100cqi, 320px, 1280px);
}

The Sass side is one function for every property and every unit. Min, max, done:

.card { @include cq-track(); }

.card .badge {
  rotate:              fluid-on(-7deg, 7deg);
  transition-duration: fluid-on(0.06s, 1.4s);
  border-radius:       fluid-on(6%, 50%);
  font-weight:         fluid-on(200, 900, var(--cq-t), "");
}

Every effect above is one fluid-on line. There is a plain CSS build too if you do not use Sass.

Demo, resizable, no JS: https://subamanis.github.io/cq-lerp/ Source: https://github.com/subamanis/cq-lerp

9 Upvotes

4 comments sorted by

3

u/morete 1d ago

'Length division inside calc() has been in browsers since 2023.' I was under the impression this still doesn't work in firefox?

https://codepen.io/editor/matthewmorete/pen/01a06225-68bb-791d-ae87-3e212fabb667

1

u/petros211 1d ago

Actuallyy you are right, Firefox has not shipped it at all (bug 1264520). There is a workaround for this operation in Firefox tan(atan2(a, b)) that makes it work no problem, but I completely misremembered the details and mixed what actually worked out of the box. So it must become
--t: clamp(0, tan(atan2(100cqi - 320px, 960px)), 1); . cq-lerp 0.1.2 now uses that for the fallback
Nice catch.
Crazy how many times Firefox can bite me and me still forgetting to be rigorous with it lol.

1

u/morete 1d ago

Firefox often is the one holding things up these days lol.

Not a bear a bearer of bad news, but you might wanna check how the atan2 trick works in safari:
https://codepen.io/editor/matthewmorete/pen/01a06267-3473-741c-89fa-a9a61a26aa03

In my experience the only way to make complex lengths work in atan2 cross browser, is to register them as custom properties, because it forces their computed value into the form 'XXXXpx'