Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

CSS  Module 5 – Values, units and math functions

calc(), min(), max(), clamp() and newer math

Mix units with calc(), bound values with min(), max() and clamp(), and use round(), mod(), pow(), sin() and progress() where they replace JavaScript.

  • Intermediate
  • 25 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Combine units with calc() and custom properties
  • Build fluid, bounded values with min(), max() and clamp()
  • Use round(), mod(), abs(), trigonometric functions and progress() where they replace JavaScript

Before you start

CSS math functions work out a value while the browser lays out the page, so they can combine quantities that no stylesheet can add up in advance, such as a percentage and a rem. calc() does arithmetic with +, -, * and /. min() takes the smallest of its values and max() the largest, and clamp(MIN, VAL, MAX) keeps a preferred value between two limits, which is how fluid type and spacing stay readable on every screen. Newer functions step and shape values: round(), mod() and rem() snap to a grid or wrap around, abs() and sign() handle negatives, pow() and its family build scales, sin() and cos() place things on a circle, and progress() says how far a value has come between two others. Every result below was read back from Chromium 153, Firefox 155 and WebKit 26.6.

calc(): arithmetic across units

calc() lets one length depend on another that only layout knows. A sidebar layout, a row of four cards and spacing from a custom property:

What calc() accepts, and what happens when it doesn't HTML · calc-rules.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>What calc() accepts, and what happens when it doesn't</title>
  <style>
    body { margin: 0; font: 16px/1.5 Arial, sans-serif; }
    .layout { display: flex; gap: 2rem; inline-size: 1000px; }
    .sidebar { inline-size: 16rem; flex: none; }
    .main { inline-size: calc(100% - 16rem - 2rem); flex: none; }   /* 1000px - 256px - 32px */

    .cards { inline-size: 1000px; }
    .card { inline-size: calc((100% - 3 * 16px) / 4); }              /* four cards, three 16px gaps */

    .spaced { --space: 0.75rem; padding: calc(var(--space) * 2); }

    /* No space before 1rem: "-1rem" is read as a negative number, so the value is invalid and the declaration is
       dropped. The 300px above it stays. */
    .broken { inline-size: 300px; inline-size: calc(100% -1rem); }

    /* A unitless custom property gets its unit by multiplication. Writing the unit after var() doesn't glue it on:
       the browser only finds out once the variable is filled in, too late to fall back to the 24px, so the property
       takes its initial value, 0. */
    .times { --gap: 16; margin-inline-start: calc(var(--gap) * 1px); }
    .glued { --gap: 16; margin-inline-start: 24px; margin-inline-start: var(--gap)px; }
  </style>
</head>
<body>
  <div class="layout"><div class="sidebar"></div><div class="main"></div></div>
  <div class="cards"><div class="card"></div></div>
  <div class="spaced"></div>
  <div class="broken"></div>
  <div class="times"></div>
  <div class="glued"></div>
  <script>
    const width = (s) => document.querySelector(s).getBoundingClientRect().width;
    console.log(`main: ${width('.main')}px, card: ${width('.card')}px`);
    console.log(`padding: ${getComputedStyle(document.querySelector('.spaced')).paddingTop}`);
    console.log(`broken: ${width('.broken')}px`);
    const margin = (s) => getComputedStyle(document.querySelector(s)).marginLeft;
    console.log(`calc(var(--gap) * 1px): ${margin('.times')}, var(--gap)px: ${margin('.glued')}`);
    for (const value of ['calc(100% - 1rem)', 'calc(100% -1rem)', 'calc(100%-1rem)', 'calc(100% - -1rem)',
      'calc(2*1rem)', 'calc(1rem * 1rem)', 'calc(1rem / 2)', 'calc(10px / 2px * 1px)']) {
      console.log(`${CSS.supports('width', value) ? 'valid  ' : 'invalid'} width: ${value}`);
    }
  </script>
</body>
</html>

In every engine the main column was 712 px (1,000 − 256 − 32), each card 238 px ((1,000 − 48) ÷ 4) and the padding 24 px. The rules behind the validity list:

