Breakpoints measured in characters, not pixels
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 is already better than one written in pixels, because it can move with the browser’s idea of text size rather than pretending pixels are the whole story.
But rem in a media query still does not mean “the width of the typeface this application is rendering with.” It does not get wider because the selected font is wide, narrower because the selected font is condensed, or larger because an accessibility menu changed the app’s body font size. It is still mostly asking a viewport question, just in a less crude unit. ScreenSensor asks a different question: after the font choice, font loading, and accessibility settings have all had their say, how many rendered characters and lines fit here?
That distinction also changes what the rest of the app can know. A media query can switch CSS; $_screen gives components a measured model they can subscribe to. That’s why the accessibility menu can choose a stepper when horizontal text capacity is tight and radio buttons when there is room — it is reading the same measured constraint the page layout is using, not maintaining a second private guess.
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. A future ContainerSensor would not just be a tidier version of the same idea; it would enable a kind of responsiveness plain viewport media queries cannot express at all: two cards on the same display, under the same global viewport, choosing different layouts because one has room for 80 rendered characters per line and the other has room for 32. CSS container queries get closer by making layout local to the container’s dimensions, which is useful, but the question is still “how big is this box?” rather than “how much of this typography fits inside it?”
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