-
Notifications
You must be signed in to change notification settings - Fork 0
Text and Typography
DFText is the primary text rendering component in DesignFoundation. It replaces ad-hoc SwiftUI Text + .font() + .foregroundColor() chains with a single call that automatically adapts to the active theme, respects Dynamic Type scaling, and applies consistent line spacing and tracking across every screen in your app.
See also: Primitives for the component overview · Theming for the full color and spacing token reference.
- Why DFText
- Initializer and Style Cases
- Color Modifiers — dfTextStyle
- DFTypographyTokens — Accessing Raw Tokens
- Dynamic Type
- Multi-Line and Truncation
- Container Cascade
- Per-Preset Typography Differences
- Accessibility
- Examples
A hand-rolled SwiftUI Text view needs at minimum:
// Custom — before DesignFoundation
Text("Section Title")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(Color(.label))
.lineSpacing(4)
.tracking(0.1)That pattern breaks in three ways as your app grows:
-
Theme changes don't propagate. Hard-coded
.foregroundColorvalues do not respond when the user switches from the Default preset to Slate or Copper. - Spacing and tracking drift. Every developer picks slightly different values, and the visual system fragments.
-
No single source of truth. Updating the body typeface means hunting every
Textview in the codebase.
DFText solves all three:
// DesignFoundation
DFText("Section Title", style: .title)Under the hood DFText reads DFTypographyTokens from the injected theme environment. When the theme changes — or when the user adjusts their preferred text size — every DFText on screen updates automatically without a single line of app code.
public init(_ content: String, style: DFTextStyle = .body)style defaults to .body, so the most common case is zero-configuration:
DFText("Hello, world") // .body, theme textPrimary color
DFText("Section Header", style: .headline)
DFText("12,480", style: .display)
DFText("Updated 3 min ago", style: .caption)| Style | SwiftUI Font Base | Weight | Line Spacing | Tracking | Typical Use |
|---|---|---|---|---|---|
.display |
.largeTitle |
Bold | 6 pt | −0.5 | Hero numbers, splash headlines |
.title |
.title2 |
Semibold | 4 pt | −0.2 | Screen titles, card headers |
.headline |
.headline |
Semibold | 3 pt | 0.1 | Section headers, list group names |
.body |
.body |
Regular | 4 pt | 0.0 | Paragraph text, descriptions (default) |
.label |
.subheadline |
Medium | 2 pt | 0.1 | Row labels, field labels, nav items |
.caption |
.caption |
Regular | 2 pt | 0.3 | Timestamps, helper text, metadata |
Line spacing and tracking values are theme-token defaults and vary slightly per preset — see Per-Preset Typography Differences.
After choosing a size/weight style, you can independently control foreground color using .dfTextStyle(_:):
DFText("Published", style: .label)
.dfTextStyle(.primary) // theme.colors.textPrimary — full emphasis
DFText("3 hours ago", style: .caption)
.dfTextStyle(.secondary) // theme.colors.textSecondary — muted
DFText("Custom", style: .body)
.dfTextStyle(.default) // no forced color; inherits environment foreground| Modifier | Color Token | Intended Role |
|---|---|---|
.primary |
theme.colors.textPrimary |
Headlines, row labels, body copy requiring full contrast |
.secondary |
theme.colors.textSecondary |
Supporting info, timestamps, helper text, subtitles |
.default |
(inherited) | Let the surrounding environment or a custom .foregroundStyle() take effect |
.dfTextStyle is a view modifier that composes cleanly with other modifiers:
DFText("Warning: action is irreversible", style: .body)
.dfTextStyle(.primary)
.foregroundStyle(theme.colors.error) // overrides primary after the factWhen no .dfTextStyle modifier is applied, DFText defaults to .primary — full textPrimary foreground — so it is always legible without extra configuration.
Every DFTextStyle case is backed by a DFTextStyle value struct:
public struct DFTextStyle: Sendable {
public let font: Font
public let lineSpacing: CGFloat
public let tracking: CGFloat
}Access the tokens from theme.typography in any view that holds a theme environment reference:
@Environment(\.dfTheme) private var theme
// Inspect headline tokens
let headlineFont = theme.typography.headline.font
let headlineLeading = theme.typography.headline.lineSpacing
let headlineKern = theme.typography.headline.tracking
// Apply tokens to a raw SwiftUI Text if you need markdown or AttributedString support
Text("**Bold** and _italic_ supported")
.font(theme.typography.body.font)
.lineSpacing(theme.typography.body.lineSpacing)
.tracking(theme.typography.body.tracking)
.foregroundStyle(theme.colors.textPrimary)Prefer DFText whenever the content is a plain String. Drop to raw tokens only when you need SwiftUI Text markdown interpolation, AttributedString, or advanced text concatenation via +.
DFText uses SwiftUI's scalable font system — the same underlying mechanism as Font.body, Font.title, and so on. All six style cases scale proportionally with the user's chosen text size in System Preferences / Accessibility Settings.
You do not need to opt in or configure anything. Scaling is on by default.
- Avoid fixed heights on containers that hold
DFText. Prefer.frame(minHeight:)or let the container expand naturally. - When a
DFTextsits inside aDFCardor aDFListRow, those components also account for Dynamic Type scaling internally. - If you are composing a custom layout alongside
DFText, test with the "Accessibility" text size category (xxxxxLarge) to catch truncation early.
// Good — expands as text scales
DFCard {
DFText(description, style: .body)
.fixedSize(horizontal: false, vertical: true)
}
// Fragile — clips large text sizes
DFCard {
DFText(description, style: .body)
.frame(height: 44)
}DFText wraps across multiple lines by default — identical to SwiftUI Text. All standard modifiers apply:
// Unlimited lines (default)
DFText(longDescription, style: .body)
// Hard cap
DFText(longDescription, style: .body)
.lineLimit(3)
// Truncate from the middle (useful for file paths, URLs)
DFText("/Users/jamie/Documents/Project Final v3 (copy).sketch", style: .label)
.lineLimit(1)
.truncationMode(.middle)
// Reserve space so the layout does not jump when content loads
DFText(asyncTitle ?? "", style: .headline)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
.redacted(reason: asyncTitle == nil ? .placeholder : [])
DFSkeletonis the preferred loading placeholder for text that has not yet arrived — it provides the animated shimmer. Use.redacted(reason:)only when you need the actual layout geometry before content is available.
.dfTextStyle applied to a container propagates to all DFText descendants that do not have their own explicit .dfTextStyle modifier:
// All DFText children inherit .secondary unless overridden
VStack(alignment: .leading, spacing: theme.spacing.xs) {
DFText("Account", style: .headline)
.dfTextStyle(.primary) // explicit override — stays primary
DFText("Manage your profile and billing", style: .body)
DFText("Last updated: today", style: .caption)
}
.dfTextStyle(.secondary) // default for all children in this VStackThis pattern is useful for building muted detail panels where most content should read as secondary, with only the heading promoted to primary.
While all presets share the same six style cases and SwiftUI font bases, the DFTypographyTokens values inside each theme are tuned to match the preset's personality:
Balanced tracking and line spacing — the neutral reference point.
| Style | Tracking | Line Spacing |
|---|---|---|
.display |
−0.5 | 6 pt |
.body |
0.0 | 4 pt |
.caption |
0.3 | 2 pt |
Near-identical to Default. Slightly tighter tracking on .display and .title to reinforce the refined, professional feel.
The most compact preset. Tighter tracking throughout — .display tracks at −0.8, .body at −0.1 — giving dense layouts a crisp, editorial quality without losing legibility.
Slightly larger display scale with increased line spacing on .body (+1 pt) and .headline (+0.5 pt), giving the airy, open feel that matches the Aurora color palette.
Organic and relaxed. The most generous line spacing preset — .body sits at 5 pt, .caption at 3 pt — reinforcing the calm, natural aesthetic.
All DFText styles scale automatically — no action needed. No .fixedSize override or explicit fixed Font size should be used with DFText.
When the visible text differs from what VoiceOver should read — a metric display showing "12.4k" that should be announced as "twelve point four thousand" — use .accessibilityLabel:
DFText("12.4k", style: .display)
.accessibilityLabel("12,400 followers")Decorative or redundant text should be hidden from the accessibility tree:
DFText("—", style: .caption)
.accessibilityHidden(true)theme.colors.textPrimary and theme.colors.textSecondary are designed to meet WCAG AA contrast minimums against their respective background tokens across all five presets. If you override foreground color using .foregroundStyle(), you are responsible for verifying contrast ratios.
import DesignFoundation
struct ArticleView: View {
@Environment(\.dfTheme) private var theme
let article: Article
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: theme.spacing.md) {
DFText(article.title, style: .display)
.dfTextStyle(.primary)
HStack {
DFAvatar(name: article.authorName, size: 28)
VStack(alignment: .leading, spacing: 2) {
DFText(article.authorName, style: .label)
.dfTextStyle(.primary)
DFText(article.publishedAt, style: .caption)
.dfTextStyle(.secondary)
}
}
DFDivider()
DFText(article.body, style: .body)
.dfTextStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(theme.spacing.lg)
}
}
}struct MetricCard: View {
@Environment(\.dfTheme) private var theme
let value: String
let label: String
let delta: String
var body: some View {
DFCard {
VStack(alignment: .leading, spacing: theme.spacing.xs) {
DFText(value, style: .display)
.dfTextStyle(.primary)
DFText(label, style: .label)
.dfTextStyle(.primary)
DFText(delta, style: .caption)
.dfTextStyle(.secondary)
}
}
}
}struct ProfileHeader: View {
@Environment(\.dfTheme) private var theme
let profile: UserProfile
var body: some View {
VStack(alignment: .leading, spacing: theme.spacing.sm) {
DFAvatar(url: profile.avatarURL, size: 64)
DFText(profile.displayName, style: .headline)
.dfTextStyle(.primary)
DFText("@\(profile.handle)", style: .caption)
.dfTextStyle(.secondary)
if !profile.bio.isEmpty {
DFText(profile.bio, style: .body)
.dfTextStyle(.primary)
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}struct NotificationRow: View {
@Environment(\.dfTheme) private var theme
let notification: AppNotification
var body: some View {
HStack(alignment: .top, spacing: theme.spacing.sm) {
DFIcon(notification.iconName, size: .md, color: theme.colors.primary)
VStack(alignment: .leading, spacing: 2) {
DFText(notification.title, style: .label)
.dfTextStyle(.primary)
DFText(notification.relativeTimestamp, style: .caption)
.dfTextStyle(.secondary)
}
Spacer()
if notification.isUnread {
DFBadge(text: "New")
}
}
.padding(.vertical, theme.spacing.xs)
}
}struct SettingsValueRow: View {
@Environment(\.dfTheme) private var theme
let label: String
let value: String
var body: some View {
HStack {
DFText(label, style: .label).dfTextStyle(.primary)
Spacer()
DFText(value, style: .label).dfTextStyle(.secondary)
}
.padding(.vertical, theme.spacing.xs)
}
}struct BreadcrumbBar: View {
@Environment(\.dfTheme) private var theme
let segments: [String]
var body: some View {
HStack(spacing: theme.spacing.xs) {
ForEach(Array(segments.enumerated()), id: \.offset) { index, segment in
if index > 0 {
DFIcon("chevron.right", size: .sm, color: theme.colors.textSecondary)
}
DFText(segment, style: .caption)
.foregroundStyle(
index == segments.count - 1
? theme.colors.textPrimary
: theme.colors.textSecondary
)
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("Breadcrumb: \(segments.joined(separator: ", "))")
}
}struct EmptyStateView: View {
@Environment(\.dfTheme) private var theme
let icon: String
let title: String
let message: String
var actionLabel: String? = nil
var action: (() -> Void)? = nil
var body: some View {
VStack(spacing: theme.spacing.lg) {
DFIcon(icon, size: .xl, color: theme.colors.textSecondary)
VStack(spacing: theme.spacing.sm) {
DFText(title, style: .headline)
.dfTextStyle(.primary)
.multilineTextAlignment(.center)
DFText(message, style: .body)
.dfTextStyle(.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
if let actionLabel, let action {
DFButton(actionLabel) { action() }
.dfButtonStyle(.secondary)
}
}
.padding(theme.spacing.xxl)
.frame(maxWidth: 320)
}
}struct MetadataBlock: View {
@Environment(\.dfTheme) private var theme
let item: ContentItem
var body: some View {
VStack(alignment: .leading, spacing: theme.spacing.xs) {
// Override to primary — the status label stands out
DFText(item.status, style: .label)
.dfTextStyle(.primary)
// All of these inherit .secondary from the VStack
DFText("Created \(item.createdAt)", style: .caption)
DFText("Modified \(item.modifiedAt)", style: .caption)
DFText("Owner: \(item.ownerName)", style: .caption)
}
.dfTextStyle(.secondary) // cascade — captions get this for free
}
}Last updated: 2026-07-02 · Part of the DesignFoundation wiki