Large Text Is a Layout Breakpoint, Not a Font-Size Setting

What a CRLO accessibility pass taught me about treating system font scale as a responsive input, hardening shared primitives, and recomposing dense mobile layouts.

projects · mobile · expo · react-native · accessibility · architecture

A mobile app can respect the system text-size setting and still be difficult to use.

The font gets larger, but the button does not. A two-line title becomes three lines inside a two-line container. Metadata competes with a status label in a horizontal row. The app has technically enabled text scaling while its layout quietly hides the result.

I ran into this while improving CRLO, the shared vehicle logbook I am building with Expo and React Native. The accessibility pass was not a one-prop change. One commit touched 31 files: shared text, buttons, inputs, record rows, dashboards, settings, documents, and detail screens.

The lesson was straightforward:

Large text is a responsive layout state. Supporting it means changing structure, not only changing typography.

Text scaling exposes layout assumptions

React Native’s Text allows font scaling by default. It also exposes dynamicTypeRamp on iOS so an element can follow a semantic Dynamic Type style such as body, headline, or title1.

Those APIs solve part of the problem: they let text respond to the user’s setting. They cannot decide what the surrounding interface should do when that text needs twice the space.

The failures usually live in layout decisions that looked harmless at the default size:

  • a fixed-height button,
  • a title and badge forced into one row,
  • a record name limited to two lines,
  • metadata and an action aligned at opposite ends,
  • a compact date block with fixed width and height.

At larger sizes, those constraints stop being visual preferences. They decide which information remains available.

CRLO therefore treats the current font scale in the same way a web interface might treat viewport width: as an input to composition.

import { useWindowDimensions } from 'react-native';

const LARGE_TEXT_SCALE = 1.3;

export function useAccessibilityLayout() {
  const { fontScale } = useWindowDimensions();

  return {
    fontScale,
    isLargeText: fontScale >= LARGE_TEXT_SCALE,
  };
}

useWindowDimensions updates when the window’s values change, including fontScale. The 1.3 threshold is a CRLO design choice based on where its dense layouts need to recompose. It is not a React Native recommendation or a universal accessibility standard.

That distinction matters. A breakpoint should describe where a particular layout stops working, not pretend to be a compliance rule.

Fix primitives before auditing screens

The highest-leverage changes were in the components used everywhere.

CRLO’s shared ThemedText now maps its semantic variants to Dynamic Type ramps. Body copy uses body, semibold text uses headline, and titles use title ramps. Fixed line heights were removed from title styles because a hard-coded line box can fight the space required by scaled glyphs.

Buttons and inputs had a similar problem. Their size variants used fixed height values. That made their default appearance predictable, but it also made the container unwilling to grow.

The replacement keeps a minimum target while allowing content to determine the final height:

const mediumButton = {
  minHeight: 44,
  paddingHorizontal: 16,
  paddingVertical: 10,
};

The label can shrink within the flex row, wrap, and increase the control’s height. The minimum preserves the compact state; vertical padding preserves breathing room when the label grows.

This is a useful design-system rule:

Use minHeight to protect the target. Use padding and content flow to protect the label.

Fixing primitives does not complete the audit, but it removes the same failure from every screen that consumes them. It also makes remaining failures easier to see: they are local composition problems rather than repeated flaws in the component library.

Dense rows need a different composition

CRLO has information-dense rows by design. A reminder can include a vehicle, status, date, type, mileage, and note. At the default font scale, horizontal grouping makes that information scannable. Preserving the same arrangement at large text sizes makes each element compete for a narrow strip of width.

The large-text layout changes the hierarchy:

  1. the outer row becomes a column,
  2. vehicle and status metadata stack,
  3. the fixed date tile becomes a full-width horizontal block,
  4. title, metadata, and notes remove their line limits.

React Native documents numberOfLines as a truncation constraint. Removing that constraint at the breakpoint is therefore deliberate: the layout gives the text more space instead of scaling it and then hiding it.

The same pattern appears on CRLO’s list-detail screen. Overview label/value pairs move from opposite ends of a row into a vertical stack. A wrapping action grid becomes a single column. Action labels can use as many lines as they need. Separators and alignment that communicated relationships in the compact layout are adjusted when those relationships become vertical.

This is not “make every row a column.” Compact layouts still matter. It is a controlled recomposition at the point where the original grouping no longer preserves meaning.

Accessibility stays in the component contract

Visual reflow is only one part of the implementation. A custom button still needs to expose that it is a button, and it needs to communicate disabled or busy state to assistive technology.

CRLO’s shared button now carries accessibilityRole="button" and an accessibilityState derived from its loading and disabled props. Keeping that logic in the primitive is more reliable than expecting every caller to remember it.

That is the same architectural idea as the layout work:

  • put semantic typography in the text primitive,
  • put interaction semantics in the control primitive,
  • put the font-scale signal in one hook,
  • keep screen-specific recomposition close to the screen.

Centralize the invariant, not every layout decision.

What the source change does not prove

This work provides a concrete implementation pattern, not a certificate.

The source shows that CRLO responds to font scale, removes several fixed constraints, relaxes truncation, and preserves control semantics across a broad set of screens. It does not prove WCAG conformance. It does not prove that every translation, device width, platform text setting, or screen-reader flow has been validated on physical devices.

Those require a separate test matrix and real-device evidence. I would rather state that boundary than turn “we added a breakpoint” into “the app is fully accessible.”

The audit sequence I would reuse

For another React Native app, I would repeat the work in this order:

  1. Observe the system setting. Expose fontScale through one small shared hook.
  2. Harden text. Map semantic variants to platform text styles and remove fixed line heights that block scaling.
  3. Harden controls. Replace fixed heights with minimums and vertical padding; let labels wrap.
  4. Remove accidental hiding. Audit numberOfLines, ellipsizing, and fixed metadata widths.
  5. Recompose dense screens. Stack rows, move secondary information, and revisit separators at a project-specific breakpoint.
  6. Preserve semantics. Verify roles, disabled state, busy state, labels, and focus behavior independently of the visual layout.
  7. Validate beyond source. Test supported platforms, device widths, translations, and assistive technologies before making compliance claims.

The order is important. If every screen invents its own font-scale behavior before the primitives are fixed, the codebase gains many local patches and no reliable foundation.

Large text does not merely enlarge a finished interface. It reveals whether the interface was allowed to adapt in the first place.