{"id":10250,"date":"2026-06-29T11:00:50","date_gmt":"2026-06-29T16:00:50","guid":{"rendered":"https:\/\/master.dev\/blog\/?p=10250"},"modified":"2026-06-29T11:00:51","modified_gmt":"2026-06-29T16:00:51","slug":"fluid-typography-with-progress","status":"publish","type":"post","link":"https:\/\/master.dev\/blog\/fluid-typography-with-progress\/","title":{"rendered":"Fluid Typography with progress()"},"content":{"rendered":"\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\">Never do pixel math with em and rem units. That\u2019s where we went wrong, by assuming that 16px == 1em is a reliable fact.<\/p>\n<cite>Miriam Suzanne,<a href=\"https:\/\/www.oddbird.net\/2025\/02\/12\/fluid-type\/\"> <em>Reimagining Fluid Typography<\/em><\/a><\/cite><\/blockquote>\n\n\n\n<p class=\"wp-block-paragraph\">Fluid typography has been around for a while. These days, most people reach for <code>clamp()<\/code> with pre-calculated values from tools like <a href=\"https:\/\/utopia.fyi\/type\/calculator\/\">Utopia<\/a>, <a href=\"https:\/\/www.fluid-type-scale.com\/\">Fluid Type Scale<\/a>, or Sass. It\u2019s modern, concise, and super easy to use.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You\u2019ve probably used a similar calculator before. You input two sizes and their corresponding breakpoints, and it generates a value that fluidly scales between them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Usually, we input these numbers as pixels, and most good calculators will convert the font size to rems. That\u2019s part of the appeal.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">But you might not have realized that your thoughtfully chosen breakpoints have also been converted to rems and are now subject to the user\u2019s default font size.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In practice, this means your breakpoints can be wildly different from what you imagined, and we can accidentally disregard the user\u2019s font size choices in the process.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">We&#8217;ll explore why this happens and how we can solve it with modern CSS, using range mapping to calculate everything in the browser.<\/p>\n\n\n\n<p class=\"learn-more wp-block-paragraph\">Before we dive in, a quick heads-up: this technique is more complex than you might be used to, but it\u2019s easy to reuse thanks to custom properties, and future CSS will make it much more concise.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>A Problem with Fluid Typography<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Let\u2019s say you\u2019ve set up some fluid type with these inputs:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td><strong>Viewport<\/strong><\/td><td><strong>Font Size<\/strong><\/td><\/tr><tr><td>600px<\/td><td>12px<\/td><\/tr><tr><td>1200px<\/td><td>32px<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">A calculator will then helpfully convert those values to rem units behind the scenes:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td><strong>Viewport<\/strong><\/td><td><strong>Font Size<\/strong><\/td><\/tr><tr><td>37.5rem<\/td><td>0.75rem<\/td><\/tr><tr><td>75rem<\/td><td>2rem<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Now, imagine a user with a <strong>1200px<\/strong> wide screen and the <strong>default root font size of 16px<\/strong>. At 2rem, the font size is 32px, exactly what we expected.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">But what if that user increases their base font size to <strong>32px<\/strong>?<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Everything is still in rems, but now 1rem = 32px. So:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td><strong>Viewport<\/strong><\/td><td><strong>Font Size<\/strong><\/td><\/tr><tr><td>1200px<\/td><td>24px<\/td><\/tr><tr><td>2400px<\/td><td>64px<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">At the same 1200px viewport, the font size is now only <strong>24px<\/strong>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Despite the viewport not changing and the user increasing their font size, our fluid type has shrunk, working directly against the user\u2019s preference.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">While this is an extreme case, any pre-calculated fluid value experiences this issue to some degree.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So how do we fix it?<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">We need to bring the calculations back into CSS and mix our units, combining the accuracy of pixel-based breakpoints with the accessibility of rem-based font sizes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There are several ways to perform these calculations, but range mapping stands out as a method especially well suited to CSS. It\u2019s particularly appealing because it aligns with emerging CSS features like <code>progress()<\/code> and <code>calc-mix()<\/code>, which will simplify these calculations in the future.<\/p>\n\n\n\n<p class=\"learn-more wp-block-paragraph\">While this is closely related to the similar problem of ensuring fluid type can be zoomed to 200% of its original size, it is unfortunately not addressed by this technique and will still require manual testing.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>Range Mapping<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Range mapping is the process of converting a value from one range to another while preserving its relative position. For fluid typography, we want to take the current viewport width and calculate the corresponding value in our output range.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In this demo, you\u2019ll see that for any value in either range, there is a corresponding value in the other:<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_azbWQWb\" src=\"\/\/codepen.io\/anon\/embed\/azbWQWb?height=520&amp;theme-id=1&amp;slug-hash=azbWQWb&amp;default-tab=result\" height=\"520\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed azbWQWb\" title=\"CodePen Embed azbWQWb\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">That\u2019s a rather large output range for the purposes of visualizing, so we\u2019ll define some more sensible values in CSS:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">--min-size<\/span>: 1<span class=\"hljs-selector-tag\">rem<\/span>;\n<span class=\"hljs-selector-tag\">--min-size-viewport<\/span>: 400<span class=\"hljs-selector-tag\">px<\/span>;\n\n<span class=\"hljs-selector-tag\">--max-size<\/span>: 1<span class=\"hljs-selector-class\">.5rem<\/span>;\n<span class=\"hljs-selector-tag\">--max-size-viewport<\/span>: 1200<span class=\"hljs-selector-tag\">px<\/span>;<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">First, we need to calculate the current viewport\u2019s position within our range.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><code>progress()<\/code><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Until quite recently, figuring this out was actually very tricky. We had to rely on<a href=\"https:\/\/dev.to\/janeori\/css-type-casting-to-numeric-tanatan2-scalars-582j\"> advanced CSS hacks<\/a> to convert values to unitless numbers, which greatly complicated the calculations.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Fortunately, modern CSS has delivered us a new function that does exactly that:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">--progress<\/span>: <span class=\"hljs-selector-tag\">progress<\/span>(100<span class=\"hljs-selector-tag\">vi<\/span>, <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--min-size-viewport<\/span>), <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--max-size-viewport<\/span>));<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">From MDN:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\">The <strong>progress()<\/strong> CSS function returns a &lt;number&gt; value representing the position of one value (the progress value) relative to two other values.<\/p>\n<\/blockquote>\n\n\n\n<p class=\"wp-block-paragraph\">Just what we need!<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It has a few key features that make it ideal for our use:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>It accepts a mix of units, as long as they are all of the same type. This allows us to use <code>100vi<\/code> to measure the current viewport, and then compare it to our range defined in pixels (or any length units we choose).<\/li>\n\n\n\n<li>It returns a unitless number, making it much easier to work with later in our calculation.<\/li>\n\n\n\n<li>Since the returned number is always between 0 and 1, it also clamps our size between its minimum and maximum.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">With <code>progress()<\/code>, we no longer need to convert our viewport breakpoints to rems, completely side-stepping the problem of traditional fluid typography discussed above.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Finishing the Calculation<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Next, we need to find the \u2018span\u2019 of our output range, how big it is. We can easily calculate that by subtracting the minimum from the maximum:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-comment\">\/* 1.5rem - 1rem = 0.5rem *\/<\/span>\n<span class=\"hljs-selector-tag\">--output-span<\/span>: <span class=\"hljs-selector-tag\">calc<\/span>(<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--max-size<\/span>) <span class=\"hljs-selector-tag\">-<\/span> <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--min-size<\/span>));<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">Now we can scale this span based on the current viewport by multiplying by our <code>progress()<\/code> value:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-comment\">\/* (0-1) * 0.5rem *\/<\/span>\n<span class=\"hljs-selector-tag\">--fluid<\/span>: <span class=\"hljs-selector-tag\">calc<\/span>(<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--progress<\/span>) * <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--output-span<\/span>));<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">This gives us a range that starts at 0, so let\u2019s use our minimum size to offset that to the correct starting point:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-comment\">\/* 1rem + (0-1) * 0.5rem *\/<\/span>\n<span class=\"hljs-selector-tag\">--fluid<\/span>: <span class=\"hljs-selector-tag\">calc<\/span>(<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--min-size<\/span>) + <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--progress<\/span>) * <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--output-span<\/span>));<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">Now it scales correctly from the start to the end of our range, and we have our final value! Here\u2019s it all put together:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">--min-size<\/span>: 1<span class=\"hljs-selector-tag\">rem<\/span>;\n<span class=\"hljs-selector-tag\">--min-size-viewport<\/span>: 400<span class=\"hljs-selector-tag\">px<\/span>;\n\n<span class=\"hljs-selector-tag\">--max-size<\/span>: 1<span class=\"hljs-selector-class\">.5rem<\/span>;\n<span class=\"hljs-selector-tag\">--max-size-viewport<\/span>: 1200<span class=\"hljs-selector-tag\">px<\/span>;\n\n<span class=\"hljs-selector-tag\">--_progress<\/span>: <span class=\"hljs-selector-tag\">progress<\/span>(100<span class=\"hljs-selector-tag\">vi<\/span>, <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--min-size-viewport<\/span>), <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--max-size-viewport<\/span>));\n<span class=\"hljs-selector-tag\">--_output-span<\/span>: <span class=\"hljs-selector-tag\">calc<\/span>(<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--max-size<\/span>) <span class=\"hljs-selector-tag\">-<\/span> <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--min-size<\/span>));\n\n<span class=\"hljs-selector-tag\">--fluid<\/span>: <span class=\"hljs-selector-tag\">calc<\/span>(<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--min-size<\/span>) + <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--_progress<\/span>) * <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--_output-span<\/span>));<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h2 class=\"wp-block-heading\"><strong>Building a Scale<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Let\u2019s extend this to a full scale, with each step derived from the previous using a custom ratio. It\u2019s popular to use different ratios for the minimum and maximum sizes, so we\u2019ll do the same.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here\u2019s our ratios in CSS alongside the rest of our setup:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-7\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">--min-size<\/span>: 1<span class=\"hljs-selector-tag\">rem<\/span>;\n<span class=\"hljs-selector-tag\">--min-size-viewport<\/span>: 400<span class=\"hljs-selector-tag\">px<\/span>;\n<span class=\"hljs-selector-tag\">--min-size-ratio<\/span>: 1<span class=\"hljs-selector-class\">.2<\/span>;\n\n<span class=\"hljs-selector-tag\">--max-size<\/span>: 1<span class=\"hljs-selector-class\">.5rem<\/span>;\n<span class=\"hljs-selector-tag\">--max-size-viewport<\/span>: 1200<span class=\"hljs-selector-tag\">px<\/span>;\n<span class=\"hljs-selector-tag\">--max-size-ratio<\/span>: 1<span class=\"hljs-selector-class\">.25<\/span>;\n\n<span class=\"hljs-selector-tag\">--_progress<\/span>: <span class=\"hljs-selector-tag\">progress<\/span>(100<span class=\"hljs-selector-tag\">vi<\/span>, <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--min-size-viewport<\/span>), <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--max-size-viewport<\/span>));<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-7\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">Now for the steps of our scale. Using what we\u2019ve already discussed, let\u2019s create our base size:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-8\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">--step-0<\/span>: <span class=\"hljs-selector-tag\">calc<\/span>(\n  <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--min-size<\/span>) + <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--_progress<\/span>) * (<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--max-size<\/span>) <span class=\"hljs-selector-tag\">-<\/span> <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--min-size<\/span>))\n);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-8\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">To calculate our next step, we multiply every use of our min and max sizes by their ratio. Let\u2019s use extra custom properties to store these values:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-9\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">--_min-1<\/span>: <span class=\"hljs-selector-tag\">calc<\/span>(<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--min-size<\/span>) * <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--min-size-ratio<\/span>));\n<span class=\"hljs-selector-tag\">--_max-1<\/span>: <span class=\"hljs-selector-tag\">calc<\/span>(<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--max-size<\/span>) * <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--max-size-ratio<\/span>));\n\n<span class=\"hljs-selector-tag\">--step-1<\/span>: <span class=\"hljs-selector-tag\">calc<\/span>(\n  <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--_min-1<\/span>) + <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--_progress<\/span>) * (<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--_max-1<\/span>) <span class=\"hljs-selector-tag\">-<\/span> <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--_min-1<\/span>))\n);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-9\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">What about step 2? We multiply each size by its ratio <em>twice<\/em>, which luckily is the same as squaring the ratio with <code>pow()<\/code>:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-10\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">--_min-2<\/span>: <span class=\"hljs-selector-tag\">calc<\/span>(<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--min-size<\/span>) * <span class=\"hljs-selector-tag\">pow<\/span>(<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--min-size-ratio<\/span>), 2));\n<span class=\"hljs-selector-tag\">--_max-2<\/span>: <span class=\"hljs-selector-tag\">calc<\/span>(<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--max-size<\/span>) * <span class=\"hljs-selector-tag\">pow<\/span>(<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--max-size-ratio<\/span>), 2));\n\n<span class=\"hljs-selector-tag\">--step-2<\/span>: <span class=\"hljs-selector-tag\">calc<\/span>(\n  <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--_min-2<\/span>) + <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--_progress<\/span>) * (<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--_max-2<\/span>) <span class=\"hljs-selector-tag\">-<\/span> <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--_min-2<\/span>))\n);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-10\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">In fact, since anything to the power of 1 is itself, and anything to the power of 0 is 1, we can use this same calculation for every step in our scale. It even works with negative numbers to create smaller sizes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For any given step <em>n<\/em>, we raise both ratios to the power of <em>n.<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">With that in mind, we have a few options for defining the rest of our scale:<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Per Element Value<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If you\u2019ve ever looked at the code of one of<a href=\"https:\/\/codepen.io\/jh3y\"> Jhey\u2019s demos<\/a>, you might notice he often uses a fluid typography technique very similar to what we\u2019ve done here.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">He gets an infinite scale with no duplication by setting the exponent via a custom property, rather than hardcoding it at each step.<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_wBgezaB\" src=\"\/\/codepen.io\/anon\/embed\/wBgezaB?height=850&amp;theme-id=1&amp;slug-hash=wBgezaB&amp;default-tab=result\" height=\"850\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed wBgezaB\" title=\"CodePen Embed wBgezaB\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">The downside is that we\u2019re limited to a single value for the entire element, so we can\u2019t set other properties with different steps.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>A little bit of Sass<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If you\u2019re already using Sass, then a complete scale is just a @for loop away:<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_RNKgGRm\" src=\"\/\/codepen.io\/anon\/embed\/RNKgGRm?height=850&amp;theme-id=1&amp;slug-hash=RNKgGRm&amp;default-tab=result\" height=\"850\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed RNKgGRm\" title=\"CodePen Embed RNKgGRm\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Stick to CSS<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Since we\u2019re only changing a few numbers for each step, it\u2019s easy to copy and paste the rest of our scale.<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_RNKgGGm\" src=\"\/\/codepen.io\/anon\/embed\/RNKgGGm?height=850&amp;theme-id=1&amp;slug-hash=RNKgGGm&amp;default-tab=result\" height=\"850\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed RNKgGGm\" title=\"CodePen Embed RNKgGGm\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">And that\u2019s it! All ready to copy and paste into your projects; just edit the scale config as required.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>Taking It Further<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Our conditional value can be anything we have the units to express. I used 100vi, but we could easily swap that for 100cqi to instead respond to the width of the nearest container:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-11\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">--progress<\/span>: <span class=\"hljs-selector-tag\">progress<\/span>(100<span class=\"hljs-selector-tag\">qi<\/span>, <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--min-size-container<\/span>), <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--max-size-container<\/span>));<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-11\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">If you prefer whole numbers or have a baseline grid you need to align to, then we can use round() with our preferred rounding interval:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-12\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">--interval<\/span>: 1<span class=\"hljs-selector-tag\">px<\/span>;\n\n<span class=\"hljs-selector-tag\">--step-0<\/span>: <span class=\"hljs-selector-tag\">round<\/span>(\n  <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--min-size<\/span>) + <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--_progress<\/span>) * (<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--max-size<\/span>) <span class=\"hljs-selector-tag\">-<\/span> <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--min-size<\/span>)),\n  <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--interval<\/span>));<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-12\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">What if our scale decreases as the viewport increases? It\u2019s probably not something commonly needed in practice, but fortunately, the range-mapping calculation already handles an inverse relationship. Just swap the viewport values:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-13\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">--min-size<\/span>: 1<span class=\"hljs-selector-tag\">rem<\/span>;\n<span class=\"hljs-selector-tag\">--min-size-viewport<\/span>: 1200<span class=\"hljs-selector-tag\">px<\/span>;\n\n<span class=\"hljs-selector-tag\">--max-size<\/span>: 1<span class=\"hljs-selector-class\">.5rem<\/span>;\n<span class=\"hljs-selector-tag\">--max-size-viewport<\/span>: 400<span class=\"hljs-selector-tag\">px<\/span>;<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-13\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h2 class=\"wp-block-heading\"><strong>Browser Support<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The biggest limiting factor is <code>progress()<\/code>, which is supported in all modern browsers except Firefox, so this isn\u2019t ready for production just yet.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you wanted to test for support and fallback to a traditional scale, it is easy to do so:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-14\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-keyword\">@supports<\/span> (<span class=\"hljs-attribute\">opacity:<\/span> progress(<span class=\"hljs-number\">1px<\/span>,<span class=\"hljs-number\">0px<\/span>,<span class=\"hljs-number\">1px<\/span>) {\n  ...\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-14\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">It is possible to support all major browsers without <code>progress()<\/code> by manually calculating this value ourselves using some CSS hacks. Here are some demos showing how to do that if you are interested, but be warned, it\u2019s even more verbose than what we\u2019ve done here:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/codepen.io\/matthewmorete\/pen\/zxvNrEM?editors=0101\">Per Element<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/codepen.io\/matthewmorete\/pen\/VYvPYqv?editors=0101\">Sass<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/codepen.io\/matthewmorete\/pen\/LEpxVPp?editors=0101\">CSS<\/a><\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>The Future<\/strong><\/h2>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-15\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-keyword\">@function<\/span> --fluid-size(--step) {\n  <span class=\"hljs-selector-tag\">--min-size<\/span>: 1<span class=\"hljs-selector-tag\">rem<\/span>;\n  <span class=\"hljs-selector-tag\">--min-size-viewport<\/span>: 400<span class=\"hljs-selector-tag\">px<\/span>;\n  <span class=\"hljs-selector-tag\">--min-size-ratio<\/span>: 1<span class=\"hljs-selector-class\">.2<\/span>;\n\t\n  <span class=\"hljs-selector-tag\">--max-size<\/span>: 1<span class=\"hljs-selector-class\">.5rem<\/span>;\n  <span class=\"hljs-selector-tag\">--max-size-viewport<\/span>: 1200<span class=\"hljs-selector-tag\">px<\/span>;\n  <span class=\"hljs-selector-tag\">--max-size-ratio<\/span>: 1<span class=\"hljs-selector-class\">.25<\/span>;\n\t\n  <span class=\"hljs-selector-tag\">--_progress<\/span>: <span class=\"hljs-selector-tag\">progress<\/span>(100<span class=\"hljs-selector-tag\">vi<\/span>, <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--min-size-viewport<\/span>), <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--max-size-viewport<\/span>));\n  <span class=\"hljs-selector-tag\">--_min-size<\/span>: <span class=\"hljs-selector-tag\">calc<\/span>(<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--min-size<\/span>) * <span class=\"hljs-selector-tag\">pow<\/span>(<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--min-size-ratio<\/span>), <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--step<\/span>)));\n  <span class=\"hljs-selector-tag\">--_max-size<\/span>: <span class=\"hljs-selector-tag\">calc<\/span>(<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--max-size<\/span>) * <span class=\"hljs-selector-tag\">pow<\/span>(<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--max-size-ratio<\/span>), <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--step<\/span>)));\n\t\n  <span class=\"hljs-selector-tag\">result<\/span>: <span class=\"hljs-selector-tag\">calc-mix<\/span>(<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--_progress<\/span>), <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--_min-size<\/span>), <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--_max-size<\/span>));\n}\n\n<span class=\"hljs-selector-tag\">h1<\/span> {\n  <span class=\"hljs-attribute\">font-size<\/span>: <span class=\"hljs-built_in\">--fluid-size<\/span>(<span class=\"hljs-number\">4<\/span>);\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-15\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">What an upgrade! Here\u2019s what\u2019s happening:<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><code>@function<\/code><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">As I mentioned at the start, and you\u2019ve probably noticed, calculating a fluid scale in CSS is very verbose. But not with <code>@function<\/code>! Instead of calculating a separate variable for each step, we put all our calculations into a single function and call it instead.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Currently only supported in Chromium.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Juan Diego Rodr\u00edguez explains<a href=\"https:\/\/css-tricks.com\/functions-in-css\/\"> CSS functions<\/a> in more detail.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><code>calc-mix()<\/code><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">In the even further future, things will get even more concise with <code>calc-mix()<\/code>, which completes the remaining range mapping calculations.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It takes 3 inputs: a normalized\/progress() value and the start and end of the output range:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-16\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">calc-mix<\/span>(<span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--_progress<\/span>), <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--_min-size<\/span>), <span class=\"hljs-selector-tag\">var<\/span>(<span class=\"hljs-selector-tag\">--_max-size<\/span>))<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-16\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">No browser supports <code>calc-mix()<\/code> yet, but until then, we can continue to calculate this ourselves.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>Further Reading<\/strong><\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/henry.codes\/writing\/how-to-map-a-number-between-two-ranges\/\">How To Map A Number Between Two Ranges<\/a> \u2014 If you\u2019re looking for more about range mapping, then check out Henry\u2019s excellent explanation.<\/li>\n\n\n\n<li><a href=\"https:\/\/www.madebymike.com.au\/writing\/precise-control-responsive-typography\/\">Precise control over responsive typography<\/a> \u2014 Pre-dating most CSS math functions and even custom properties, this 2015 post from Mike is the earliest I can find describing the precise fluid type we use today. He even uses the same range mapping approach we\u2019ve used here.<\/li>\n\n\n\n<li><a href=\"https:\/\/adrianroselli.com\/2019\/12\/responsive-type-and-zoom.html\">Responsive Type and Zoom<\/a> \u2014 Adrian Roselli reminds us of accessibility concerns and argues that we may not need fluid typography.<\/li>\n\n\n\n<li><a href=\"https:\/\/www.smashingmagazine.com\/2023\/11\/addressing-accessibility-concerns-fluid-type\/\">Addressing Accessibility Concerns With Using Fluid Type<\/a> \u2014 Maxwell Barvian gives us advice on passing <a href=\"https:\/\/www.w3.org\/WAI\/WCAG21\/quickref\/?showtechniques=144#resize-text\">1.4.4 Resize text (AA)<\/a> by ensuring we can zoom all text to 200% of its original size.<\/li>\n\n\n\n<li><a href=\"https:\/\/www.oddbird.net\/2025\/02\/12\/fluid-type\/\">Reimagining Fluid Typography<\/a> &amp; <a href=\"https:\/\/clagnut.com\/blog\/2441\/\">In defence of fluid typography<\/a> \u2014 A back and forth between Miriam Suzanne and Richard Rutter, considering the merits of fluid type.<\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>With the `progress()` function in CSS we&#8217;ve got a new way to calculate the size for type based on the viewport without problems of the past.<\/p>\n","protected":false},"author":23,"featured_media":10267,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"sig_custom_text":"","sig_image_type":"featured-image","sig_custom_image":0,"sig_is_disabled":false,"inline_featured_image":false,"_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_feature_clip_id":0,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_post_was_ever_published":false},"categories":[1],"tags":[506,7,503,504,505,39],"class_list":["post-10250","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-blog-post","tag-calc-mix","tag-css","tag-fluid-typography","tag-font-size","tag-progress-2","tag-typography"],"acf":[],"jetpack_featured_media_url":"https:\/\/i0.wp.com\/master.dev\/blog\/wp-content\/uploads\/2026\/06\/fluid-type.jpg?fit=2000%2C1200&ssl=1","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/posts\/10250","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/users\/23"}],"replies":[{"embeddable":true,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/comments?post=10250"}],"version-history":[{"count":12,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/posts\/10250\/revisions"}],"predecessor-version":[{"id":10282,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/posts\/10250\/revisions\/10282"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/media\/10267"}],"wp:attachment":[{"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/media?parent=10250"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/categories?post=10250"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/tags?post=10250"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}