About me

Selected work

Selected writing

Experience

Skills

Education

Archive

Breakpoints measured in characters, not pixels

Technical note · 2026

TL;DR: how much text a display can hold — characters across, lines down — is what determines whether a text-heavy layout still works, because that capacity is what correlates with real, readable information density. Pixel width and height don't directly measure that; they're just the substrate text happens to sit on. We get to characters-and-lines-that-fit by first measuring an average glyph, then converting that into characters per width and lines per height.

I designed this at Nesta for Svizzle, Nesta’s open-source Svelte component library, and it shipped across several of Nesta’s data products — WAIfinder (a public-sector service finder) is the one I’ll use for concrete examples below. The component landed under the name ScreenSensor; earlier in its life it was called ScreenGauge.

The problem it solves is ordinary and easy to underestimate: font size can change — from an OS-level text-size setting, a browser’s default or zoom level, an accessibility control, or simply a design decision to use a more condensed or wider typeface — without the browser’s pixel viewport moving by a single pixel. Any of those can break a layout, responsive or not, because how much text actually fits just changed while every pixel measurement stayed exactly the same. If your breakpoints are keyed off window.innerWidth, none of that shows up in the number you’re switching on — the viewport didn’t change, only the content’s appetite for space did. Pixel-based breakpoints answer “how wide is the window,” when the question that actually matters is “how much text can I fit in it.”

How many characters fit — glyph width is just how you get there

The mechanism is small enough to describe in one paragraph. A hidden sample string — the alphabet, roughly, rendered with visibility: hidden and no layout width constraint — sits in the DOM and gets measured with a ResizeObserver whenever the font it’s set in changes. From that:

glyph.width  = sampleWidth / sampleLength
glyph.height = blockSize
maxChars     = floor(displayWidth / glyph.width)
maxLines     = floor(displayHeight / glyph.height)

maxChars — how many characters fit across the display — is the useful criterion for the responsive decision; glyph width is just the intermediate measurement that gets you there. Breakpoints are then just thresholds on maxChars — Svizzle’s defaults are [45, 90, 135, 180], mapping to xSmall/small/medium/large/xLarge size classes. Everything downstream — which layout variant mounts, which grid areas exist, whether a settings panel is a sidebar or a drawer — reads off that classification, exposed as a Svelte store ($_screen) any component in the tree can subscribe to.

maxLines — how many lines fit vertically — is measured right alongside maxChars, from the same glyph metrics applied to display height instead of width. It’s real, not a hypothetical extension I’m proposing for later. What it isn’t, yet, is part of the sizes breakpoint decision above, which reads maxChars alone — more on that gap further down.

The measured glyph.width is an average, not the measured width of whatever text is actually on screen — it’s an approximation, and a deliberate one: measuring every real string in the tree on every resize would be its own performance problem, and “close enough, consistently” is what a breakpoint threshold actually needs. Worth stating plainly rather than letting the mechanism sound more precise than it is.

One small documentation wrinkle, because docs drift too: the current ScreenSensor README still mentions a fontSize prop, while the changelog and the source say it was removed. The implementation now measures the hidden sample at its own 1rem size. Even the docs for code about measuring things need the occasional measurement.

A decade of thinking about the wrong unit

This way of thinking about responsive design — as a “how much fits” problem rather than a “how wide is the screen” problem — had been sitting in the back of my head for the better part of a decade before Nesta. An early, much smaller sign of the same itch: vminpoly, a small polyfill I wrote years earlier for CSS3’s viewport-relative distance units, back when browser support for them was still patchy (still linked from this site’s own archive). Different problem, same underlying suspicion — that the pixel grid isn’t always the right unit to hang a layout decision on.

Nesta was simply the right environment to build the real version of it: a public-sector accessibility requirement, several Svelte products that needed to share the same responsive logic, and Luca Bonavita as the right engineer to design and build it together with.

Sequencing it anyway, to avoid a visible jump

None of this is fragile the way a naive one-shot measurement might be — because the whole thing is Svelte-reactive, ScreenSensor recomputes maxChars/maxLines cleanly any time the measured glyph actually changes, whether that’s a resize, a font swap, or an accessibility setting taking effect. There’s no stale-state risk to design around.