Value for width Chromium Firefox WebKit
calc(100% - 1rem) valid valid valid
calc(100% -1rem) invalid invalid invalid
calc(100%-1rem) invalid invalid invalid
calc(100% - -1rem) valid valid valid
calc(2*1rem) valid valid valid
calc(1rem * 1rem) invalid invalid invalid
calc(1rem / 2) valid valid valid
calc(10px / 2px * 1px) valid invalid valid

+ and - need white space on both sides, because without it -1rem is read as a negative number and the expression no longer parses; * and / don’t. The result must have the property’s type: 1rem * 1rem is a length squared, which no property accepts. Dividing a length by a length is newer: the current specification allows it, giving a plain number, and Chromium and WebKit accepted it while Firefox 155 did not, so keep it out of production CSS for now. An invalid value drops the whole declaration while the stylesheet is parsed, with no message in the console, which is why the .broken box kept the 300 px of the declaration before it.

calc() can nest and can read custom properties, as calc(var(--space) * 2) did. A custom property that holds a bare number gets its unit by multiplication: with --gap: 16, calc(var(--gap) * 1px) was 16 px in every engine. Writing the unit after it, as var(--gap)px, glues nothing together, and the browser can only tell once the variable is filled in, after the cascade has already picked that declaration. Such a value is invalid at computed-value time, so the margin fell to its initial value, 0, instead of the 24 px written before it. Inside min(), max() and clamp() you can write the arithmetic directly, without an inner calc().

min() and max(): limits in one value

min() takes the smallest of its arguments and max() the largest, and the names read backwards at first: min() sets an upper limit and max() a lower one. inline-size: min(100%, 40rem) is 40rem, but never wider than the container, and padding-inline: max(1rem, 5vw) grows with the viewport but never drops below 1rem. Both take any number of arguments of compatible types, and each argument may be a whole expression.

clamp(): a fluid value between two limits

clamp(MIN, VAL, MAX) returns VAL, kept at MIN or more and MAX or less; it is exactly max(MIN, min(VAL, MAX)). When the limits cross, the minimum wins, and either limit can be the keyword none:

min(), max() and clamp(), with crossed limits HTML · comparisons.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>min(), max() and clamp(), including a minimum above the maximum</title>
  <style>
    body { margin: 0; }
    .box { block-size: 4px; }
    .min { inline-size: min(10px, 20px, 30px); }       /* the smallest */
    .max { inline-size: max(10px, 20px, 30px); }       /* the largest */
    .clamp { inline-size: clamp(10px, 50px, 30px); }   /* the middle value, kept between 10px and 30px */
    .crossed { inline-size: clamp(30px, 20px, 10px); } /* the minimum is above the maximum */
    .open { inline-size: clamp(none, 50px, 30px); }    /* none: no limit on that side */
  </style>
</head>
<body>
  <div class="box min" data-value="min(10px, 20px, 30px)"></div>
  <div class="box max" data-value="max(10px, 20px, 30px)"></div>
  <div class="box clamp" data-value="clamp(10px, 50px, 30px)"></div>
  <div class="box crossed" data-value="clamp(30px, 20px, 10px)"></div>
  <div class="box open" data-value="clamp(none, 50px, 30px)"></div>
  <script>
    for (const el of document.querySelectorAll('.box')) {
      const value = el.dataset.value;
      const state = CSS.supports('inline-size', value) ? `${el.getBoundingClientRect().width}px` : 'invalid here, so the box keeps its automatic width';
      console.log(`${value.padEnd(24)} -> ${state}`);
    }
  </script>
</body>
</html>

All three engines gave 10, 30 and 30 px for the first three boxes, 30 px for clamp(30px, 20px, 10px) (the minimum won), and 30 px for clamp(none, 50px, 30px), so none works everywhere tested. The common use is a value that grows with the viewport between two readable limits, such as a grid gap:

A fluid gap with clamp() HTML · fluid-gap.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>A fluid gap with clamp()</title>
  <style>
    body { margin: 0; font: 16px/1.5 Arial, sans-serif; }
    .grid {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: clamp(1rem, 2.5vw + 0.5rem, 2.5rem);   /* at least 16px, at most 40px, fluid in between */
    }
  </style>
