← Read

Tech · · 5 min read

CSS progress() is Baseline. It is not what the name suggests

progress() returns how far one value sits between two others, as a number from 0 to 1. What it computes, the fluid typography it simplifies, and what it doesn't do.

progress() takes three values and returns how far the first sits between the other two, as a unitless number clamped to 0–1:

opacity: progress(5, 0, 10);   /* 0.5 */
opacity: progress(50px, 0px, 100px); /* 0.5 */

It has nothing to do with scroll progress, animation progress or <progress>. It's arithmetic: (value − start) / (end − start), with the result clamped. The CSS Values Level 5 draft describes it as "essentially syntactic sugar for a particular pattern of calc() notations".

That sugar turns out to matter, because the pattern it replaces is the one everybody gets wrong: fluid typography. progress() became Baseline newly available on 1 September 2026 when Firefox 155 shipped it.

The syntax

progress(no-clamp? <calc-sum>, <calc-sum>, <calc-sum>)

The three arguments are the progress value, the start value and the end value, in that order. no-clamp is a bare keyword before the first argument, with no comma after it.

ExpressionResult
progress(5, 0, 10)0.5
progress(15, 0, 10)1 — clamped
progress(no-clamp 15, 0, 10)1.5
progress(no-clamp -5, 0, 10)-0.5

The return value is always a plain <number>, whatever units go in. To get a length back out, multiply it:

width: calc(progress(100vw, 320px, 1200px) * 600px);

The arguments must share a type

The three arguments can be numbers, dimensions or percentages, and they can use different units — but they must resolve to the same type, or the function is invalid and the declaration is dropped.

/* Valid: all lengths. 3em resolves to 48px at a 16px font size,
   which is 48% of the way from 0px to 100px, so this returns 0.48 */
progress(3em, 0px, 100px)

/* Invalid: a time can't be measured against lengths */
progress(3s, 0px, 100px)

/* Invalid: a length can't be measured against unitless numbers */
progress(3em, 0, 100)

That last one is the mistake worth remembering. Mixing a length with bare numbers looks harmless and silently kills the declaration.

Fluid typography, without the magic numbers

The standard fluid-type recipe asks you to work out a slope and an intercept by hand, then wrap them in clamp():

/* 1rem at a 320px viewport, 2rem at 1200px */
font-size: clamp(1rem, 0.636rem + 1.818vw, 2rem);

Nobody reads 0.636rem + 1.818vw and knows what it does. You have to trust the calculator you generated it with, and you have to regenerate it whenever a designer changes a breakpoint.

With progress(), the same curve states its own intent:

font-size: calc(1rem + progress(100vw, 320px, 1200px) * 1rem);

Read it back: start at 1rem, and add up to 1rem more as the viewport travels from 320px to 1200px. The two forms produce the same value — at a 700px viewport both give about 1.43rem, assuming a 16px root font size — but only one of them survives a code review.

The clamping is free. Below 320px the function returns 0, above 1200px it returns 1, so the size holds at 1rem and 2rem without a separate clamp().

Naming the ends

Custom properties make it reusable, which is where this starts to look like a type scale rather than a one-off:

:root {
  --fluid-min: 320px;
  --fluid-max: 1200px;
  --fluid: progress(100vw, var(--fluid-min), var(--fluid-max));
}

h1 { font-size: calc(1.75rem + var(--fluid) * 1.75rem); }
p  { font-size: calc(1rem + var(--fluid) * 0.125rem); }
.section { padding-block: calc(2rem + var(--fluid) * 4rem); }

One ratio, computed once, reused across type and spacing. Every element ramps between the same two viewport widths, which is the thing hand-written vw formulas quietly fail to guarantee.

Other places it earns its keep

Driving a value from a container's own size. Because the arguments can be custom properties, anything you can set — from JavaScript, from a container query, from an inline style — can drive an interpolation:

.card {
  --lift: progress(var(--depth), 0, 10);
  box-shadow: 0 calc(var(--lift) * 24px) calc(var(--lift) * 48px) rgb(0 0 0 / 0.2);
}

Interpolating colour channels. The MDN reference shows progress() used inside rgb() to ramp two channels together. It works, but color-mix() is usually the clearer tool for colour; reach for progress() when you need the ratio itself for something else too.

Extrapolating deliberately. no-clamp lets a value keep going past its endpoints — useful for a parallax-style offset that should overshoot, and dangerous everywhere else. Clamped is the default for a reason.

What progress() does not do

  • It doesn't track scrolling. Scroll-linked effects are the job of scroll-driven animations and animation-timeline. progress() has no timeline and no notion of time.
  • It doesn't animate. The value changes when its inputs change — a resize, a custom property update — and that's it. Nothing eases.
  • It doesn't return a percentage. The output is a unitless number, so width: progress(...) is invalid. Multiply by 100% if a percentage is what you want. The CSS Working Group has an open issue about whether a percent-progress() notation is needed.
  • It doesn't replace clamp(). clamp() bounds a value you already have; progress() produces a ratio. They compose fine.

Browser support

Baseline newly available since 1 September 2026. At the time of writing:

BrowserVersion
Chrome and Edge138
Chrome for Android138
Firefox (desktop and Android)155
Safari (macOS and iOS)26

Firefox was the last engine to ship it, which is why the Baseline date is so recent even though Chrome has had it since mid-2025. "Newly available" means all four engines support it now, not that every visitor's browser is up to date — an older Safari or Firefox will drop the declaration entirely, so keep a static fallback above it:

h1 {
  font-size: 2.5rem; /* used if progress() isn't understood */
  font-size: calc(1.75rem + progress(100vw, 320px, 1200px) * 1.75rem);
}

FAQ

Is progress() related to scroll-driven animations?

No. Despite the name, it takes no timeline. Use animation-timeline: scroll() or view() for anything tied to scroll position.

What happens if the start and end values are the same?

With clamping on, the function returns 0. With no-clamp, the spec defines it as 0, −∞ or +∞ depending on whether the progress value is equal to, below or above the shared value.

Can I use percentages?

Yes, as long as all three arguments are percentages — progress(50%, 30%, 80%) is valid. You can't mix a percentage with a bare number.

Does it work in @keyframes?

Yes, it's an ordinary math function and valid anywhere calc() is. It won't create motion on its own; it only computes a value.

Sources

More to read