What’s still worth sequencing deliberately is the first measurement: waiting on a font loader (FontsLoader, built on the FontFace API) to report isFirstLoaded before trusting that first read. The reason isn’t correctness — reactivity would eventually catch up regardless — it’s polish: measuring against a fallback font and then visibly reflowing a moment later, once the real font arrives, is the kind of jump a user notices even if they can’t say why it bothered them. Fonts load, then the first real measurement happens, then layout decisions get made — not because skipping the order breaks anything permanently, but because skipping it means shipping a flash that didn’t need to happen.

The criterion pixels were standing in for

Here’s the point, stripped of any one specific cause: maxChars (and, where vertical capacity matters, maxLines) is a more direct criterion for whether a text-heavy layout still works, regardless of why the font ended up the size it is — an OS-level text-size setting, a browser default or zoom level, an accessibility control, a user’s own font preference, or just a more condensed or wider typeface than whatever the design was built against — and regardless of what the viewport’s pixel dimensions happen to be. Pixel width is, at best, an indirect proxy for the thing the layout depends on; character count measures it directly.

It’s worth being precise about what’s not the interesting claim here: “a layout can change without the window being resized” isn’t new or unique to this approach — a media query written in em/rem already responds to a root font-size change with no resize event involved. So the useful comparison isn’t “text-based breakpoints react to more things than pixel-based ones” — they don’t, necessarily. It’s that a pixel-based breakpoint is answering the wrong question to begin with, and gets the right layout by coincidence when pixels and “how much fits” happen to move together, and the wrong one whenever any of the causes above pull them apart. A character-count breakpoint has less of that coincidence problem, because it’s measuring the text constraint instead of a proxy for it.

Where this doesn’t reach yet

A few limitations, stated plainly rather than left for someone else to discover:

The measurement is viewport-wide (window.innerWidth/innerHeight), not per-container. Every consumer of $_screen in an app shares one global size classification. That’s fine for a page-level layout decision (which WAIfinder mostly needs) and wrong for, say, a dashboard with several independently-sized panels that each want their own breakpoint. Making the measurement modular per-container rather than global is the clearest piece of unfinished work here, and one I’d like to pick up again.

Second, and this one’s genuinely unresolved rather than just unbuilt: what do you do when both the text and an image in the same layout carry meaningful information, and they disagree about how much room they need? Character-count breakpoints have nothing useful to say about an image’s information density. I don’t have a good answer yet — it’s an open design question, not a documented gap I inherited from somewhere else.

A third, more pointed one, because it cuts against this article’s own argument: the closest thing this system computes to an “aspect ratio” — orientations.landscape/portrait — is derived from plain pixel dimensions (window.innerWidth / window.innerHeight), not from maxChars/maxLines, even though both are already being measured a few lines away in the same function. So the same $_screen store can tell one part of an app “this is a small/medium/large text-fit” while telling another part “this is portrait/landscape” from a completely different, pixel-based measurement — and the two aren’t guaranteed to agree. A display could plausibly read “portrait” by pixel aspect ratio while comfortably fitting more characters across than lines down, depending on glyph shape — exactly the kind of disagreement the rest of this piece argues shouldn’t be able to happen. The fix is straightforward, a text-based aspect ratio (maxChars / maxLines) compared against the pixel one instead of orientation standing alone — I just haven’t done it yet.

A separate, unstarted avenue rather than a bug: none of this accounts for reading order by language. Right-to-left scripts, vertical text, and CJK line-breaking conventions all change what “how much fits” should even mean before you get anywhere near a breakpoint decision, and the current system doesn’t know any of that exists. A legitimate direction for the idea to grow into, not a gap I’ve tried and failed to close.

Measure what fits, not how big the screen is

Characters-per-display — not pixels-of-display — is the unit that decides whether a text-heavy layout still works, no matter what changed the font size or the typeface along the way. Measuring glyph width is just how you get there.

Next up: the accessibility menu this component was built to work with, and the specific way WAIfinder composed FontsLoader, ScreenSensor, and A11yMenu so typography, layout, and controls could react in the right order.

If you’re building something similar and want the actual Svelte source (the hidden sample-text component, the ResizeObserver action, the store shape) rather than just the description above, ask — happy to walk through it in more detail.

— Seb · get in touchferreyrapons.com

← Selected writing Home Résumé