</head>
<body>
  <div class="grid"><div></div><div></div><div></div></div>
  <script>
    console.log(`${innerWidth}px wide: gap ${getComputedStyle(document.querySelector('.grid')).columnGap}`);
  </script>
</body>
</html>
Viewport 2.5vw + 0.5rem Gap
320 px 8 + 8 = 16 px 16 px, the minimum
768 px 19.2 + 8 = 27.2 px 27.2 px
1,440 px 36 + 8 = 44 px 40 px, the maximum
The fluid gap is 16 px at a 320 px viewport, 27.2 px at 768 px, and stops at its 40 px maximum at 1440 px.gap: clamp(1rem, 2.5vw + 0.5rem, 2.5rem)= max(16 px, min(2.5vw + 8 px, 40 px))320 px viewport2.5vw + 8 px = 8 + 8 = 16 pxat the minimum: 16 px768 px viewport2.5vw + 8 px = 19.2 + 8 = 27.2 pxbetween the limits: 27.2 px1440 px viewport2.5vw + 8 px = 36 + 8 = 44 pxabove the maximum: 40 pxwiderwider

How clamp() keeps a fluid value between its limits

Text description of the diagram

The diagram follows the lesson's fluid gap, clamp(1rem, 2.5vw + 0.5rem, 2.5rem), which is the same as max(16 px, min(2.5vw + 8 px, 40 px)) with a 16 px root font size.

  1. At the top is the rule: the preferred value 2.5vw + 8 px, kept at 16 px or more and 40 px or less.
  2. At a 320 px viewport, 2.5vw is 8 px, so the preferred value is 16 px, exactly the minimum: the gap is 16 px. Any narrower and the minimum holds it at 16 px.
  3. At 768 px, 2.5vw is 19.2 px and the preferred value 27.2 px, between the limits, so the gap is 27.2 px.
  4. At 1440 px, 2.5vw is 36 px and the preferred value 44 px, above the maximum, so the gap stops at 40 px.

The middle value is a straight line, so it can be worked out from two points: the size you want at a narrow viewport and at a wide one. The slope is the growth per px of viewport, written in vw by multiplying it by 100, and the rest is an intercept in rem:

A clamp() through two points JavaScript · clamp_points.mjs
// A clamp() that is linear between two points: 28px text at a 320px viewport, growing to 40px at 1200px. The middle
// value is a straight line, slope × 100vw + intercept, written in vw and rem so it follows the reader's font size.

const rootPx = 16;
const from = { viewport: 320, size: 28 };
const to = { viewport: 1200, size: 40 };

const slope = (to.size - from.size) / (to.viewport - from.viewport);   // px of text per px of viewport
const intercept = from.size - slope * from.viewport;                    // px of text at a 0px viewport
const round = (n) => Number(n.toFixed(4));

const css = `clamp(${round(from.size / rootPx)}rem, ${round(intercept / rootPx)}rem + ${round(slope * 100)}vw, ${round(to.size / rootPx)}rem)`;
console.log(`slope ${round(slope)}, intercept ${round(intercept)}px`);
console.log(`font-size: ${css};`);

for (const viewport of [280, 320, 760, 1200, 1600]) {
  const size = Math.min(to.size, Math.max(from.size, intercept + slope * viewport));
  console.log(`${String(viewport).padStart(4)}px viewport: ${round(size)}px`);
}

Output

slope 0.0136, intercept 23.6364px
font-size: clamp(1.75rem, 1.4773rem + 1.3636vw, 2.5rem);
 280px viewport: 28px
 320px viewport: 28px
 760px viewport: 34px
1200px viewport: 40px
1600px viewport: 40px

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node clamp_points.mjs

Keep the limits and the intercept in rem, so the value still follows the reader’s font size; a fluid size in vw alone does not grow when the page is zoomed, which the viewport units lesson measures.

CSS Clamp Generator (Fluid Typography) Plot a clamp() between two sizes and two viewports, and check how it behaves when the page is zoomed.

Percentages inside math functions

