SF Pro: Variable Axes, Optical Sizing, And The Dynamic Type Contract

SF Pro, Apple’s system font since iOS 9 / OS X El Capitan in 2015, is a variable font with three axes (weight, width, optical size), a designed family that includes a sans (SF Pro), a rounded variant (SF Pro Rounded), a monospace (SF Mono), a compact variant (SF Compact, used on watchOS), and a serif companion (New York)1. The font is engineered around the Dynamic Type contract: SwiftUI’s eleven text styles (.largeTitle, .title, .body, .callout, .footnote, etc.) all use SF Pro by default, and they all scale automatically when the user changes their preferred text size in Settings.

Most apps use one or two of those eleven styles, ignore Dynamic Type beyond the default behavior, and never touch the variable axes. The result is typography that works but doesn’t speak the platform’s vocabulary. The post walks the system font family, the variable axes, the eleven semantic styles, the Dynamic Type contract, and the cases where custom fonts need explicit scaling work to participate.

TL;DR

  • SF Pro Variable exposes three axes: weight (wght), width (wdth), and optical size (opsz)2. Optical sizing is automatic based on point size; weight and width are addressable through SwiftUI’s Font.Weight and Font.Width.
  • The system family includes SF Pro (default), SF Pro Rounded (friendly UI elements), SF Mono (code, technical UI), SF Compact (watchOS, narrow contexts), and New York (serif companion for editorial reading).
  • SwiftUI’s eleven text styles automatically support Dynamic Type. .body is the safe default; .largeTitle through .caption2 cover the platform’s hierarchy.
  • Custom fonts do not scale with Dynamic Type by default. Use Font.custom("Name", size: 16, relativeTo: .body) to opt the font into Dynamic Type scaling3.
  • @Environment(\.dynamicTypeSize) lets a view adapt layout to the current text size; the value range goes from .xSmall to .accessibility5 (12 sizes total)4.

The System Font Family

Apple ships five families that all participate in the system font experience.

SF Pro

The default for every Apple platform. iPhone, iPad, Mac, Vision use SF Pro for body text, headlines, and most UI. The font has wide language coverage (Latin, Greek, Cyrillic, Arabic, Hebrew, Devanagari, etc.), supports right-to-left layout natively, and includes designed variants for small caps, alternate digits (lining vs. tabular), and stylistic alternates.

Access in SwiftUI through the system font:

Text("Hello").font(.system(.body))                    // SF Pro by default
Text("Hello").font(.system(.body, weight: .semibold)) // SF Pro Semibold
Text("Hello").font(.system(.body, design: .default))  // explicit SF Pro

SF Pro Rounded

A rounded variant designed for friendly, approachable UI: Apple Watch complications, Fitness rings, parts of Maps directional callouts, Find My, and selected Health surfaces. The rounded forms communicate softness; use them for tone, not just for visual variety.

Text("Hello").font(.system(.body, design: .rounded))

SF Mono

Monospace family for code, terminal UI, and any context where character cell alignment matters. SF Mono is the font in Xcode by default, in the Terminal app’s monospace setting, and in any SwiftUI view that requests .monospaced.

Text("let x = 42").font(.system(.body, design: .monospaced))

SF Compact

A narrower family used on watchOS, on tvOS focus highlights, and in some Mac contexts where horizontal space is constrained. The narrower forms preserve x-height while reducing horizontal advance, which makes them work in Apple Watch face dimensions where SF Pro would feel cramped.

watchOS apps automatically use SF Compact through the system font; iOS apps generally don’t need to request it explicitly.

New York

A serif family designed as a companion for editorial reading. New York shows up in Books for long-form text, in Notes for handwriting-style notes, and in SwiftUI through the .serif design.

Text("Long-form essay").font(.system(.body, design: .serif))

The serif companion is rare in app UI. Reach for it deliberately (a reading mode, a quoted passage, an article body) rather than as a default.

The Three Variable Axes

SF Pro Variable encodes three axes that combine to produce every glyph:

Weight (wght)

Nine named weights: .ultraLight, .thin, .light, .regular, .medium, .semibold, .bold, .heavy, .black. The variable font interpolates continuously between them, but SwiftUI’s API exposes the named values.

Text("Heading").font(.system(.title, weight: .semibold))

Weight communicates emphasis hierarchy: .regular for body, .semibold or .bold for headlines, .medium for active toolbar items, .light for de-emphasized labels. The semantic styles (.headline, .subheadline) ship with sensible weight defaults; reach for explicit weights only when the semantic style isn’t the right shape.

Width (wdth)

Four named widths in the iOS 16+ API: .compressed, .condensed, .standard, .expanded. The width axis affects horizontal advance without changing weight or visual character. Use it for tight UI (a navigation bar with many items) or for visual texture (a display-size headline that wants more horizontal presence).

Width is applied through SwiftUI’s .fontWidth(_:) view modifier (or chained on a Font via .width(_:)):

Text("Compressed")
    .font(.system(.title, weight: .bold))
    .fontWidth(.compressed)

Width is rarely the right axis for body text; it works well in display sizes where the typography is part of the design.

