DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Every string in our app goes through one component, and iOS is allowed to multiply it by 3.571

Munchable scans a packaged food and tells you whether it fits the gut conditions you manage. The conditions are at munchable.app/conditions, and the app's own answer pages are at munchable.app/answers. The app is React Native through Expo.

A food app has an unusual reader. People use it in a shop, one-handed, often with a basket in the other hand, and a decent share of them have the phone's text size turned up. So the question "what does this screen look like at 300 per cent text" is not a nice-to-have, it is the normal case for a chunk of the audience.

Here is what I found when I measured it.

One component, 272 call sites

Across the app's screens and components there are 272 <Text call sites, and exactly one file imports React Native's own Text:

import { Text as RNText, type TextProps } from 'react-native';
Enter fullscreen mode Exit fullscreen mode

That is src/components/Text.tsx, and everything else imports the wrapper. The wrapper's only real job is to take a variant name and attach the font family, size, line height and colour:

const base = t.typography[variant];
return (
  <RNText
    {...rest}
    style={[{
      fontFamily: base.fontFamily,
      fontSize: base.fontSize,
      lineHeight: base.lineHeight,
      color: color ?? (muted ? t.colors.textMuted : t.colors.text),
    }, /* ... */]}
  />
);
Enter fullscreen mode Exit fullscreen mode

The scale it reads from lives in a workspace package, so the app and the video overlays render from the same numbers rather than from two copies:

export const typeScale = {
  brand:      { fontSize: 24, lineHeight: 30 },
  hero:       { fontSize: 44, lineHeight: 50 },
  display:    { fontSize: 32, lineHeight: 38 },
  h1:         { fontSize: 27, lineHeight: 33 },
  h2:         { fontSize: 21, lineHeight: 27 },
  title:      { fontSize: 17, lineHeight: 23 },
  body:       { fontSize: 16, lineHeight: 23 },
  bodyStrong: { fontSize: 16, lineHeight: 23 },
  label:      { fontSize: 13, lineHeight: 17 },
  caption:    { fontSize: 12, lineHeight: 16 },
} as const;
Enter fullscreen mode Exit fullscreen mode

Ten variants, 12 to 44 points, every one with a hardcoded line height. That last detail is the one that usually goes wrong.

Three things the app never sets

Grep the whole app for the props that would opt out of OS text scaling and you get nothing:

allowFontScaling      0
maxFontSizeMultiplier 0
PixelRatio            0
Enter fullscreen mode Exit fullscreen mode

Those zeroes are the feature. React Native's own types spell out what the absence means:

allowFontScaling: Specifies whether fonts should scale to respect Text Size accessibility settings. The default is true.

maxFontSizeMultiplier: 0: no max, ignore parent/global default. null/undefined (default): inherit from the parent node or the global default (0).

So the default is scaling with no ceiling, and the app has never argued with it. Not one screen, not one badge, not even the camera chrome.

The number is 3.571

How far "no ceiling" goes is not a guess, it is a table inside React Native, in RCTAccessibilityManager.mm:

UIContentSizeCategoryExtraSmall   : @0.823,
UIContentSizeCategorySmall        : @0.882,
UIContentSizeCategoryMedium       : @0.941,
UIContentSizeCategoryLarge        : @1.0,
UIContentSizeCategoryExtraLarge   : @1.118,
UIContentSizeCategoryExtraExtraLarge      : @1.235,
UIContentSizeCategoryExtraExtraExtraLarge : @1.353,
UIContentSizeCategoryAccessibilityMedium  : @1.786,
UIContentSizeCategoryAccessibilityLarge   : @2.143,
UIContentSizeCategoryAccessibilityExtraLarge         : @2.643,
UIContentSizeCategoryAccessibilityExtraExtraLarge    : @3.143,
UIContentSizeCategoryAccessibilityExtraExtraExtraLarge : @3.571
Enter fullscreen mode Exit fullscreen mode

The five accessibility sizes are the ones below the fold in iOS Settings, behind "Larger Accessibility Sizes". At the top of that list our 16 point body text renders at 57 points. The 44 point verdict word renders at 157. The 12 point caption lands at 43, which is larger than the hero was designed to be. Android has the same idea through its own font scale setting, reached separately from display size.

There is no sensible way to design for that. What you can do is make sure the layout bends rather than breaks.

The hardcoded line height does scale