A percentage inside a math function means what it means for that property, which is not always the parent’s width. Each line of this page puts a percentage in a different property of children of a 400 px wide container:

What a percentage inside calc() is a percentage of HTML · percentages.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>What a percentage inside calc() is a percentage of</title>
  <style>
    body { margin: 0; font: 16px/1.5 Arial, sans-serif; }
    .container { inline-size: 400px; }                    /* no height set: it is as tall as its content */

    .width   { inline-size: calc(50% + 1rem); }          /* of the container's width */
    .padding { padding-block-start: calc(10% + 0px); }    /* of the container's width too, even for top padding */
    .font    { font-size: calc(150% - 2px); }             /* of the parent's font size */
    .line    { line-height: calc(150% + 0px); }           /* of the element's own font size */
    .shift   { inline-size: 200px; translate: calc(50% - 10px); }   /* of the element's own width */
    .height  { block-size: calc(50% + 10px); }            /* of a height that isn't set */
  </style>
</head>
<body>
  <div class="container">
    <div class="width"></div>
    <div class="padding"></div>
    <div class="font">Text</div>
    <div class="line">Text</div>
    <div class="shift"></div>
    <div class="height">Text</div>
  </div>
  <script>
    const el = (s) => document.querySelector(s);
    const cs = (s) => getComputedStyle(el(s));
    const left = el('.container').getBoundingClientRect().left;
    console.log(`inline-size: calc(50% + 1rem)         -> ${el('.width').getBoundingClientRect().width}px`);
    console.log(`padding-block-start: calc(10% + 0px)  -> ${cs('.padding').paddingTop}`);
    console.log(`font-size: calc(150% - 2px)           -> ${cs('.font').fontSize}`);
    console.log(`line-height: calc(150% + 0px)         -> ${cs('.line').lineHeight}`);
    console.log(`translate: calc(50% - 10px), 200px box -> moved ${el('.shift').getBoundingClientRect().left - left}px`);
    console.log(`block-size: calc(50% + 10px), no height -> ${el('.height').getBoundingClientRect().height}px`);
  </script>
</body>
</html>
Declaration Result The percentage is of
inline-size: calc(50% + 1rem) 216 px the container’s width
padding-block-start: calc(10% + 0px) 40 px the container’s width, even for top padding
font-size: calc(150% - 2px) 22 px the parent’s font size
line-height: calc(150% + 0px) 24 px the element’s own font size
translate: calc(50% - 10px) on a 200 px box moved 90 px the element’s own width
block-size: calc(50% + 10px) 24 px nothing: the container’s height isn’t set

The engines agreed on every line. The last one is the percentage-height rule of the sizing lesson again: with no definite height to take a share of, the whole value behaves as auto, and the box took its content’s 24 px.

round(), mod() and rem(): stepping values

round(strategy, A, B) rounds A to a multiple of B. The strategy is nearest (the default), up, down or to-zero. mod(A, B) and rem(A, B) give what is left after taking whole steps of B away from A: mod() takes the sign of B, rem() the sign of A, so they differ only when the two signs differ.

round(), mod(), rem(), abs() and sign() HTML · stepped.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>round(), mod(), rem(), abs() and sign()</title>
  <style>
    body { margin: 0; }
    .box { block-size: 4px; }
    /* Snap a width to an 8px grid: round down, so it never overflows its container. */
    .snap { inline-size: round(down, 100%, 8px); }
  </style>
</head>
<body>
  <div class="container"><div class="box snap"></div></div>
  <div class="probe box"></div>
  <script>
    const container = document.querySelector('.container');
    for (const width of [357, 360, 365, 1003]) {
      container.style.inlineSize = `${width}px`;
      console.log(`round(down, 100%, 8px) in ${width}px -> ${document.querySelector('.snap').getBoundingClientRect().width}px`);
    }
    // The other functions, read back through margin-left, which accepts negative lengths.
    const probe = document.querySelector('.probe');
    for (const value of ['round(365px, 8px)', 'round(up, 361px, 8px)', 'round(down, -13px, 8px)', 'round(to-zero, -13px, 8px)',
      'mod(-7px, 5px)', 'rem(-7px, 5px)', 'mod(7px, -5px)', 'rem(7px, -5px)', 'abs(-24px)', 'calc(10px * sign(-3px))']) {
      probe.style.marginLeft = value;
      console.log(`${value.padEnd(27)} -> ${getComputedStyle(probe).marginLeft}`);
    }
  </script>