Optical Size (opsz)

The technically ambitious axis. SF Pro’s old SF Text and SF Display variants are now a continuous gradient encoded in the variable font. At sizes below 20 points, the system applies the SF Text optical adjustments (wider letter-spacing, slightly heavier strokes, more open counters). Above 20 points, SF Display takes over (tighter spacing, refined proportions). The transition is smooth.

The opsz axis is automatic. Apps don’t address it explicitly; the system reads the requested point size and resolves to the right optical sizing. The implication: typography at small UI sizes has different proportions than the same font at headline sizes, and that’s by design. Custom fonts that lack optical sizing look fine at one size and wrong at others; SF Pro’s automatic handling avoids that trap entirely.

The Eleven Text Styles

SwiftUI’s Font.TextStyle defines eleven semantic styles that all participate in Dynamic Type4:

Style Default size (Large) Common use
.largeTitle 34 pt Top-level headers, hero text
.title 28 pt Section headers
.title2 22 pt Subsection headers
.title3 20 pt Minor headings
.headline 17 pt Emphasized body, list-row titles
.body 17 pt Default body text
.callout 16 pt Supporting body, captions in context
.subheadline 15 pt Secondary headers, meta text
.footnote 13 pt Small supporting text
.caption 12 pt Image captions, fine print
.caption2 11 pt Minimum readable text

The “Default size” column shows the size at the user’s “Large” Dynamic Type setting (the system default). Each style scales up or down as the user adjusts their preferred text size; the relative hierarchy stays intact.

The right adoption move is to use the semantic styles directly:

VStack(alignment: .leading) {
    Text("Title").font(.title)
    Text("Subtitle").font(.subheadline).foregroundStyle(.secondary)
    Text("Body content here.").font(.body)
}

The hierarchy is preserved at every Dynamic Type setting, the Apple HIG conventions are honored, and the typography responds to the user’s accessibility preferences without per-app code.

The Dynamic Type Contract

Dynamic Type is the user-controlled text-size setting in Settings > Accessibility > Display & Text Size > Larger Text. The value flows through the environment as DynamicTypeSize, with twelve values from .xSmall to .accessibility54:

  • Standard sizes: .xSmall, .small, .medium, .large (default), .xLarge, .xxLarge, .xxxLarge
  • Accessibility sizes: .accessibility1, .accessibility2, .accessibility3, .accessibility4, .accessibility5

Apps that use the semantic text styles get the scaling for free. Apps that want to adapt layout to the current size read the environment:

struct AdaptiveLayout: View {
    @Environment(\.dynamicTypeSize) var dynamicTypeSize

    var body: some View {
        if dynamicTypeSize.isAccessibilitySize {
            VStack { content }    // stack vertically at accessibility sizes
        } else {
            HStack { content }    // horizontal at standard sizes
        }
    }
}

The isAccessibilitySize property covers the five accessibility sizes (where text is large enough that horizontal layouts often break). The pattern is the right one for any layout that depends on text fitting horizontally.

Apps can constrain the supported range with the .dynamicTypeSize(_:) modifier:

ContentView()
    .dynamicTypeSize(.large ... .accessibility3)

The constraint clips the Dynamic Type setting for the modified subtree. Use it when a view’s layout genuinely cannot accommodate the full range; the right default is to support every size and adapt the layout instead.

Custom Fonts and Dynamic Type

Custom fonts (a brand font shipped through Info.plist’s UIAppFonts array) do not scale with Dynamic Type by default. The simplest fix is Font.custom’s relativeTo: parameter:

// Doesn't scale with Dynamic Type
Text("Brand").font(.custom("MyBrandFont", size: 16))

// Scales with Dynamic Type relative to body
Text("Brand").font(.custom("MyBrandFont", size: 16, relativeTo: .body))

The relativeTo: parameter tells SwiftUI to scale the custom font using the body style’s scaling curve. The font size at the user’s “Large” setting is the requested 16pt; at larger settings, SwiftUI applies the same multiplier the body style would use.

For more sophisticated scaling (different curves at different sizes, custom optical handling), use UIKit’s UIFontMetrics directly. The pattern is more verbose but supports per-size adjustments custom fonts often need.

When Typography Fails

Three failure modes worth naming:

Fixed-size custom fonts everywhere. The most common iOS app accessibility failure: a brand font shipped with Font.custom("BrandFont", size: 16) (no relativeTo:) ignores Dynamic Type entirely. Users with accessibility text sizes see brand text at 16pt while system text scales to 28pt+; the visual hierarchy inverts. The fix is relativeTo: on every custom font usage, audited via the AccessibilityInspector at maximum Dynamic Type setting.

Hardcoded weights for emphasis. A subtitle styled .font(.body).fontWeight(.bold) is fragile: at accessibility sizes, the bold body becomes nearly indistinguishable from a body that’s already large. The semantic .headline style handles emphasis correctly across the Dynamic Type range; use it instead of body+bold.

