Fluid Type, Accessibility and Zoom: The vw-Only Trap

A fluid type scale looks like the most accessible thing you can ship. It never stair-steps, it never leaves a heading absurdly large on a phone, and it reads well at every width you check in a device toolbar. Then a user who reads at 200% zoom loads the page, and the body copy barely moves while every margin, button and image around it grows — because the scale was written in vw units, and vw is one of the few things in CSS that zoom cannot enlarge. This page is about that specific failure, how it relates to WCAG 2.2 Success Criterion 1.4.4 Resize Text, and the one-line change that fixes it. It sits alongside the rest of Fluid Typography with clamp() and the sizing approach described in Mastering Container Queries & Responsive Layouts; the mechanics of building the scale itself are covered in fluid typography without JavaScript.

What 1.4.4 actually requires

Success Criterion 1.4.4 Resize Text is a Level AA requirement: text must be resizable up to 200 percent without assistive technology, and without loss of content or functionality. Two things about the wording are easy to miss.

First, "up to 200%" is a floor for the user's action, not a cap on yours. A scale that grows text by 12% when the user asks for 200% has not met it, even though nothing visibly broke. The criterion is about the text actually getting twice as big.

Second, it is deliberately silent about which mechanism the user employs. Full-page browser zoom satisfies it on most desktop browsers essentially for free, which is why the criterion is so often assumed to be automatically met. But text-only zoom (Firefox's zoom-text-only mode, and the text scaling controls on mobile operating systems) resizes the root font size without touching the layout viewport at all — and a vw-derived value does not depend on the root font size, so it does not move by a single pixel. A scale that only passes under full-page zoom passes by accident.

Why vw cancels itself out under zoom

Full-page browser zoom works by changing the ratio between CSS pixels and device pixels. At 200% zoom, one CSS pixel is painted at twice the size, so the same window now holds half as many CSS pixels across. The layout viewport, measured in CSS pixels, halves.

1vw is defined as one percent of that layout viewport width. So at 200% zoom, 1vw resolves to half as many CSS pixels as before — and each of those CSS pixels is painted twice as large. The two factors multiply to exactly 1. Rendered size unchanged.

1rem, by contrast, is one times the root element's computed font size, which is a fixed number of CSS pixels regardless of viewport width. Zoom leaves that number alone and simply paints each CSS pixel bigger, so 1rem grows by the full zoom factor. Text-only zoom changes the number itself, so rem grows there too. It is the only unit in the pair that responds to either mechanism.

Rendered text size at 100% and 200% zoom A clamp scale with no rem term stays near its original rendered size when the page is zoomed to 200 percent, while a scale whose preferred value includes a rem term roughly doubles. rendered size at 100% vs 200% zoom clamp(1rem, 4vw, 2.5rem) 100% 200% grows by about 6% fails 1.4.4 clamp(1rem, .85rem + 1.2vw, 2.5rem) 100% 200% grows with the zoom factor passes 1.4.4 The rem term is the only part of the value that zoom can reach.

See both scales side by side

Both headings below carry the same 1rem floor and the same 2.5rem ceiling. Only the preferred value differs. Zoom this page to 200% and compare them.

Live demoA vw-only clamp scale next to a rem + vw clamp scale
Zoom the page to 200% (Ctrl/Cmd and +). The left heading barely grows; the right one doubles, because its preferred value carries a rem term.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Zoom-safe fluid type</title>
<style>
  /* Never set a px or % font-size on :root — it overrides the user's own
     browser font-size preference, which is the setting rem is built on. */
  body { margin: 0; padding: 1rem; font: 16px/1.5 system-ui, sans-serif; }

  .shell { display: grid; gap: 0.9rem; grid-template-columns: 1fr 1fr; }

  .col { border: 1px solid #d4d4d8; border-radius: 10px; padding: 0.85rem; }
  .col--good { border-color: #3b6df5; }

  .col__tag {
    margin: 0 0 0.4rem;
    font-size: 0.72rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    opacity: 0.7;
  }

  .col__code {
    display: block;
    margin-block-start: 0.6rem;
    font-family: ui-monospace, monospace;
    font-size: 0.78rem;
    opacity: 0.7;
    overflow-wrap: anywhere;
  }

  /* The trap. The preferred value is pure vw, so between the 1rem floor and
     the 2.5rem ceiling the rendered size is pinned to the layout viewport —
     and the layout viewport shrinks by exactly the zoom factor. */
  .trap-heading {
    margin: 0;
    line-height: 1.15;
    font-size: clamp(1rem, 4vw, 2.5rem);
  }

  /* The fix. The rem term is multiplied by root font size and zoom, so it
     carries the growth; the vw term only supplies the fluid slope. */
  .good-heading {
    margin: 0;
    line-height: 1.15;
    font-size: clamp(1rem, 0.85rem + 1.2vw, 2.5rem);
  }
</style>
</head>
<body>
  <div class="shell">
    <section class="col col--trap">
      <p class="col__tag">defeats zoom</p>
      <h3 class="trap-heading">Resize me</h3>
      <code class="col__code">clamp(1rem, 4vw, 2.5rem)</code>
    </section>
    <section class="col col--good">
      <p class="col__tag">honours zoom</p>
      <h3 class="good-heading">Resize me</h3>
      <code class="col__code">clamp(1rem, 0.85rem + 1.2vw, 2.5rem)</code>
    </section>
  </div>
</body>
</html>

Key technique: split the preferred value

clamp(MIN, PREFERRED, MAX) returns MAX when the preferred value exceeds it, MIN when it falls below, and the preferred value the rest of the time. That middle band is where users spend nearly all of their reading, so it is the only part of the expression that matters for this criterion. Bounds expressed in rem do not save a preferred value expressed in vw; they only take over once you are outside the fluid range entirely, at which point the type has stopped being fluid.

So the rule is narrow and mechanical: the preferred value must be a sum containing a rem term. Write Arem + Bvw, never Bvw alone. The rem term is the part zoom and root-font-size preferences can multiply; the vw term supplies the slope that makes the type fluid at a fixed zoom level.

Deriving the pair for a target range is straightforward. To go from 1rem at a 320px viewport to 2.5rem at a 1280px viewport, with a 16px root:

  • The slope is (40px − 16px) / (1280px − 320px) = 0.025, expressed as 2.5vw.
  • The intercept is 16px − (0.025 × 320px) = 8px, expressed as 0.5rem.
  • The result is clamp(1rem, 0.5rem + 2.5vw, 2.5rem).

The diagnostic for an existing scale: at your median viewport width, work out how much of the computed size comes from the rem term. If that share is near zero, the scale is fluid but not resizable. Raising the intercept and lowering the vw coefficient — a flatter slope with a bigger base — trades a little responsiveness for a lot of zoom headroom, and is almost always the right trade.

Two related habits belong here. Never set font-size in px or % on the root element, because that overrides the user's browser font-size setting and breaks rem at its source. And keep the ratio between your min and max modest; a heading that triples between narrow and wide viewports has a huge fluid band to defend, which makes the rem share hard to keep high.


How to test it

Four checks, in increasing order of how much they catch:

  1. Full-page zoom to 200%. In Chrome, Edge, Safari or Firefox, press Ctrl/Cmd and + until the indicator reads 200%. Text sized correctly roughly doubles. Compare against a paragraph you know is set in plain rem.
  2. Text-only zoom. In Firefox, enable View → Zoom → Zoom Text Only, then zoom. This isolates the failure mode that full-page zoom hides: nothing in the layout changes, so anything anchored to vw does not move at all.
  3. Root font size override. In your browser's appearance settings, set the default font size to a large value. rem-based text follows it; vw-based text ignores it entirely. This is the setting a large number of real users actually change.
  4. Computed value inspection. Select the element in DevTools and read the Computed panel's resolved font-size in pixels at 100% and at 200% zoom. This gives you the actual ratio rather than an impression, and it is the fastest way to audit a whole scale — walk the headings, record both numbers, and flag anything whose ratio is under about 1.8.

Check 1.4.4 together with 1.4.10 Reflow, which requires content to work at a 320 CSS-pixel-wide viewport without two-dimensional scrolling. They interact: fixing 1.4.4 by making text much larger can break 1.4.10 if the layout was not already fluid, and container-driven components help precisely because they respond to the box rather than to a viewport measurement that zoom has already distorted.


Variation: keeping spacing in step

Type that doubles inside a spacing scale that does not ends up cramped — lines nearly touching their containers, headings crowding the paragraphs beneath them. Apply the same split to the space scale so the rhythm scales with the type:

:root {
  --space-s: clamp(0.75rem, 0.7rem + 0.25vw, 1rem);
  --space-m: clamp(1.25rem, 1.1rem + 0.6vw, 2rem);
}

.prose > * + * { margin-block-start: var(--space-m); }

/* Line length must be bounded in a font-relative unit too. A max-width in
   px keeps the measure constant in CSS pixels, so zoomed text wraps at the
   same character count instead of gaining the shorter, easier line the
   user asked for. The ch unit is font-relative and reads well here. */
.prose { max-inline-size: 66ch; }

The ch unit is derived from the font's 0 glyph, so it scales with font-size and therefore with zoom. The full treatment of this pattern is in fluid space scale with clamp(), and the decision of when to use a fluid scale at all rather than discrete steps is weighed in clamp() vs media-query typography.


Browser support

clamp() is universally available — it shipped across all four engines years ago — so no guard is needed for the syntax. The zoom behaviour described here is not a bug or a vendor quirk — it follows from the CSS Values specification's definition of viewport-percentage units against the layout viewport, and every current engine behaves the same way.

Text-only zoom availability does differ. Firefox exposes it directly in the View menu; Chrome and Edge expose a page-level font-size setting rather than a per-page text zoom; Safari's Cmd++ is full-page zoom, with text-only behaviour available through the Accessibility preferences. Mobile operating systems apply their own text scaling on top of all of this. Since none of these mechanisms touch vw, one scale written with a rem term satisfies all of them at once.


FAQ

Why does browser zoom not enlarge text sized in vw units? Zooming shrinks the CSS layout viewport by the zoom factor, so 1vw shrinks by the same factor and then gets scaled back up by it. The two cancel out and the rendered size stays roughly constant while everything around it grows.

How much of the preferred value has to be in rem? Enough that the text still grows substantially under zoom. A practical target is for the rem term to supply at least half the computed size at your most common viewport width, which the AAA guidance for text spacing and resize comfortably accommodates.

Do container query units like cqi have the same zoom problem? Yes. Container units are a percentage of a container measured in CSS pixels, and that container shrinks under zoom exactly as the viewport does. Mix cqi with a rem term the same way you mix vw with one.

Does clamping the maximum in rem fix a vw-only preferred value? Only above the point where the maximum takes over. Between the two bounds the vw term is what is being used, so that whole range still resists zoom. The preferred value itself needs the rem term.

Related articles

More pages in the same section.