This is the part I expected to be a bug and was not. A design system that sets both fontSize and lineHeight as absolute numbers is relying on the renderer to scale both of them; if it only scaled the font size, every line would overlap at large sizes.

React Native does scale it. From the iOS text layout manager:

if (!isnan(textAttributes.lineHeight)) {
  CGFloat lineHeight = textAttributes.lineHeight * RCTEffectiveFontSizeMultiplierFromTextAttributes(textAttributes);
  paragraphStyle.minimumLineHeight = lineHeight;
  paragraphStyle.maximumLineHeight = lineHeight;
}
Enter fullscreen mode Exit fullscreen mode

The same multiplier, applied to the same attribute set. So body at 3.571 is a 57 point glyph on an 82 point line, and the proportion the designer chose survives. Which means the risk is never the type. It is the box the type sits in.

Five fixed heights, and not one of them holds text

So I went looking for boxes that cannot grow. In the whole app there are five height: declarations with a literal number in them:

  • 48, the round camera buttons.
  • 128, the verdict disc on the result screen.
  • 40, the close button in a modal header.
  • 8, the reward progress bar's track.
  • 1, the divider.

Every one of them contains an icon, a shape or nothing. No fixed height in the app has text inside it. That was not a written-down rule until I measured it, and it is now the rule: a fixed height is for things whose size the OS does not control. Everything a finger has to hit uses minHeight: 44 instead, so the row grows with its content and still meets the tap target floor when the content is small.

The verdict disc is a nice illustration of the division. The disc is 128 by 128 forever, because it is a coloured circle with an icon in it, and the word underneath is a separate Text that is allowed to do whatever the OS says:

{/* Centred and allowed to wrap: "Can't assess" is a wide word at 44px and
    the smallest phones have about 270pt to give it. Truncating the answer
    would be the one thing worse than letting it take two lines. */}
<Text variant="hero" color={c.fill} center>
  {label}
</Text>
Enter fullscreen mode Exit fullscreen mode

The one prop that pairs two styles

There is one place where text must not grow sideways: the value half of a label and value row. For that the wrapper has a single nowrap prop, used at 17 call sites, and the comment on it is the whole argument for making it one prop instead of two styles:

/**
 * One line, and never squeezed by a flex sibling. For the value side of a
 * label/value row: a verdict word, a status, an amount.
 *
 * The two halves are only correct together, which is why this is one prop and
 * not two. `numberOfLines={1}` on its own truncates the value to "Cauti…" as
 * soon as a long product name pushes on it, and `flexShrink: 0` on its own
 * lets the value wrap and blow out the row height.
 */
Enter fullscreen mode Exit fullscreen mode

numberOfLines={1} alone and flexShrink: 0 alone are both wrong, in opposite directions, and both failure modes look like a styling nit rather than a lost answer. Pairing them in a named prop means the eighth call site cannot ship with one half of the pair.

What still truncates, honestly

Eighteen call sites set numberOfLines explicitly, and those are the places where a very large text size costs the reader a word. The sharpest one is the screen title:

<Text variant="h1" numberOfLines={1}>
Enter fullscreen mode Exit fullscreen mode

27 points at 3.571 is a 96 point line, and no phone is wide enough to hold a two-word screen title at that size. It ellipsises. The others are product names in lists, where the row is deliberately one line each so the list stays scannable, and the full name is on the screen the row opens.

I am comfortable with those and not with the title. The fix is not a smaller font, it is letting the header wrap to two lines and making the one thing below it that cares about header height read the measured height instead of assuming one line. That is on the list.

What I have not built is a test for any of this. There is no screenshot run at 3.571, so the guarantee is only the rule about fixed heights, which a reviewer has to keep in their head. The checkable version would be a lint rule: a literal height: in the same style object as a Text child is a bug. That is a day of work I have not spent yet, and writing this measured it clearly enough that it is worth spending.

See it at your own text size

The app's screens are demonstrated on the landing page at munchable.app, and the web half of the product uses the same scale: turn your browser text size up on munchable.app/recipes or any of the single-question pages like munchable.app/hidden-gluten-ingredients and the reflow is the same idea in CSS.

If you ship a React Native app, the two greps are worth running on your own codebase: allowFontScaling and height: [0-9]. The first tells you whether you ever opted a screen out of a system setting, and the second tells you which boxes will not let the result fit.

Top comments (0)