System text styles like .body and .headline adapt automatically when someone changes their preferred text size in Settings. Custom fonts work differently. When you write .font(.custom("Inter-Regular", fixedSize: 16)), you get a fixed size that stays the same whether the reader uses the smallest setting or the largest accessibility size.
That gap matters. Many people rely on larger text to read comfortably, and a brand typeface that ignores their choice can make an app hard to use or unusable.
You can close the gap with a view modifier whose core is four lines. It pairs a custom font with @ScaledMetric, so your typography stays on-brand and still responds to Dynamic Type.
struct ScaledFont: ViewModifier {
@ScaledMetric var size: CGFloat
let name: String
func body(content: Content) -> some View {
content.font(.custom(name, fixedSize: size))
}
}That's the whole mechanism. Here's what each part does.
| Line | Role |
|---|---|
@ScaledMetric var size: CGFloat |
Stores a base point size and returns a value scaled to the current Dynamic Type setting. |
let name: String |
The PostScript name of the custom font. |
func body(content:) |
Applies the font to whatever view the modifier wraps. |
.custom(name, fixedSize: size) |
Creates the font at the already-scaled size, without scaling it a second time. |
fixedSize?@ScaledMetric has already done the scaling. If you passed the value to .custom(_:size:) instead, SwiftUI would scale it again, relative to .body. At large accessibility sizes, the doubled scaling makes text grow much faster than the reader expects. fixedSize tells SwiftUI to use the number exactly as given, so scaling happens once.
Important Combine
@ScaledMetricwith.custom(_:fixedSize:), not.custom(_:size:). Mixing the two scaling mechanisms produces text that's larger than intended.
Calling .modifier(ScaledFont(size: 17, name: "Inter-Regular")) works, but it reads awkwardly at the call site. A View extension makes the modifier read like a native one:
extension View {
func scaledFont(_ name: String, size: CGFloat) -> some View {
modifier(ScaledFont(size: size, name: name))
}
}Usage now looks like this:
Text("Welcome back")
.scaledFont("Inter-SemiBold", size: 28)By default, @ScaledMetric scales relative to the .body text style. Different text styles grow at different rates, though. Large titles grow more slowly than body text, so headlines don't take over the screen at accessibility sizes.
To match that behavior, give the modifier an initializer that accepts a text style:
struct ScaledFont: ViewModifier {
@ScaledMetric private var size: CGFloat
private let name: String
init(name: String, size: CGFloat, relativeTo textStyle: Font.TextStyle = .body) {
self.name = name
_size = ScaledMetric(wrappedValue: size, relativeTo: textStyle)
}
func body(content: Content) -> some View {
content.font(.custom(name, fixedSize: size))
}
}
extension View {
func scaledFont(
_ name: String,
size: CGFloat,
relativeTo textStyle: Font.TextStyle = .body
) -> some View {
modifier(ScaledFont(name: name, size: size, relativeTo: textStyle))
}
}The underscore prefix (_size) gives you access to the property wrapper itself, so you can configure it with a base value and a text style.
VStack(alignment: .leading, spacing: 8) {
Text("Trail Report")
.scaledFont("Inter-Bold", size: 34, relativeTo: .largeTitle)
Text("Conditions updated 10 minutes ago.")
.scaledFont("Inter-Regular", size: 15, relativeTo: .subheadline)
}Tip Match the base size and text style to the role the text plays. A 34-point title pairs naturally with
.largeTitle; 15-point supporting copy pairs with.subheadline.
A modifier that takes raw strings and numbers is flexible, but it lets inconsistencies slip in. In a design system, you usually want a fixed set of named styles. An enum captures that:
enum AppTypography {
case display, title, body, caption
var fontName: String {
switch self {
case .display, .title: "Inter-Bold"
case .body, .caption: "Inter-Regular"
}
}
var baseSize: CGFloat {
switch self {
case .display: 34
case .title: 22
case .body: 17
case .caption: 12
}
}
var textStyle: Font.TextStyle {
switch self {
case .display: .largeTitle
case .title: .title2
case .body: .body
case .caption: .caption
}
}
}
extension View {
func typography(_ style: AppTypography) -> some View {
scaledFont(style.fontName, size: style.baseSize, relativeTo: style.textStyle)
}
}Views now describe intent rather than measurements:
Text("Settings").typography(.title)
Text("Choose how notifications appear.").typography(.body)When the design changes, you update one enum instead of searching the codebase for hard-coded sizes.
Since iOS 14, SwiftUI also offers .custom(_:size:relativeTo:), which scales a custom font against a text style without a property wrapper:
Text("Hello").font(.custom("Inter-Regular", size: 17, relativeTo: .body))Both approaches produce correctly scaled text. The modifier approach is useful when:
Because @ScaledMetric works with any CGFloat, you can apply the same idea to non-text metrics:
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = 20Some layouts can't accommodate every size. Rather than turning off scaling, cap it for a specific region with dynamicTypeSize(_:) (iOS 15 and later):
TabBarLabel()
.dynamicTypeSize(...DynamicTypeSize.accessibility2)Note Use caps sparingly and only where space is genuinely constrained. Content areas should support the full range.
Larger text sometimes needs a different arrangement, not just bigger glyphs. Read the current size from the environment and switch layouts when needed:
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
let layout = typeSize.isAccessibilitySize
? AnyLayout(VStackLayout(alignment: .leading))
: AnyLayout(HStackLayout())
layout {
Image(systemName: "bell")
Text("Notifications").typography(.body)
}
}The modifier assumes the font is available at runtime. Before using it:
UIAppFonts) in your Info.plist.UIFont.familyNames and UIFont.fontNames(forFamilyName:).If SwiftUI can't find the name, it silently falls back to the system font. When a custom font doesn't appear, the name is the first thing to check.
Verify behavior across the full range of sizes, not only the default.
Xcode Previews: Render several sizes side by side.
#Preview {
VStack(spacing: 24) {
ForEach([DynamicTypeSize.xSmall, .large, .accessibility3], id: \.self) { size in
Text("The quick brown fox")
.typography(.body)
.environment(\.dynamicTypeSize, size)
}
}
}Environment Overrides: While running in the debugger, use the Environment Overrides button in Xcode's debug bar to change the text size live.
Accessibility Inspector: Adjust font size on a running simulator and audit for truncation or clipping.
On device: Go to Settings > Accessibility > Display & Text Size > Larger Text and turn on Larger Accessibility Sizes to test the extremes.
If part of your app uses UIKit, UIFontMetrics provides the same behavior:
let base = UIFont(name: "Inter-Regular", size: 17)!
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: base)
label.adjustsFontForContentSizeCategory = trueSetting adjustsFontForContentSizeCategory to true lets the label update automatically when the reader changes their text size while the app is running.
@ScaledMetric and .custom(_:fixedSize:) fixes that.Typography is part of a brand, but readability belongs to the reader. With one small modifier, you can respect both.
Thank you for reading. If you have any questions feel free to follow me on X and send me a DM. If this article helped you, Buy me a coffee.