Layouts that break at accessibility sizes. A horizontal stack of text + icon + text that overflows at .accessibility3 is a layout bug Dynamic Type exposes. The fix is the dynamicTypeSize.isAccessibilitySize adaptive layout pattern above; the test is running the app at maximum Dynamic Type during QA, not just the default size.

What This Pattern Means For iOS 26+ Apps

Three takeaways.

  1. Use the semantic text styles, not hand-tuned point sizes. .body, .headline, .title2, etc. carry Dynamic Type, optical sizing, and platform-correct hierarchy. Hand-tuned Font.system(size: 17) defeats every system feature and ages badly when Apple adjusts the default ramp.

  2. Always pass relativeTo: on custom fonts. A brand font shipped with Font.custom(_, size: _, relativeTo: .body) participates in Dynamic Type. A brand font shipped without it is a per-user accessibility regression that QA will catch only at maximum text size.

  3. Test layouts at accessibility Dynamic Type sizes. The .accessibility3 setting is roughly 2x the Large default. Layouts that look fine at standard sizes routinely break at accessibility sizes. The fix is layout-level adaptation through the dynamicTypeSize environment, not opting out via .dynamicTypeSize(...) constraints.

The full Apple Ecosystem cluster: typed App Intents; MCP servers; the routing question; Foundation Models; the runtime vs tooling LLM distinction; three surfaces; the single source of truth pattern; Two MCP Servers; hooks for Apple development; Live Activities; the watchOS runtime; SwiftUI internals; RealityKit’s spatial mental model; SwiftData schema discipline; Liquid Glass patterns; multi-platform shipping; the platform matrix; Vision framework; Symbol Effects; Core ML inference; Writing Tools API; Swift Testing; Privacy Manifest; Accessibility as platform; what I refuse to write about. The hub is at the Apple Ecosystem Series. For broader iOS-with-AI-agents context, see the iOS Agent Development guide.

FAQ

What’s the difference between SF Pro and SF Pro Display / SF Pro Text?

From iOS 9 (when SF first shipped as the system font) through iOS 16, SF shipped as two separate fonts: SF Text for sizes below 20pt and SF Display for sizes 20pt and above, each with hand-tuned letter spacing and stroke weights for its size range. SF Pro Variable consolidates the same Text/Display split into a continuous optical-size axis (opsz). The two fonts are no longer separate; the variable font handles the transition automatically based on the requested point size.

How do I get monospace digits in body text?

Use .monospacedDigit() in SwiftUI:

Text("\(score)").font(.body).monospacedDigit()

The modifier swaps the body font’s proportional digits for monospaced digits while keeping the rest of the text proportional. Use it for any UI where digits must align across rows (timers, score boards, balance displays).

Should I use SF Pro Rounded for all UI?

No. SF Pro Rounded carries a tone (friendly, approachable) that fits some contexts and not others. The Watch app’s complications, the iPhone’s Phone number pad, and certain Health-app surfaces use it. A productivity app, a banking app, or a developer tool generally should not. Reach for .rounded deliberately, not as a default.

What’s the right Dynamic Type range for an iPhone app?

Default to supporting every size from .xSmall to .accessibility5. The accessibility sizes (.accessibility1 through .accessibility5) are how users with low vision, motor difficulties, or other accessibility needs use iPhone. An app that opts out via .dynamicTypeSize(...) constraints fails those users. The right move is layout adaptation (the isAccessibilitySize pattern), not opting out of the size range.

Can I ship a custom variable font with my app?

Yes. Variable fonts ship like any other custom font (add to Info.plist’s UIAppFonts, reference by Font.custom). To address the variable axes from SwiftUI, use Font.custom’s underlying CTFont APIs through UIFontDescriptor.SymbolicTraits or, for full axis control, drop to CTFontCreateCopyWithAttributes with kCTFontVariationAttribute. The bridge from SwiftUI to variable axes is more verbose than for system fonts; for most apps, system fonts cover the cases.

References


  1. Apple Developer: Fonts. The system font family overview including SF Pro, SF Pro Rounded, SF Mono, SF Compact, and New York. 

  2. Apple Developer: Meet the expanded San Francisco font family (WWDC 2022 session 110381). The introduction of SF Pro Variable’s three-axis design (weight, width, optical size). 

  3. Apple Developer Documentation: Font.custom(_:size:relativeTo:). The custom-font initializer that opts the font into Dynamic Type scaling relative to a chosen text style. 

  4. Apple Developer Documentation: DynamicTypeSize. The twelve-value enum from .xSmall to .accessibility5 plus the isAccessibilitySize predicate for layout-level adaptation. 

Related Posts

Liquid Glass in SwiftUI: Three Patterns From Shipping Return on iOS 26

Apple's Liquid Glass is a one-line SwiftUI API. Three patterns from Return go beyond .glassEffect(): glass on text via C…

19 min read

Accessibility As Platform: Personal Voice, Live Speech, Eye Tracking, Music Haptics

Personal Voice, Live Speech, Eye Tracking, Music Haptics, Vocal Shortcuts: accessibility as platform features, not app r…

14 min read

The Design Engineer's Agent Stack

Design engineers need agent infrastructure that enforces visual consistency, typography discipline, color compliance, an…

14 min read