Rendering HTML Text in SwiftUI with Custom Link Styling


Greetings, traveler!

Displaying HTML content in SwiftUI can be surprisingly tricky, especially if you want to go beyond simply rendering raw text. UIKit has long supported rich NSAttributedString rendering with HTML, but SwiftUI still lacks a native way to render HTML while preserving full control over styling. In this post, we’ll look at a custom approach to bridge that gap using a simple SwiftUI wrapper.

We’ll also consider some caveats and limitations to be aware of before relying on this technique in production.

Use Case: Displaying a Legal Notice with Styled Links

Suppose you’re building a screen that needs to show a legal disclaimer with links to your privacy policy and terms of use. You want full control over the font, color, and appearance of the links — and you want it to fit naturally into a SwiftUI view hierarchy.

Here’s what the preview looks like:

HTMLText("""
By using this <b>app</b>, you agree to our <a href='https://example.com/privacy'>Privacy Policy</a> and <a href='https://example.com/terms'>Terms of Service</a>.
""")
.font(.systemFont(ofSize: 22))
.linkColor(.purple)
.boldLinks(true)
.underlinedLinks(true)

The HTMLText View

The main component is HTMLText, a SwiftUI View that accepts an HTML string and renders it using an NSAttributedString. Internally, it uses a repository to avoid reprocessing the HTML on every re-render.

struct HTMLText: View {
    
    @State private var htmlRepository = HTMLRepository()
    
    private let html: String
    private var font: UIFont = .systemFont(ofSize: 17)
    private var underlinedLinks: Bool = false
    private var linkColor: UIColor = .blue
    private var boldLinks: Bool = true
    
    init(_ html: String) {
        self.html = html
    }
    
    var body: some View {
        Text(AttributedString(htmlRepository.htmlText))
            .environment(\.openURL, OpenURLAction { url in
                UIApplication.shared.open(url)
                return .handled
            })
            .onAppear {
                htmlRepository.configure(
                    html: html,
                    font: font,
                    underlinedLinks: underlinedLinks,
                    linkColor: linkColor,
                    boldLinks: boldLinks
                )
            }
    }
}

Some styling:

extension HTMLText {
    func font(_ font: UIFont) -> HTMLText {
        var view = self
        view.font = font
        return view
    }
    
    func underlinedLinks(_ underlinedLinks: Bool) -> HTMLText {
        var view = self
        view.underlinedLinks = underlinedLinks
        return view
    }
    
    func boldLinks(_ boldLinks: Bool) -> HTMLText {
        var view = self
        view.boldLinks = boldLinks
        return view
    }
    
    func linkColor(_ linkColor: UIColor) -> HTMLText {
        var view = self
        view.linkColor = linkColor
        return view
    }
}

And our repository:

@Observable
private final class HTMLRepository {
    
    var htmlText: NSAttributedString = .init()
    private var isConfigured: Bool = false
    
    func configure(
        html: String,
        font: UIFont,
        underlinedLinks: Bool = false,
        linkColor: UIColor = .blue,
        boldLinks: Bool = true
    ) {
        guard !isConfigured else { return }
        isConfigured.toggle()
        
        htmlText = html.htmlToAttributedString(
            font: font,
            underlinedLinks: underlinedLinks,
            linkColor: linkColor,
            boldLinks: boldLinks
        )
    }
}

Customizing Link Appearance in Attributed Strings

To gain fine-grained control over how links and fonts appear in our parsed HTML, we extend NSMutableAttributedString with two utility methods: decorateLinks and setFontFace.

extension String {
    func htmlToAttributedString(
        font: UIFont,
        underlinedLinks: Bool,
        linkColor: UIColor,
        boldLinks: Bool
    ) -> NSAttributedString {
        guard
            let data = self.data(using: .utf8),
            let attributedString = try? NSMutableAttributedString(
                data: data,
                options: [
                    .documentType: NSAttributedString.DocumentType.html,
                    .characterEncoding: String.Encoding.utf8.rawValue
                ],
                documentAttributes: nil
            ) else {
            return .init()
        }
        
        attributedString.setFontFace(
            font: font
        )
        attributedString.decorateLinks(
            linkColor: linkColor,
            isUnderlined: underlinedLinks,
            isBold: boldLinks,
            font: font
        )
        
        return attributedString
    }
}

