James McGrath

CSS only responsive typorgraphy

Published on Jul 26, 2025
Reading time: 7 minutes

A few years ago when I started using css custom properties, I would try all kinds of things with them to see what I could discover about using them. One of the things I made is this .fs-scale class that does responsive typography. It's pretty interesting in the way it uses the cascade and vars to make it work.

.fs-scale {
	--root-fs: 16;
	--min-fs: 1;
	--max-fs: 2;
	--mq1: 300;
	--mq2: 1024;
	--min-width: calc(var(--mq1) / var(--root-fs));
	--max-width: calc(var(--mq2) / var(--root-fs));
	--slope: calc(
		(var(--max-fs) - var(--min-fs)) / (var(--max-width) - var(--min-width))
	);
	--y-intersect: calc(
		(calc(var(--min-width) * -1) * var(--slope) + var(--min-fs))
	);
	--slope-vw: calc(var(--slope) * 100vw);
	--preferred-val: calc(calc(var(--y-intersect) * 1rem) + var(--slope-vw));
	--clamp-fs: clamp(
		calc(var(--min-fs) * 1rem),
		var(--preferred-val),
		calc(var(--max-fs) * 1rem)
	);
}

.fs-scale {
		font-size: calc(var(--min-fs) * 1rem);
}

@media (min-width: 64em) {
	.fs-scale {
		font-size: calc(var(--max-fs) * 1rem);
	}
}

@media (min-width: 1px) {
	@supports (font-size: clamp(1px, 2px, 3px)) {
		.fs-scale {
			font-size: var(--clamp-fs);
		}
	}
}
/* how to use */
<div class="fs-scale title">Title</div>

.title {
	--min-fs: 1;
	--max-fs: 5;
}

I have actually used this in production. There is also a comment with it that says "If you don't understand this, don't use it." It's been quite some time since I wrote it and it turns out that I know how to use it, but I don't understand what it is doing. If you think this post will be me trying to explain it to you then you have guessed wrong. I gave the class to multiple AIs and asked them to explain it and then combined the results I liked into one. Here's the comprehensive explanation that emerged from that collaboration:

What it does (in plain English)

.fs-scale creates smooth, proportional text scaling between viewport breakpoints instead of jarring jumps: • From --mq1 (300px) → use --min-fs (in rem) • To --mq2 (1024px) → use --max-fs (in rem) • Between them → font-size grows linearly along a mathematically precise curve • Below/above them → clamped to min/max boundaries

Think of it like plotting two points on a graph and drawing a straight line between them. Instead of text that's 16px until 768px then suddenly jumps to 24px, you get smooth scaling where text at 600px viewport might be 20px.

Progressive enhancement: Modern browsers get fluid scaling with clamp(), older browsers get sensible breakpoint-based steps.

Ready to implement? If you just want to use this technique, skip to the How to use it section. Want to understand potential implementation challenges first? Check out the gotchas and tips section. If you want to understand the mathematical foundations, keep reading.

The mathematical foundation

This system implements linear interpolation using the classic equation y = mx + b:

1. Coordinate system setup

--root-fs: 16; /* Base reference: 16px = 1rem */
--min-fs: 1; /* Starting size: 1rem = 16px */
--max-fs: 2; /* Ending size: 2rem = 32px */
--mq1: 300; /* Starting viewport: 300px */
--mq2: 1024; /* Ending viewport: 1024px */

This establishes two coordinate points: (300px, 16px) and (1024px, 32px)

2. Unit normalization

--min-width: calc(var(--mq1) / var(--root-fs)); /* 300÷16 = 18.75rem */
--max-width: calc(var(--mq2) / var(--root-fs)); /* 1024÷16 = 64rem */

Converts pixel breakpoints to rem for mathematical consistency.

3. Slope calculation (rise over run)

--slope: calc(
  (var(--max-fs) - var(--min-fs)) / (var(--max-width) - var(--min-width))
);

Result: (2-1) ÷ (64-18.75) = 0.022 rem per rem of viewport width • For every rem the screen gets wider, font grows by ~0.022rem

4. Y-intercept calculation

--y-intersect: calc(
  (calc(var(--min-width) * -1) * var(--slope) + var(--min-fs))
);

Solves for b in y = mx + b using our known point (18.75rem, 1rem) Result: b ≈ 0.59rem

5. Viewport-responsive value

--slope-vw: calc(var(--slope) * 100vw);
--preferred-val: calc(calc(var(--y-intersect) * 1rem) + var(--slope-vw));

Translates the linear equation to work with current viewport width (100vw) Formula becomes: font-size = 0.59rem + (0.022 × viewport-width)

6. Safety boundaries

--clamp-fs: clamp(
  calc(var(--min-fs) * 1rem),
  /* Never below 1rem */ var(--preferred-val),
  /* Calculated value */ calc(var(--max-fs) * 1rem) /* Never above 2rem */
);

