# Nunito on every pixel > Typography is part of measurement UI. How Nunito stays consistent across SwiftUI and Compose, and what the OS still controls. 2025-08-18 · 18 min read · design · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/nunito-on-every-pixel.html --- Typography in an instrument app is not decoration; it is part of the measurement surface. When Sonarish displays LAeq trending toward a legal noise threshold, the number itself must dominate the visual field and the chrome around it must recede. When Wheria shows a confidence badge during a garage walk, the label has to be readable at arm's length in dim light, without the user's brain spending cycles parsing inconsistent font weights. uranashel ships five apps across iOS and Android with a single typeface: **Nunito**, loaded as static font instances at weights 400 (Regular), 500 (Medium), 600 (SemiBold), and 700 (Bold). No system font fallback on app-owned surfaces. The only exceptions are OS-native dialogs (TimePicker, permission sheets, share sheets), which the operating system renders in San Francisco or Roboto and which no app can override without private API hacks we will not ship. ## Why a custom font at all Both iOS and Android default to excellent system typefaces, SF Pro and Roboto respectively, and our early prototypes ran on them. Quality was never the complaint. What we wanted was recognizability and control. A user who opens Wheria, then Sonarish, then Phyzix should sense one studio's hand on all three; the system-font prototypes felt like three unrelated apps that forgot to configure branding. Nunito is rounded enough to feel approachable in a consumer context, and its x-height and letter spacing stay readable at 13–15 pt on small screens, which is where instrument labels actually live. We auditioned geometric sans-serifs that look crisp at 24 pt and collapse at 13 pt. Out. We auditioned display fonts that would fight the monochrome design system described in our [monochrome UI post](https://uranashel.com/blog/monochrome-ui.html). Also out. One typeface, five apps, two platforms, two themes each. That consistency builds a quiet trust that the numbers on screen were placed there deliberately. ## The arithmetic of arm's length How small can a label get before a tired driver misreads it? Legibility has actual arithmetic. A lowercase letter of x-height `x` viewed from distance `d` subtends a visual angle `θ = 2·atan(x/2d)`. At 15 pt, one em is 15/72 inch, about 5.3 mm, and Nunito's x-height is roughly 0.53 em, so lowercase letters stand about 2.8 mm tall. Held at 55 cm, a typical arm's length, that gives `θ ≈ 0.29°`, about 17 arcminutes. The comfortable-reading floor quoted in vision literature sits near 0.2°. So 15 pt Nunito clears the floor with roughly 40% margin; 13 pt lands at 15 arcminutes, still fine for secondary labels; 11 pt is marginal. Bench note: 3 phones (an iPhone 13, a Pixel 7, a 2019-era Redmi), one evening on floor B2 near pillar E9, screens at 40% brightness, ambient light 38–45 lux, walking the aisle and reading trial labels set at 11, 13, and 15 pt. Two of the three of us misread the 11 pt labels at least once. Nobody misread 13 or 15. Those became the only secondary label sizes in Wheria. ## The iOS font war SwiftUI makes custom typography deceptively easy and structurally incomplete. Setting `.font(.custom("Nunito-Regular", size: 15))` on individual Text views works until you miss one, and then a stray label renders in SF Pro and the whole screen reads as broken. Our fix lives in AppFont.swift and it is layered. The root view applies `.font(.nunito(.body))` so any Text without an explicit override inherits Nunito at body size. Navigation bar titles are a separate battle. On iOS 16 and later, `UINavigationBar.appearance().titleTextAttributes` is ignored by SwiftUI navigation stacks; the title you see is rendered by SwiftUI's own toolbar system, which defaults to SF Pro semibold at 17 pt. We inject a custom `.nunitoTitle()` modifier as a `ToolbarItem(placement: .principal)` on every navigation screen. Toolbar buttons (Done, Save, Cancel) need their own explicit `.font(.nunito(.subheadline))` or they quietly revert to the system button font. The worst offender is Form and List: SwiftUI Forms default to 17 pt system font for row labels while the body text around them is 15 pt Nunito. The Settings screens in Wheria, Estua, Sonarish, Phyzix, and Stashio all force `.font(.nunito(.subheadline))` on form content. Miss it on one screen and ktuyen files a parity bug within the hour. She has done this four times. We deserved all four. ## The Android font war Jetpack Compose is more honest about typography. MaterialTheme.typography maps every Material text role to a Nunito instance in AppTypography.kt, and a single CompositionLocalProvider wrapping the app tree sets the default text style. The leak points are third-party composables and system dialogs. Some library components internally hardcode TextStyle.Default, which resolves to Roboto; we wrap those screens in an additional ProvideTextStyle as defense. TimePickerDialog, DatePickerDialog, and the permission rationale sheet are system surfaces. Roboto forever, unfixable, and acceptable, because users recognize them as OS chrome rather than app instrumentation. Cross-platform parity adds its own constraint. Android Settings rows have to match iOS Settings rows at 15 pt Medium weight, and Material's default BodyLarge would set them at 16 pt, so AppTypography.kt overrides it. ktuyen checks this side by side on an iPhone and a Pixel during every release pass, the discipline documented in our [cross-platform post](https://uranashel.com/blog/cross-platform-two-codebases.html). ## Static instances and the Vietnamese alphabet Nunito also ships as a variable font, and for about a week during development we used it. The single file is tidy: one 590 KB TTF against four static instances at 168–176 KB each, roughly 690 KB total. We went back to statics for two reasons. Rendering first: Core Text on iOS and Minikin on Android interpolate variable weights slightly differently, and our width probe (next section) measured the interpolated 600 instance 2–3% wider on Android than on iOS at the same size. Static instances cut from the same master agree within 0.3%. Discipline second: with a weight axis available, a weight of 570 will eventually ship because someone nudged a slider. Four fixed weights are a constraint we can enforce in code review by grepping for font constructors. The other reason Nunito survived our shortlist is Vietnamese. Half our interface strings carry diacritics, and stacked marks like the circumflex-plus-tone in ế or the horn-plus-tilde in ữ reach well above the Latin ascender line. A font with pretty Latin glyphs and afterthought Vietnamese marks clips at tight line heights. Before every release we run all localized strings through a render harness at every text style; the current values-vi set holds 1,842 strings, 61 of which open with a stacked mark on the first line, and every one of those 61 has clipped at least once in some layout during development. The fixes are unglamorous. includeFontPadding stays false on Android with an explicit 1.35 line-height multiplier, and fixed-height frames around single-line Text are banned on both platforms. ## Catching leaks with a width probe Manual screenshot review misses font leaks, because SF Pro, Roboto, and Nunito are all competent humanist-leaning sans-serifs, and at 13 pt on a 460 ppi screen the eye forgives a lot. Metrics forgive nothing. The three faces set the same string at measurably different widths: at 15 pt, our probe string renders 4.1% narrower in SF Pro and 2.7% narrower in Roboto than in Nunito. So every app carries a debug-only audit that walks the view tree and measures each text element twice, once with the paint the view actually has and once with the Nunito instance it should have. ``` PROBE = "Illegal1 O0 mịn ữ 12:45" fun auditScreen(root): for t in textNodes(root): wActual = measure(PROBE, t.paint) wExpect = measure(PROBE, nunito(t.weight, t.sizePt)) if abs(wActual - wExpect) / wExpect > 0.005: report(t.id, t.paint.family, t.sizePt) ``` The probe string mixes ambiguous glyphs (Il1, O0), Vietnamese stacked marks, and digits, so it fingerprints the family and the feature settings at once. The 0.5% threshold sits above cross-platform rounding noise and far below the 2.7% Roboto gap. The first run across all five apps reported 11 leaks: 9 Form rows on iOS and 2 toolbar buttons. The audit costs about 40 ms per screen on a Pixel 7, which is why it runs only in debug builds and in ktuyen's release checklist. No leak has shipped since. ## Radius, weight, and hierarchy without color Font weight carries hierarchy in a monochrome app where color cannot. Primary instrument readings use SemiBold or Bold at 17–22 pt; secondary labels and timestamps sit at Regular, 13–15 pt. Disabled or stale data keeps Regular weight and just drops opacity; Nunito Light never ships in the bundle, so weight stays consistent even when luminance falls. Corner radius is 12 px on cards, buttons, and sheets across all five apps. We prototyped 8 px and 16 px and reverted both within a week: 8 looked sharp against Nunito's round terminals, 16 looked like a toy. Nunito at fixed weights, strict monochrome, and uniform radius together are what make a Wheria settings row and a Sonarish settings row feel like the same product, even though one app tracks magnetometer heading and the other tracks A-weighted decibels, a curve we derive in [our A-weighting post](https://uranashel.com/blog/a-weighting-decibels.html). Typography is the glue. We tune it with the same rigor as a Kalman filter constant, and we regression-test it the same way. --- uranashel · [Home](https://uranashel.com/) · [Apps](https://uranashel.com/apps.html) · [Lab](https://uranashel.com/lab.html) · [About](https://uranashel.com/about.html) · [Blog](https://uranashel.com/blog/) · [Developers](https://uranashel.com/developers/) · [API docs](https://uranashel.com/docs/) · [Privacy](https://uranashel.com/privacy.html) Machine-readable: [llms.txt](https://uranashel.com/llms.txt) · [sitemap.xml](https://uranashel.com/sitemap.xml) · [openapi.json](https://uranashel.com/openapi.json) · [API](https://uranashel.com/api/v1/)