extension NSMutableAttributedString {
    func decorateLinks(
        linkColor: UIColor,
        isUnderlined: Bool,
        isBold: Bool,
        font: UIFont
    ) {
        enumerateAttribute(
            .link,
            in: NSRange(location: .zero, length: self.length),
            options: []
        ) { value, range, _ in
            if value != nil {
                var attributes: [NSAttributedString.Key: Any] = [
                    .foregroundColor: linkColor
                ]
                
                if !isUnderlined {
                    attributes[.underlineStyle] = NSUnderlineStyle().rawValue
                }
                
                if isBold {
                    let boldFont = UIFont(
                        descriptor: font.fontDescriptor.withSymbolicTraits(.traitBold) ?? font.fontDescriptor,
                        size: font.pointSize
                    )
                    attributes[.font] = boldFont
                }
                
                addAttributes(attributes, range: range)
            }
        }
    }
    
    func setFontFace(
        font: UIFont,
        color: UIColor? = nil
    ) {
        enumerateAttribute(
            .font,
            in: NSRange(location: .zero, length: self.length)
        ) { value, range, _ in
            if let fontValue = value as? UIFont,
               let newFontDescriptor = fontValue
                .fontDescriptor
                .withFamily(font.familyName)
                .withSymbolicTraits(fontValue.fontDescriptor.symbolicTraits) {
                let newFont = UIFont(
                    descriptor: newFontDescriptor,
                    size: font.pointSize
                )
                removeAttribute(.font, range: range)
                addAttribute(.font, value: newFont, range: range)
                if let color = color {
                    removeAttribute(
                        .foregroundColor,
                        range: range
                    )
                    addAttribute(
                        .foregroundColor,
                        value: color,
                        range: range
                    )
                }
            }
        }
    }
}

Let’s walk through what each of them does.

decorateLinks(...)

This method is responsible for customizing the appearance of links in the attributed string. It works by enumerating all .link attributes and applying additional formatting to the ranges where links are found.

enumerateAttribute(.link, in: NSRange(location: .zero, length: self.length), options: []) { value, range, _ in
    ...
}

Within the loop:

  • We first check that a .link attribute is present (value != nil).
  • We prepare a dictionary of attributes to apply. At minimum, we set the desired link color via .foregroundColor.
  • If link underlining is not desired (isUnderlined == false), we explicitly set .underlineStyle to 0 to remove the default underline.
  • If isBold is true, we attempt to create a bold version of the current font:
    • We reuse the font’s existing descriptor and apply .traitBold.
    • If bold conversion fails (e.g. the font doesn’t support bold), we fall back to the original descriptor.
  • Finally, all of these new attributes are applied to the range using addAttributes.

This approach ensures that we can independently control whether links are underlined, what color they use, and whether they appear bold — something that is not always straightforward using only HTML styling.

setFontFace(…)

This method is used to normalize the font used throughout the attributed string, typically to override the default font styles applied by the HTML parser.

It works by enumerating all .font attributes in the string and rebuilding them with a specified base font.

enumerateAttribute(.font, in: NSRange(location: .zero, length: self.length)) { value, range, _ in
    ...
}

For each font:

  • We extract the symbolic traits (e.g. bold, italic) from the original font and apply them to a new font that shares the specified font.familyName and point size.
  • This allows us to enforce a consistent font family while preserving individual style traits like bold or italic.
  • If a color override is provided, we remove any existing .foregroundColor attribute and replace it with the specified color.

The result is a more predictable and uniform text appearance that still respects any inline emphasis (like bold or italic) defined in the original HTML.

Together, these two methods give you control over how HTML-rendered text looks and behaves — something especially useful when integrating styled legal disclaimers, help texts, or other rich content into SwiftUI views.

What’s Good About This Approach

  • Full Control Over Styling: You can define link color, underline style, boldness, and base font.
  • Encapsulation: The HTMLText view is self-contained and works like any other SwiftUI view.
  • UIKit Interop: Leverages NSAttributedString‘s HTML support without needing a custom UIViewRepresentable.

Alternatives

If you need full HTML rendering with complex content or advanced interaction, WKWebView or UITextView are still the most capable tools — at the cost of performance and layout flexibility in SwiftUI. For simpler use cases, this HTMLText wrapper strikes a good balance.

Conclusion

This approach offers a clean way to integrate styled HTML into SwiftUI views without relying on a WebView or on a UITextView. It gives you decent control over fonts and links and integrates naturally into the declarative SwiftUI world.

However, it’s best suited for lightweight use cases — like legal notices, onboarding screens, or brief HTML descriptions — rather than full document rendering.

Full code:

import SwiftUI

#Preview {
    HTMLText("By using this <b>app</b>, you agree to our <a href='https://example.com/privacy'>Privacy Policy</a> and <a href='https://example.com/terms'>Terms of Service</a>.")
    .font(.systemFont(ofSize: 22))
    .linkColor(.purple)
    .boldLinks(true)
    .underlinedLinks(true)
}

struct HTMLText: View {
    
    @State private var htmlRepository = HTMLTRepository()
    
    private let html: String
    private var font: UIFont = .systemFont(ofSize: 17)
    private var underlinedLinks: Bool = false
    private var linkColor: UIColor = .blue
    private var boldLinks: Bool = true
    
    init(_ html: String) {
        self.html = html
    }
    
    var body: some View {
        Text(AttributedString(htmlRepository.htmlText))
            .environment(\.openURL, OpenURLAction { url in
                UIApplication.shared.open(url)
                return .handled
            })
            .onAppear {
                htmlRepository.configure(
                    html: html,
                    font: font,
                    underlinedLinks: underlinedLinks,
                    linkColor: linkColor,
                    boldLinks: boldLinks
                )
            }
    }
}

extension HTMLText {
    func font(_ font: UIFont) -> HTMLText {
        var view = self
        view.font = font
        return view
    }
    
    func underlinedLinks(_ underlinedLinks: Bool) -> HTMLText {
        var view = self
        view.underlinedLinks = underlinedLinks
        return view
    }
    
    func boldLinks(_ boldLinks: Bool) -> HTMLText {
        var view = self
        view.boldLinks = boldLinks
        return view
    }
    
    func linkColor(_ linkColor: UIColor) -> HTMLText {
        var view = self
        view.linkColor = linkColor
        return view
    }
}

@Observable
private final class HTMLRepository {
    
    var htmlText: NSAttributedString = .init()
    private var isConfigured: Bool = false
    
    func configure(
        html: String,
        font: UIFont,
        underlinedLinks: Bool = false,
        linkColor: UIColor = .blue,
        boldLinks: Bool = true
    ) {
        guard !isConfigured else { return }
        isConfigured.toggle()
        
        htmlText = html.htmlToAttributedString(
            font: font,
            underlinedLinks: underlinedLinks,
            linkColor: linkColor,
            boldLinks: boldLinks
        )
    }
}

extension String {
    func htmlToAttributedString(
        font: UIFont,
        underlinedLinks: Bool,
        linkColor: UIColor,
        boldLinks: Bool
    ) -> NSAttributedString {
        guard
            let data = self.data(using: .utf8),
            let attributedString = try? NSMutableAttributedString(
                data: data,
                options: [
                    .documentType: NSAttributedString.DocumentType.html,
                    .characterEncoding: String.Encoding.utf8.rawValue
                ],
                documentAttributes: nil
            ) else {
            return .init()
        }
        
        attributedString.setFontFace(
            font: font
        )
        attributedString.decorateLinks(
            linkColor: linkColor,
            isUnderlined: underlinedLinks,
            isBold: boldLinks,
            font: font
        )
        
        return attributedString
    }
}

extension NSMutableAttributedString {
    func decorateLinks(
        linkColor: UIColor,
        isUnderlined: Bool,
        isBold: Bool,
        font: UIFont
    ) {
        enumerateAttribute(
            .link,
            in: NSRange(location: .zero, length: self.length),
            options: []
        ) { value, range, _ in
            if value != nil {
                var attributes: [NSAttributedString.Key: Any] = [
                    .foregroundColor: linkColor
                ]
                
                if !isUnderlined {
                    attributes[.underlineStyle] = NSUnderlineStyle().rawValue
                }
                
                if isBold {
                    let boldFont = UIFont(
                        descriptor: font.fontDescriptor.withSymbolicTraits(.traitBold) ?? font.fontDescriptor,
                        size: font.pointSize
                    )
                    attributes[.font] = boldFont
                }
                
                addAttributes(attributes, range: range)
            }
        }
    }
    
    func setFontFace(
        font: UIFont,
        color: UIColor? = nil
    ) {
        enumerateAttribute(
            .font,
            in: NSRange(location: .zero, length: self.length)
        ) { value, range, _ in
            if let fontValue = value as? UIFont,
               let newFontDescriptor = fontValue
                .fontDescriptor
                .withFamily(font.familyName)
                .withSymbolicTraits(fontValue.fontDescriptor.symbolicTraits) {
                let newFont = UIFont(
                    descriptor: newFontDescriptor,
                    size: font.pointSize
                )
                removeAttribute(.font, range: range)
                addAttribute(.font, value: newFont, range: range)
                if let color {
                    removeAttribute(
                        .foregroundColor,
                        range: range
                    )
                    addAttribute(
                        .foregroundColor,
                        value: color,
                        range: range
                    )
                }
            }
        }
    }
}