---
title: "Text resizing and zoom"
description: "The 200% text check and the 400% reflow check are different tests that fail in different ways. It also says why a truncated number is a safety defect."
url: "https://opsinjs.pensievelabs.org/accessibility/text-resizing-and-zoom"
source: "https://opsinjs.pensievelabs.org/accessibility/text-resizing-and-zoom.md"
section: "Accessibility"
kind: "accessibility"
reviewed: "2026-09-02"
reviewer: "design"
aliases: ["200 percent", "400 percent reflow", "1.4.4", "1.4.10"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="accessibility" />

## What we guarantee [#what-we-guarantee]

Two separate things, which are routinely confused:

**Text resizing (SC 1.4.4).** Text can be scaled to 200% without loss of content
or functionality. In this system that is real: the product theme has no fixed
pixel type sizes at the root, sets `-webkit-text-size-adjust: 100%` so mobile
browsers do not silently rescale, and the preview harness exposes it as a genuine
root font-size change. `app/product.css` does that with
`[data-text-size="200"] { font-size: 200% }` rather than a `transform: scale()`.
That distinction matters: a transform makes everything bigger and proves nothing,
whereas a root font-size change makes text bigger *inside a layout that did not
grow*, which is the condition that actually breaks things.

**Reflow (SC 1.4.10).** Content reflows to a 320 CSS pixel equivalent width
without two-dimensional scrolling. In practice this is tested as 400% browser
zoom at a 1280 px viewport. The layout system is built for it: the spacing scale
derives from a single `--spacing` custom property, containers are
percentage-and-`ch` based rather than fixed, and the responsive-mode rules on
[Responsive modes](../foundations/space/responsive-modes.mdx) treat narrow as a
first-class mode rather than a degradation.

**And one health-specific guarantee: a numeric value must never truncate.** A
component may wrap, stack, abbreviate its label or drop its decoration, but the
number and its unit are the payload. A `RangeBar` that clips "138" to "13" at
200% text is not a layout bug; it is a screen that displays a false reading. Every
component specification in the roster carries this as an accessibility
requirement.

<SafetyCallout severity="urgent" evidence="opinion">
  Truncation of a health value is treated in this system as a defect of the same
  class as displaying the wrong value, because to the reader it is
  indistinguishable from one. If you take one rule from this page, take that one.
</SafetyCallout>

## What you own [#what-you-own]

**Fixed heights.** The single most common cause of a 200% failure is a container
with a height in pixels. At 200% the text inside it is twice as tall and either
overflows or is clipped. Use minimum heights and let content set the rest.

**`overflow: hidden` used as a tidiness tool.** It converts an overflow bug into
an invisible data-loss bug, which is worse. If content must be clipped, it must
also be reachable another way.

**Single-line assumptions.** Buttons, chips, table headers and status pills are
usually designed at one line. At 200%, in German, with a long clinical term, they
are not. Decide now whether they wrap or truncate. For anything carrying a value,
the answer is wrap.

**Viewport meta.** `user-scalable=no` and `maximum-scale=1` disable pinch zoom and
are an outright failure. The docs site does not set them; check that your product
does not either.

**Text spacing (SC 1.4.12).** Readers apply user stylesheets that increase line
height, letter spacing and word spacing. Layouts that assume a fixed line height
break under this and it is not covered by the two checks above.

## How to check [#how-to-check]

<Steps>
  ### The 200% text check [#the-200-text-check]

  In the preview harness, switch text size to 200% with the text-size control on
  any `<ComponentPreview>`, which stamps `data-text-size` on the embedded `/view`
  document. In a product, use the operating system: iOS Settings → Accessibility
  → Display and Text Size → Larger Text at maximum, or the Android font-size
  slider, or a browser minimum-font-size setting.

  **Expected result:** everything reflows, nothing is clipped, no number is
  truncated, and every interactive element is still reachable. **Failure looks
  like** a value with an ellipsis in it, a button whose label has vanished, or a
  sticky footer that has eaten the bottom third of the screen.

  ### The 400% zoom check [#the-400-zoom-check]

  Set the browser window to 1280 px wide and zoom to 400%. &#x2A;*Expected result:** no
  horizontal scrollbar for the page as a whole; content is in a single column;
  tables scroll horizontally *within their own container* rather than making the
  page scroll. **Failure looks like** a two-dimensional scroll, where the reader has
  to move right to read the end of a sentence and then left again for the next one.

  ### The both-at-once check [#the-both-at-once-check]

  Do them together. This is not required by either criterion and it is where health
  dashboards actually fall over, because a card grid that survives each test
  separately can still produce a 4 px-wide column when both apply.

  ### The Dynamic Type check on a real device [#the-dynamic-type-check-on-a-real-device]

  Simulated font scaling in a browser is not the same as a platform text-size
  setting, which also changes system control metrics. Test on hardware if the
  product ships to phones. The type system's side of this is on
  [Dynamic Type](../foundations/typography/dynamic-type.mdx).
</Steps>

## Measured results [#measured-results]

<NoDataYet script="scripts/capture-registry.mts" />

The intended generated artefact is a captured render of every component at 100%
and 200% text in both themes. The check underneath it exists and runs:
`scripts/check-a11y.mts --layout` loads every built `/view` route at 390x844 at
1x, again with the root font size at 1.3x, and again through the route's own
`?text=200`, and it fails when an element's `scrollWidth` exceeds its
`clientWidth`. Any `[data-opsinjs-value]` that truncates gets a separate, louder
failure, because a missing digit is the failure this page is about. The check
runs in `.github/workflows/nightly.yml`, the one job that installs Playwright;
Playwright is still in no `package.json`. What is missing is the captured render:
the check reports what failed and keeps no picture of what passed.

## Known gaps [#known-gaps]

* **No 200% result is published.** The check exists and has real routes to point
  at. Every implemented component renders at `/view`, and
  `scripts/check-a11y.mts --layout` loads them through `?text=200`. The check
  nevertheless needs Playwright, which is deliberately in no `package.json`, so
  it runs only in the nightly workflow and nothing on this site reports what a
  run found. The mechanism is verifiable in `app/product.css`; the result is not.
* **Text spacing (SC 1.4.12) is not addressed anywhere in this system.** Line
  height is a token; the behaviour of the layout when a user stylesheet
  overrides it is untested and unspecified.
* **No long-string or pseudo-localisation fixtures exist,** so the wrapping
  behaviour of labels is unproven.

## Updates to this page [#updates-to-this-page]

<Reviewed />