Acts like guardrails ensuring text stays within reasonable bounds on extreme screens.

Progressive enhancement strategy

The implementation builds in three layers for maximum browser compatibility:

Layer 1: Base fallback (all browsers)

.fs-scale {
  font-size: calc(var(--min-fs) * 1rem); /* Safe minimum */
}

Layer 2: Large screen fallback (older browsers)

@media (min-width: 64em) {
  .fs-scale {
    font-size: calc(var(--max-fs) * 1rem); /* Step to maximum */
  }
}

Layer 3: Modern browser enhancement

@media (min-width: 1px) {
  @supports (font-size: clamp(1px, 2px, 3px)) {
    .fs-scale {
      font-size: var(--clamp-fs); /* Full fluid scaling */
    }
  }
}

The @media (min-width: 1px) hack ensures this rule has higher specificity than the previous media query.

How to use it

Understanding the theory is one thing, but putting this into practice is where it becomes truly valuable:

1. Add the class

<h1 class="fs-scale title">Fluid Title</h1>
<p class="fs-scale">Body text that scales subtly</p>

2. Set per-element ranges

Override min/max per component via companion classes or inline styles:

/* Dramatic title scaling */
.title {
  --min-fs: 1;
  --max-fs: 5;
} /* 16px → 80px */

/* Subtle body text scaling */
p.fs-scale {
  --min-fs: 1;
  --max-fs: 1.25;
} /* 16px → 20px */

/* Small text scaling */
.small {
  --min-fs: 0.875;
  --max-fs: 1;
} /* 14px → 16px */

Or inline:

<h2 class="fs-scale" style="--min-fs:1.25; --max-fs:2.5;">Section</h2>

3. (Optional) Customize site-wide breakpoints

:root {
  --root-fs: 16; /* Keep accurate: match your actual 1rem in px */
  --mq1: 360; /* Start scaling at 360px instead of 300px */
  --mq2: 1280; /* Stop scaling at 1280px instead of 1024px */
}

What the provided .title does

.title {
  --min-fs: 1;
  --max-fs: 5;
}

A <h1 class="fs-scale title"> will be: • 1rem (16px) at ≤300px viewports • Smoothly scales from 16px to 80px between 300px–1024px • 5rem (80px) at ≥1024px viewports

The scaling follows the mathematical curve: font-size = 0.59rem + (0.022 × viewport-width)

Advantages over traditional breakpoints

Traditional approach problems: • Abrupt jumps create jarring user experience • Limited control points (usually 2-3 breakpoints) • Awkward in-between sizes on tablets and intermediate screens • Requires manual fine-tuning for each breakpoint

This fluid system benefits: • Seamless scaling eliminates visual jumps • Mathematical precision ensures proportional growth • Infinite responsiveness works perfectly at any screen size • Accessibility friendly respects user font-size preferences (rem-based) • Future-proof automatically handles new device sizes

Advanced usage patterns

Once you've mastered the basics, these advanced techniques can expand the system's capabilities:

Container-based scaling (modern browsers)

/* Replace 100vw with 100cqw for container queries */
--slope-cqw: calc(var(--slope) * 100cqw);
--preferred-val: calc(calc(var(--y-intersect) * 1rem) + var(--slope-cqw));

@container (min-width: 300px) {
  .fs-scale {
    font-size: var(--clamp-fs);
  }
}

Multiple scaling ranges

.hero-text {
  --min-fs: 2; /* Start larger */
  --max-fs: 8; /* Scale dramatically */
  --mq1: 320; /* Different breakpoints */
  --mq2: 1440;
}

Reverse scaling limitation

/* This WON'T work - clamp() breaks when min > max */
.broken-reverse {
  --min-fs: 1.5; /* clamp() minimum */
  --max-fs: 1; /* clamp() maximum - invalid! */
}

Note: This system only supports scaling up (min-fs < max-fs). Reverse scaling would require modifying the clamp logic.

Gotchas / tips

Keep --root-fs accurate: If html { font-size: 62.5%; } (10px rem), set --root-fs: 10Values are viewport-based: Uses 100vw, so horizontal scrollbars can affect scaling • Respect user preferences: The rem-based system honors user zoom and font-size settings
Performance: Calculations happen once per element, very efficient • Debug tip: Use browser dev tools to inspect --preferred-val and see the live calculation • Accessibility: Always test with 200% browser zoom to ensure readability • Design systems: Consider creating preset classes for common scaling patterns

Browser support

Full fluid scaling: All modern browsers (Chrome 79+, Firefox 75+, Safari 13.1+) • Graceful fallback: All browsers back to IE9 get step-based responsive text
Progressive enhancement: Older browsers still get a good experience, just less smooth