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.
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
What you’ll learn · Before you start
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
On this page
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:
<!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:
<!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:
<!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 |
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.
- At the top is the rule: the preferred value 2.5vw + 8 px, kept at 16 px or more and 40 px or less.
- 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.
- 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.
- 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() 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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:
<!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.
<!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:
<!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:
<!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:
<!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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
-
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.
-
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?
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 andmax()a lower one;clamp(MIN, VAL, MAX)ismax(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()andsign()are newly available; the exponential and trigonometric functions are widely available.sibling-index()andsibling-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
- CSS Values and Units Module Level 4: basic arithmetic, calc() (W3C CSS Working Group)
- CSS Values and Units Module Level 4: comparison functions, min(), max() and clamp() (W3C CSS Working Group)
- CSS Values and Units Module Level 4: stepped value functions, round(), mod() and rem() (W3C CSS Working Group)
- CSS Values and Units Module Level 4: trigonometric functions (W3C CSS Working Group)
- CSS Values and Units Module Level 4: exponential functions (W3C CSS Working Group)
- CSS Values and Units Module Level 4: syntax and type checking of math functions (W3C CSS Working Group)
- CSS Values and Units Module Level 5: progress() (W3C CSS Working Group)
- CSS Values and Units Module Level 5: tree counting functions, sibling-count() and sibling-index() (W3C CSS Working Group)
- clamp() (MDN Web Docs) (Mozilla)
- Web features explorer: min(), max() and clamp() (W3C WebDX Community Group)
- Web features explorer: round(), mod() and rem() (W3C WebDX Community Group)
- Web features explorer: sibling-count() and sibling-index() (W3C WebDX Community Group)
- Web features explorer: progress() (W3C WebDX Community Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress