Dynamic Type for Custom Fonts in SwiftUI - A 4-Line Fix

October 24, 2026

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.


The Modifier

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.

Why 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 @ScaledMetric with .custom(_:fixedSize:), not .custom(_:size:). Mixing the two scaling mechanisms produces text that's larger than intended.


Adding a Convenience API

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)

Scaling Relative to a Text Style

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.


Building a Type Scale

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.


Discussion

The built-in alternative

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:

  • You want one consistent, named API across the app.
  • You need the scaled value for something else, such as padding, icon size, or line spacing, that should grow alongside the text.
  • You want the scaling behavior to be explicit and easy to find during code review.

Because @ScaledMetric works with any CGFloat, you can apply the same idea to non-text metrics:

@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = 20

Limiting the range

Some 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.

Adapting layout at large sizes

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)
    }
}

Registering the Font

The modifier assumes the font is available at runtime. Before using it:

  1. Add the font files to your target.
  2. List them under Fonts provided by application (UIAppFonts) in your Info.plist.
  3. Use the font's PostScript name, which may differ from the file name. Check it in Font Book or by printing 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.


Testing Your Implementation

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.


UIKit Equivalent

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 = true

Setting adjustsFontForContentSizeCategory to true lets the label update automatically when the reader changes their text size while the app is running.


Summary

  • Custom fonts with a fixed size don't respond to Dynamic Type on their own.
  • A four-line modifier built on @ScaledMetric and .custom(_:fixedSize:) fixes that.
  • Pass a text style so each role scales at the right rate.
  • Wrap the modifier in a named type scale to keep typography consistent.
  • Avoid double scaling, cap sizes only where necessary, and test at the extremes.

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.