ResponsiveHumanist Studio
The fluid type scale that got smaller as the screen got bigger
Visual generators for fluid typography stack media queries whose curves do not meet. The result is a root font-size that drops 12% at 480px and again at 1025px. Here is the maths, the fix, and the check that walks every viewport.
The original responsive system in this repository came from a visual generator. You give it a few breakpoints and target sizes, it gives you a block of CSS for html { font-size }, and everything in rem follows. It looked reasonable and it took four hours to find out why the page felt wrong at some widths.
The sawtooth
The generated CSS was a stack of max-width media queries, each with its own calc():
html { font-size: calc(0.625rem + 0.4167vw); }
@media (max-width: 1440px) { html { font-size: calc(0.8127rem + 0.2081vw); } }
@media (max-width: 479px) { html { font-size: calc(0.7495rem + 0.8368vw); } }Each rule is a straight line. Nobody checked that the lines meet at the boundaries. Evaluate them and they do not: at 479px the root is 16.00px, at 480px it is 14.00px. The page loses twelve percent of its type size because the window got one pixel wider. Add more breakpoints and you get more teeth.
A curve made of segments that were calculated independently is not a curve. It is a set of cliffs with the same name.
Two decisions
The first was to stop scaling the root at all. html { font-size: 62.5% }, fixed. That makes 1rem equal 10px at every viewport, which means a 32px value from a Figma frame is text-[3.2rem] and there is nothing to convert. It also means the Studio, which is a separate root layout, keeps its 16px — a scaling root had been shrinking every Sanity UI measurement by 37%.
The second was to put the fluidity in the utilities instead, as a single clamp() per value with two anchors: the mobile frame width and the desktop frame width, both read from one config file.
const DESIGN = { mobile: 390, desktop: 1620, root: 10, cap: 1920 };
// text-fluid-[32,48] → 32px at 390, 48px at 1620, capped past 1920
function fluid(minPx, maxPx) {
const K = ((maxPx - minPx) / (DESIGN.desktop - DESIGN.mobile)) * 100;
const C = (minPx - (K * DESIGN.mobile) / 100) / DESIGN.root;
return `clamp(${minPx / 10}rem, ${C.toFixed(4)}rem + ${K.toFixed(4)}vw, ${capPx / 10}rem)`;
}One line, one continuous function, exact at both anchors. text-fluid-[32,48] resolves to 32.0px at 390 and 48.0px at 1620, interpolates between, and holds at the cap. There is no breakpoint to meet because there is only one segment.
The check
Because the first system also looked fine, the new one is not trusted on inspection. A script imports fluid(), reimplements clamp() in JavaScript, and walks every viewport from 200px to 6000px for every pair used in the project:
for (let w = 200; w <= 6000; w++) {
const px = resolve(css, w);
if (px < previous - 0.01) fail(`${pair} goes backwards at ${w}px`);
previous = px;
}
assert(resolve(css, DESIGN.mobile) === minPx);
assert(resolve(css, DESIGN.desktop) === maxPx);It asserts both anchors exactly and that the value is monotonic across the whole range. And it carries a negative test — the old sawtooth formula is fed through the same walk and must fail, with the drop and the viewport named. A check that cannot fail is decoration.
It runs before every build. Change the frame widths, forget to update the container, and the build names both numbers and stops.