--- name: axiom-typography-ref description: Apple platform typography reference (San Francisco fonts, text styles, Dynamic Type, tracking, leading, internationalization) through iOS 26 license: MIT --- # Typography Reference Complete reference for typography on Apple platforms including San Francisco font system, text styles, Dynamic Type, tracking, leading, and internationalization through iOS 26. ## San Francisco Font System ### Font Families **SF Pro** and **SF Pro Rounded** (iOS, iPadOS, macOS, tvOS) - Main system fonts for most UI elements - Rounded variant for friendly, approachable interfaces (e.g., Reminders app) **SF Compact** and **SF Compact Rounded** (watchOS, narrow columns) - Optimized for constrained spaces and small sizes - watchOS default system font **SF Mono** (Code environments, monospaced text) - Monospaced font for code editors and technical content - Consistent character widths for alignment **New York** (Serif system font) - Serif alternative for editorial content - Works with text styles just like SF Pro ### Variable Font Axes #### Weight Axis (9 weights) - Ultralight, Thin, Light, Regular, Medium, Semibold, Bold, Heavy, Black - Continuous weight spectrum via variable fonts - Avoid light weights at small sizes (legibility issues) #### Width Axis (WWDC 2022) - **Condensed** — narrowest width - **Compressed** — narrow width - **Regular** — standard width (default) - **Expanded** — wide width Access via: ```swift // iOS/macOS let descriptor = UIFontDescriptor(fontAttributes: [ .family: "SF Pro", kCTFontWidthTrait: 1.0 // 1.0 = Expanded ]) ``` **SF Arabic** (WWDC 2022) - Matches SF Pro design language for Arabic text - Proper right-to-left support #### Optical Sizes Variable fonts automatically adjust optical size based on point size: - **Text variant** (< 20pt) — more spacing, sturdier strokes - **Display variant** (≥ 20pt) — tighter spacing, refined details - **Smooth transition** (17-28pt) with variable SF Pro From WWDC 2020: > "TextKit 2 abstracts away glyph handling to provide a consistent experience for international text." ## Text Styles & Dynamic Type ### System Text Styles | Text Style | Default Size (iOS) | Use Case | |------------|-------------------|----------| | `.largeTitle` | 34pt | Primary page headings | | `.title` | 28pt | Secondary headings | | `.title2` | 22pt | Tertiary headings | | `.title3` | 20pt | Quaternary headings | | `.headline` | 17pt (Semibold) | Emphasized body text | | `.body` | 17pt | Primary body text | | `.callout` | 16pt | Secondary body text | | `.subheadline` | 15pt | Tertiary body text | | `.footnote` | 13pt | Footnotes, captions | | `.caption` | 12pt | Small annotations | | `.caption2` | 11pt | Smallest annotations | #### Font Size Guidance - **Avoid `.caption2` for readable content** — at 11pt, it's acceptable for timestamps and metadata annotations but too small for body text or labels users need to read. Prefer `.caption` or `.footnote` as the minimum for readable content. ### Emphasized Text Styles Apply `.bold` symbolic trait to get emphasized variants: ```swift // UIKit let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .title1) let boldDescriptor = descriptor.withSymbolicTraits(.traitBold)! let font = UIFont(descriptor: boldDescriptor, size: 0) // SwiftUI Text("Bold Title") .font(.title.bold()) ``` **Actual weights by text style:** - Some styles map to **medium** - Others map to **semibold**, **bold**, or **heavy** - Depends on semantic hierarchy ### Leading Variants **Tight Leading** (reduces line height by 2pt on iOS, 1pt on watchOS): ```swift // UIKit let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) let tightDescriptor = descriptor.withSymbolicTraits(.traitTightLeading)! // SwiftUI Text("Compact text") .font(.body.leading(.tight)) ``` **Loose Leading** (increases line height by 2pt on iOS, 1pt on watchOS): ```swift // SwiftUI Text("Spacious paragraph") .font(.body.leading(.loose)) ``` ### Dynamic Type **Automatic Scaling** (iOS): Text styles scale automatically based on user preferences from Settings → Display & Brightness → Text Size. **Custom Fonts with Dynamic Type:** ```swift // UIKit - UIFontMetrics let customFont = UIFont(name: "Avenir-Medium", size: 34)! let bodyMetrics = UIFontMetrics(forTextStyle: .body) let scaledFont = bodyMetrics.scaledFont(for: customFont) // Also scale constants let spacing = bodyMetrics.scaledValue(for: 20.0) ``` ```swift // SwiftUI - .font(.custom(_:relativeTo:)) Text("Custom scaled text") .font(.custom("Avenir-Medium", size: 34, relativeTo: .body)) // @ScaledMetric for values @ScaledMetric(relativeTo: .body) var padding: CGFloat = 20 ``` ### Platform Differences **macOS** - No Dynamic Type support in AppKit - Text style sizes optimized for macOS control sizes - Catalyst apps use iOS sizes × 77% (legacy) or macOS-optimized sizes ("Optimize Interface for Mac") **watchOS** - Smaller text styles optimized for watch faces - Tight leading default for compact displays **visionOS** - System fonts work identically to iOS - Dynamic Type support included ## Tracking & Leading ### Tracking (Letter Spacing) Tracking adjusts space between letters. Essential for optical size behavior. **Size-Specific Tracking Tables:** SF Pro includes tracking values that vary by point size to maintain optimal spacing: - Larger sizes: tighter tracking - Smaller sizes: looser tracking Example from Apple Design Resources: - 34pt (largeTitle): +0.016 tracking - 17pt (body): +0.008 tracking - 11pt (caption2): +0.06 tracking **Tight Tracking API** (for fitting text): ```swift // UIKit textView.allowsDefaultTightening(for: .byTruncatingTail) // SwiftUI Text("Long text that needs to fit") .lineLimit(1) .minimumScaleFactor(0.5) // Allows tight tracking ``` **Manual Tracking:** ```swift // UIKit let attributes: [NSAttributedString.Key: Any] = [ .font: UIFont.preferredFont(forTextStyle: .body), .kern: 2.0 // 2pt tracking ] // SwiftUI Text("Tracked text") .tracking(2.0) .kerning(2.0) // Alternative API ``` **Important:** Use `.tracking()` not `.kerning()` API for semantic correctness. Tracking disables ligatures when necessary; kerning does not. ### Leading (Line Spacing) **Default Line Height:** Calculated from font's built-in metrics (ascender + descender + line gap). **Language-Aware Adjustments:** iOS 17+ automatically increases line height for scripts with tall ascenders/descenders: - Arabic - Thai, Lao - Hindi, Bengali, Telugu From WWDC 2023: > "Automatic line height adjustment for scripts with variable heights" **Manual Leading:** ```swift // UIKit let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineSpacing = 8.0 // 8pt additional space // SwiftUI (iOS 13+) Text("Custom spacing") .lineSpacing(8.0) ``` **Line Height (iOS 26+):** `.lineHeight()` sets baseline-to-baseline distance directly — more intuitive than `.lineSpacing()` (which measures bottom-to-top). ```swift // Presets Text("Open layout").lineHeight(.loose) Text("Compact layout").lineHeight(.tight) // Precise control Text("Scaled").lineHeight(.multiple(factor: 1.5)) Text("Fixed").lineHeight(.exact(points: 30)) // Does NOT scale with Dynamic Type ``` Also available as `AttributedString.lineHeight` for styled strings. See `axiom-swiftui-26-ref` for full API details. ### Third-Party Font Tracking **New in iOS 18:** Font vendors can embed tracking tables in custom fonts using STAT table + CTFont optical size attribute. ```swift let attributes: [String: Any] = [ kCTFontOpticalSizeAttribute as String: pointSize ] let descriptor = CTFontDescriptorCreateWithAttributes(attributes as CFDictionary) let font = CTFontCreateWithFontDescriptor(descriptor, pointSize, nil) ``` ## SwiftUI AttributedString Typography ### Font Environment Interaction **Critical Pattern** When using `AttributedString` with SwiftUI's `Text`, paragraph styles (like `lineHeightMultiple`) can be lost if fonts come from the environment instead of the attributed content. From WWDC 2025-280: > "TextEditor substitutes the default value calculated from the environment for any AttributedStringKeys with a value of nil." This same principle applies to `Text`—when your `AttributedString` doesn't specify a font, SwiftUI applies the environment font, which can cause it to rebuild text runs and drop or normalize paragraph style details. ### The Problem ```swift // ❌ WRONG - .font() modifier can override and drop paragraph styles var s = AttributedString(longString) // Set paragraph style var p = AttributedString.ParagraphStyle() p.lineHeightMultiple = 0.92 s.paragraphStyle = p // ⚠️ No font set in AttributedString Text(s) .font(.body) // ⚠️ May rebuild runs, lose lineHeightMultiple ``` **Why this fails:** 1. `AttributedString` has no font attribute set (value is `nil`) 2. SwiftUI's `.font(.body)` modifier tells it "use this font for the whole run" 3. SwiftUI rebuilds text runs with the environment font 4. Paragraph styles get dropped or normalized during rebuild ### The Solution **Keep typography inside the AttributedString when you need fine control:** ```swift // ✅ CORRECT - Font in AttributedString, no environment override var s = AttributedString(longString) // Set font INSIDE the attributed content s.font = .system(.body) // ✅ Typography inside AttributedString // Set paragraph style var p = AttributedString.ParagraphStyle() p.lineHeightMultiple = 0.92 s.paragraphStyle = p Text(s) // ✅ No .font() modifier ``` **Why this works:** 1. Font is part of the attributed content (not `nil`) 2. No environment override from `.font()` modifier 3. SwiftUI preserves both font AND paragraph styles 4. Text runs remain intact with all attributes ### When to Use Each Approach #### Use Font in AttributedString (Fine Control) ```swift var s = AttributedString("Carefully styled text") s.font = .system(.body) var p = AttributedString.ParagraphStyle() p.lineHeightMultiple = 0.92 p.alignment = .leading s.paragraphStyle = p Text(s) // No modifier ``` **When to use:** - Need precise paragraph styling (line height, alignment) - Mixing multiple fonts in one string - Content will be displayed in both `Text` and `TextEditor` - Preserving exact formatting from rich text editor #### Use .font() Modifier (Broad Override) ```swift Text("Simple text") .font(.body) .lineSpacing(4.0) // SwiftUI-level spacing ``` **When to use:** - Simple text without paragraph styles - Want Dynamic Type automatic scaling - Need SwiftUI's semantic font behavior (Dark Mode, accessibility) - Intentionally overriding AttributedString fonts ### Multiple Fonts in One String ```swift var s = AttributedString("Title") s.font = .system(.title).bold() var body = AttributedString(" and body text") body.font = .system(.body) s.append(body) Text(s) // ✅ No .font() modifier preserves both fonts ``` ### Common Mistake: Order Doesn't Matter ```swift // ❌ WRONG mental model: "Create AttributedString first" var s = AttributedString(text) var p = AttributedString.ParagraphStyle() p.lineHeightMultiple = 0.92 s.paragraphStyle = p s.font = .system(.body) // ⚠️ Setting font last doesn't help if you use .font() modifier Text(s).font(.body) // Still breaks! ``` The issue isn't **when** you set the font in `AttributedString`. The issue is **whether the attributed content carries its own font attributes** versus relying on SwiftUI's `.font(...)` environment. ### Verification Checklist When using `AttributedString` with paragraph styles: - [ ] Font set inside `AttributedString` (not `nil`) - [ ] No `.font()` modifier on `Text` view (unless intentionally overriding) - [ ] Paragraph styles set after or before font (order doesn't matter) - [ ] Tested with actual content to verify line height/alignment preserved ## Internationalization ### Bidirectional Text **Complex Script Example (from WWDC 2021):** Kannada word "October": - Character index 4 has split vowel → 2 glyphs - Glyphs reorder before ligature application - Glyph index ≠ character index This is why TextKit 2 uses **NSTextLocation** instead of integer indices. **Hebrew/Arabic Selection:** Single visual selection = multiple NSRanges in AttributedString due to right-to-left layout. ### Line Breaking **Language-Aware (iOS 17+):** - Chinese, Japanese, Korean: break at semantic boundaries - German: avoid breaking compound words - English: prefer breaking at hyphens **Even Line Breaking (TextKit 2):** Justified paragraphs use improved line breaking algorithm: - Reduces stretched-out lines - More even interword spacing - Automatic in TextKit 2 ### Text Clipping Prevention **Best Practices:** 1. Use Dynamic Type (auto-adjusts) 2. Set `.lineLimit(nil)` or `.lineLimit(2...5)` in SwiftUI 3. Use `.minimumScaleFactor()` for constrained single-line text 4. Test with large accessibility sizes ## CSS & Web Typography **System UI Font Families:** ```css font-family: system-ui; /* SF Pro */ font-family: ui-rounded; /* SF Pro Rounded */ font-family: ui-serif; /* New York */ font-family: ui-monospace; /* SF Mono */ ``` **Legacy:** ```css font-family: -apple-system; /* deprecated, use system-ui */ ``` ## Code Examples ### Emphasized Large Title (SwiftUI) ```swift Text("Recipe Editor") .font(.largeTitle.bold()) // Emphasized variant ``` ### Custom Font + Dynamic Type (UIKit) ```swift let customFont = UIFont(name: "Avenir-Medium", size: 17)! let metrics = UIFontMetrics(forTextStyle: .body) label.font = metrics.scaledFont(for: customFont) label.adjustsFontForContentSizeCategory = true ``` ### Rounded Design (UIKit) ```swift let descriptor = UIFontDescriptor .preferredFontDescriptor(withTextStyle: .largeTitle) .withDesign(.rounded)! let font = UIFont(descriptor: descriptor, size: 0) ``` ### Rounded Design (SwiftUI) ```swift Text("Today") .font(.largeTitle.bold()) .fontDesign(.rounded) ``` ### ScaledMetric (SwiftUI) ```swift struct RecipeView: View { @ScaledMetric(relativeTo: .body) var padding: CGFloat = 20 var body: some View { Text("Recipe") .padding(padding) // Scales with Dynamic Type } } ``` ## Resources **WWDC**: 2020-10175, 2022-110381, 2023-10058 **Docs**: /uikit/uifontdescriptor, /uikit/uifontmetrics, /swiftui/font