</body>
</html>
Value Result in all three engines
round(down, 100%, 8px) in 357, 360, 365 and 1,003 px 352, 360, 360 and 1,000 px
round(365px, 8px), round(up, 361px, 8px) 368 px, 368 px
round(down, -13px, 8px), round(to-zero, -13px, 8px) −16 px, −8 px
mod(-7px, 5px), rem(-7px, 5px) 3 px, −2 px
mod(7px, -5px), rem(7px, -5px) −3 px, 2 px
abs(-24px), calc(10px * sign(-3px)) 24 px, −10 px

Rounding a width down to an 8 px grid keeps it a multiple of 8 that never overflows its container, a job that used to need a resize listener in JavaScript. mod() suits values that wrap, such as rotate: mod(var(--angle), 360deg). abs() drops the sign and sign() returns −1, 0 or 1, which turns the direction of a value into a factor. round(), mod() and rem() are Baseline newly available, and so are abs() and sign().

pow() and a type scale

The exponential functions, pow(), sqrt(), hypot(), log() and exp(), are Baseline widely available. pow() builds a type scale from one ratio, so changing the ratio retunes every heading:

A type scale with pow() HTML · type-scale.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>A type scale with pow()</title>
  <style>
    :root { --ratio: 1.25; }                 /* each step is 1.25 times the last */
    body { margin: 0; font-family: Arial, sans-serif; }
    h1 { font-size: calc(1rem * pow(var(--ratio), 4)); }
    h2 { font-size: calc(1rem * pow(var(--ratio), 3)); }
    h3 { font-size: calc(1rem * pow(var(--ratio), 2)); }
    h4 { font-size: calc(1rem * pow(var(--ratio), 1)); }
    p { font-size: 1rem; }
    small { font-size: calc(1rem * pow(var(--ratio), -1)); }
  </style>
</head>
<body>
  <h1>Step 4</h1><h2>Step 3</h2><h3>Step 2</h3><h4>Step 1</h4><p>Step 0</p><small>Step -1</small>
  <script>
    for (const tag of ['h1', 'h2', 'h3', 'h4', 'p', 'small']) {
      console.log(`${tag.padEnd(5)} ${getComputedStyle(document.querySelector(tag)).fontSize}`);
    }
  </script>
</body>
</html>

With a ratio of 1.25, the steps came out at 39.0625, 31.25, 25, 20, 16 and 12.8 px in Chromium and WebKit; Firefox rounded the smallest to 12.7969 px.

sin(), cos() and sibling-index(): a circular menu

The trigonometric functions sin(), cos(), tan() and their inverses take an angle (or a number of radians) and are Baseline widely available, like the constants pi and e. Paired with sibling-index(), which gives an element’s position among its siblings counting from 1, and sibling-count(), which counts them, they can place items on a circle without a class per item. The tree counting functions are only newly available, so the circle sits inside a feature query, and older browsers keep a plain row:

A circular menu with sin(), cos() and sibling-index() HTML · circle-menu.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>A circular menu with sin(), cos() and sibling-index()</title>
  <style>
    body { margin: 0; font: 16px/1.5 Arial, sans-serif; }
    .menu { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0; padding: 0; list-style: none; }
    .menu li { inline-size: 3rem; block-size: 3rem; display: grid; place-items: center; background: #345; color: #fff; }

    /* Browsers that know sibling-index() place the items on a circle; the others keep the row above. */
    @supports (order: sibling-index()) {
      .menu { position: relative; inline-size: 15rem; block-size: 15rem; --radius: 5.5rem; }
      .menu li {
        position: absolute;
        inset: 0;
        margin: auto;
        --angle: calc(360deg / sibling-count() * (sibling-index() - 1));
        translate: calc(cos(var(--angle)) * var(--radius)) calc(sin(var(--angle)) * var(--radius));
      }
    }
  </style>
</head>
<body>
  <ul class="menu"><li>1</li><li>2</li><li>3</li><li>4</li><li>5</li><li>6</li></ul>
  <script>
    const menu = document.querySelector('.menu').getBoundingClientRect();
    const cx = menu.left + menu.width / 2;
    const cy = menu.top + menu.height / 2;
    console.log(`@supports (order: sibling-index()): ${CSS.supports('order', 'sibling-index()')}`);
    for (const li of document.querySelectorAll('.menu li')) {
      const r = li.getBoundingClientRect();
      const x = Math.round((r.left + r.width / 2 - cx) * 100) / 100;
      const y = Math.round((r.top + r.height / 2 - cy) * 100) / 100;
      console.log(`item ${li.textContent}: centre at (${x}, ${y}) from the menu's centre`);
    }
  </script>
</body>
</html>

All three engines passed the feature query and put the six items 60 degrees apart on a circle of 5.5rem, 88 px: the first at (88, 0) from the centre, the second at (44, 76.21), and so on round to (44, −76.21), with Firefox a hundredth of a pixel off. The y axis points down in CSS, so positive angles turn clockwise. The visual circle changes nothing for keyboard and screen reader users, who meet the items in their source order, so keep that order the same as the visual one.

progress(): how far between two values

progress(VALUE, START, END) returns a number: 0 when VALUE is at START, 1 at END, and the fraction in between. The current specification clamps it to the range 0 to 1, and it can feed any other calculation, such as a width or an opacity that follows the viewport:

progress() between two viewport widths HTML · progress.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>progress(): how far a value is between two others</title>
  <style>
    body { margin: 0; }
    /* 0 at a 320px viewport, 1 at 1280px: a header shadow that fades in as screens get wider. */
    .bar {
      block-size: 4px;
      inline-size: calc(200px * progress(100vw, 320px, 1280px));
      opacity: progress(100vw, 320px, 1280px);
    }
  </style>
</head>
<body>
  <div class="bar"></div>
  <script>
    const bar = document.querySelector('.bar');
    console.log(`${innerWidth}px wide: width ${bar.getBoundingClientRect().width}px, opacity ${getComputedStyle(bar).opacity}`);
  </script>
</body>
</html>

At viewports of 320, 800, 1,280 and 1,600 px, the bar was 0, 100, 200 and 200 px wide and its opacity 0, 0.5, 1 and 1 in all three engines: past the end, the value stayed at 1. progress() is Baseline newly available.

Practise: a fluid heading

Exercise · Medium · JavaScript

Write a clamp() for a fluid heading

A heading should be 24 px at a 360 px viewport, 48 px at a 1280 px viewport, change linearly in between and stay within those sizes outside them. Write fluidClamp({ minSize, maxSize, minViewport, maxViewport, rootPx = 16 }) in fluid.mjs and export it. All its arguments are px, and maxSize is larger than minSize. It returns the CSS value as a string:

clamp(<min>rem, <intercept>rem + <slope>vw, <max>rem)

The minimum and the maximum are the two sizes in rem. The middle term is the straight line through the two points: its vw number is the growth in px per px of viewport, times 100, and its rem number is the size that line gives at a 0 px viewport, divided by rootPx. The intercept can be negative (-1.25rem + 10vw is valid CSS). Round every number to at most 4 decimals and drop trailing zeros. Sizes in rem keep the limits following the reader's default font size.

The sample tests read your value back the way a browser computes it and check the size at 360, 820 and 1280 px (within 0.5 px), the limits at 200 and 1920 px, and what changes with a 20 px root.

Starter code · fluid.mjs

/** A clamp() value that is minSize px at minViewport, maxSize px at maxViewport and linear in between. */
export function fluidClamp({ minSize, maxSize, minViewport, maxViewport, rootPx = 16 }) {
  // Replace this line with your code.
  return `clamp(${minSize}px, 4vw, ${maxSize}px)`;
}
The sample tests · fluid.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { fluidClamp } from './fluid.mjs';

const VALUE = /^clamp\((\d+(?:\.\d+)?)rem, (-?\d+(?:\.\d+)?)rem \+ (\d+(?:\.\d+)?)vw, (\d+(?:\.\d+)?)rem\)$/;

/** The size in px a browser computes for such a clamp() value at a viewport width, with a root font size. */
function computed(value, viewport, rootPx = 16) {
  const m = VALUE.exec(value);
  assert.ok(m, `"${value}" is not of the form clamp(<n>rem, <n>rem + <n>vw, <n>rem)`);
  const [min, intercept, slope, max] = m.slice(1).map(Number);
  const preferred = intercept * rootPx + (slope * viewport) / 100;
  return Math.max(min * rootPx, Math.min(preferred, max * rootPx));
}

const heading = { minSize: 24, maxSize: 48, minViewport: 360, maxViewport: 1280 };
const near = (actual, expected, what) => assert.ok(Math.abs(actual - expected) <= 0.5, `${what}: expected about ${expected}px, got ${actual}px`);

test('the value has the form clamp(rem, rem + vw, rem)', () => {
  assert.match(fluidClamp(heading), VALUE);
});

test('24px at 360px, 36px at 820px and 48px at 1280px', () => {
  const value = fluidClamp(heading);
  near(computed(value, 360), 24, '360px');
  near(computed(value, 820), 36, '820px');
  near(computed(value, 1280), 48, '1280px');
});

test('the limits hold outside the two viewports', () => {
  const value = fluidClamp(heading);
  near(computed(value, 200), 24, '200px');
  near(computed(value, 1920), 48, '1920px');
});

test('numbers are rounded to 4 decimals, without trailing zeros', () => {
  assert.equal(fluidClamp(heading), 'clamp(1.5rem, 0.913rem + 2.6087vw, 3rem)');
  assert.equal(fluidClamp({ minSize: 16, maxSize: 20, minViewport: 320, maxViewport: 1120 }), 'clamp(1rem, 0.9rem + 0.5vw, 1.25rem)');
});

test('a steep line has a negative intercept', () => {
  const value = fluidClamp({ minSize: 20, maxSize: 60, minViewport: 400, maxViewport: 800 });
  assert.equal(value, 'clamp(1.25rem, -1.25rem + 10vw, 3.75rem)');
  near(computed(value, 600), 40, '600px');
});

test('the root size sets the rem numbers, and the limits follow the reader', () => {
  const value = fluidClamp({ ...heading, rootPx: 20 });
  assert.equal(value, 'clamp(1.2rem, 0.7304rem + 2.6087vw, 2.4rem)');
  near(computed(value, 360, 20), 24, '360px, written for a 20px root');
  near(computed(fluidClamp(heading), 200, 20), 30, 'the minimum when the reader chose 20px');
  near(computed(fluidClamp(heading), 1920, 20), 60, 'the maximum when the reader chose 20px');
});
A hint

The slope is (maxSize - minSize) / (maxViewport - minViewport), in px of text per px of viewport, so the vw number is slope * 100. The intercept is minSize - slope * minViewport. Number(x.toFixed(4)) rounds and drops the trailing zeros; build the string with a template literal.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

7 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 7 With a 16px root font size, how wide is gap: clamp(1rem, 2.5vw + 0.5rem, 2.5rem) in a 1000px viewport?

    Type a number.

    Show the answer to question 1

    Answer: 33 px

    2.5vw of 1,000 px is 25 px and 0.5rem is 8 px, so the preferred value is 33 px. That is between the 16 px minimum and the 40 px maximum, so clamp() keeps it. At 1,440 px the same value would be 44 px and stop at 40.

  2. Question 2 of 7 What does clamp(40px, 30px, 20px) compute to?

    Choose one answer.

    Show the answer to question 2

    Answer: 40px, because clamp() is max(MIN, min(VAL, MAX)) and the minimum wins

    min(30px, 20px) is 20px and max(40px, 20px) is 40px. The specification favours the minimum when the limits cross, and all three engines gave 30px for clamp(30px, 20px, 10px) in the lesson's run.

  3. Question 3 of 7 Which of these is a valid value for width?

    Choose one answer.

    Show the answer to question 3

    Answer: calc(100% - 1rem)

    + and - need white space on both sides; without it, "-1rem" is read as a negative number and the expression falls apart. 1rem × 1rem is a length squared, which no property accepts. An invalid value drops the whole declaration, so the box keeps whatever an earlier declaration set.

  4. Question 4 of 7 A container is 500px wide and has no height set. Its child has padding-block-start: calc(10% + 4px). How much top padding does the child get?

    Type a number.

    Show the answer to question 4

    Answer: 54 px

    Inside a math function a percentage means what it means in the property. Padding percentages, top and bottom included, are a share of the containing block's width: 10 % of 500 px is 50 px, plus 4 px.

  5. Question 5 of 7 inline-size: round(down, 100%, 8px) sits in a container 397px wide. How wide is the box?

    Type a number.

    Show the answer to question 5

    Answer: 392 px

    397 ÷ 8 is 49.625, and rounding down takes 49 steps of 8 px: 392 px, a multiple of 8 that never overflows the container. round(397px, 8px), which rounds to the nearest, would give 400 px.

  6. Question 6 of 7 What do mod(-7px, 5px) and rem(-7px, 5px) compute to?

    Choose one answer.

    Show the answer to question 6

    Answer: 3px and -2px

    Both shift -7px by a multiple of 5px. mod() keeps the result between 0 and the step, so it takes the step's sign: -7 + 10 = 3. rem() keeps the sign of the first value: -7 + 5 = -2. With both values positive they agree.

  7. Question 7 of 7 An element has --gap: 16, then margin-inline-start: 24px; margin-inline-start: var(--gap)px;. What is its margin?

    Choose one answer.

    Show the answer to question 7

    Answer: 0px, because the second declaration wins the cascade and only then turns out to be invalid

    A declaration with var() is accepted while parsing, so it wins over the 24px. When the variable is filled in, "16" and "px" stay two separate pieces, the value is invalid at computed-value time, and the property takes its initial value, 0, as all three engines did. calc(var(--gap) * 1px) gives 16px.

Interview questions

Fresher · What does clamp() do, and what happens when its minimum is larger than its maximum?

clamp(MIN, VAL, MAX) returns the preferred value VAL, but never less than MIN and never more than MAX; it is the same as max(MIN, min(VAL, MAX)). The usual job is fluid sizing: gap: clamp(1rem, 2.5vw + 0.5rem, 2.5rem) is 16 px at a 320 px viewport, 27.2 px at 768 px and stops at 40 px on wide screens. If the minimum is larger than the maximum, the minimum wins, because min() runs first and max() last: clamp(30px, 20px, 10px) is 30 px, as all three engines agreed. Either limit can be none to leave that side open. Keep the limits in rem rather than px so they follow the reader’s font size.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A designer wants headings to grow from 28 px on a 375 px phone to 56 px on a 1440 px desktop. A developer ships font-size: 5vw. What goes wrong? Write a clamp() that keeps the design and still lets readers enlarge the text.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    A sidebar layout works in development, but in production the main column has its default width and overlaps the sidebar. The source says width: calc(100% - 16rem - 2rem), and the production CSS comes from a new minifier. What do you suspect, how do you confirm it, and how do you prevent it?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • calc() mixes units at layout time; + and - need spaces, and an invalid value drops the whole declaration without a message.
  • min() sets an upper limit and max() a lower one; clamp(MIN, VAL, MAX) is max(MIN, min(VAL, MAX)), so the minimum wins when the limits cross.
  • A fluid value is a straight line through two points: a slope in vw and an intercept in rem, between limits in rem.
  • A percentage keeps its property’s meaning inside a math function: top padding is still a share of the width.
  • round(), mod(), rem(), abs() and sign() are newly available; the exponential and trigonometric functions are widely available.
  • sibling-index() and sibling-count() place items without a class each; guard them with @supports (order: sibling-index()).
  • progress() returns 0 to 1 between two values and stayed at 1 past the end in every engine.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.