```html SwiftUI: How to Build a Markdown Renderer (iOS 17+, 2026)

How to Build a Markdown Renderer in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: AttributedString Updated: May 12, 2026
TL;DR

Use AttributedString(markdown:) to parse a Markdown string, then hand it directly to SwiftUI's Text view — no third-party library needed. iOS 17 supports bold, italic, strikethrough, inline code, and links out of the box.

import SwiftUI

struct MarkdownText: View {
    let source: String

    private var attributed: AttributedString {
        (try? AttributedString(
            markdown: source,
            options: .init(interpretedSyntax: .inlinesOnlyPreservingWhitespace)
        )) ?? AttributedString(source)
    }

    var body: some View {
        Text(attributed)
            .textSelection(.enabled)
    }
}

Full implementation

The complete renderer wraps AttributedString parsing in a reusable MarkdownRenderer view that handles errors gracefully, supports a configurable InterpretedSyntax, and applies a base font so headings and body copy feel cohesive. A ScrollView wrapper keeps long documents scrollable without any extra plumbing.

Because AttributedString(markdown:) is a throwing initialiser we parse eagerly inside a computed property and fall back to plain text on failure — keeping the body expression simple and avoiding forced try.

import SwiftUI

// MARK: - MarkdownRenderer

struct MarkdownRenderer: View {
    let source: String
    var font: Font = .body
    var syntax: AttributedString.MarkdownParsingOptions.InterpretedSyntax = .inlinesOnlyPreservingWhitespace
    var accentColor: Color = .accentColor

    private var attributed: AttributedString {
        var options = AttributedString.MarkdownParsingOptions()
        options.interpretedSyntax = syntax
        options.allowsExtendedAttributes = true
        options.languageCode = Locale.current.language.languageCode?.identifier

        guard let result = try? AttributedString(markdown: source, options: options) else {
            return AttributedString(source)
        }
        return result
    }

    var body: some View {
        ScrollView {
            Text(attributed)
                .font(font)
                .tint(accentColor)
                .textSelection(.enabled)
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding()
                .accessibilityLabel(Text(attributed))
        }
        .background(Color(.systemBackground))
    }
}

// MARK: - MarkdownDocument (wraps file loading)

struct MarkdownDocument: View {
    let url: URL

    @State private var source: String = ""
    @State private var loadError: Error?

    var body: some View {
        Group {
            if let error = loadError {
                ContentUnavailableView(
                    "Could not load document",
                    systemImage: "exclamationmark.triangle",
                    description: Text(error.localizedDescription)
                )
            } else {
                MarkdownRenderer(source: source)
            }
        }
        .task {
            do {
                source = try String(contentsOf: url, encoding: .utf8)
            } catch {
                loadError = error
            }
        }
        .navigationTitle(url.deletingPathExtension().lastPathComponent)
        .navigationBarTitleDisplayMode(.inline)
    }
}

// MARK: - Preview

#Preview("Inline Markdown") {
    let sample = """
    # Hello, SwiftUI
    Render **bold**, *italic*, ~~strikethrough~~, and `inline code`.

    Visit [Soarias](https://soarias.com) to ship faster.

    > AttributedString makes this *remarkably* easy.
    """
    NavigationStack {
        MarkdownRenderer(source: sample)
            .navigationTitle("Preview")
    }
}

How it works

  1. Parsing with AttributedString(markdown:options:) — The Foundation initialiser converts a raw Markdown string into a typed AttributedString whose runs carry attributes like .inlinePresentationIntent (bold, italic, code) and .link. All parsing happens synchronously and throws on malformed input rather than silently failing.
  2. InterpretedSyntax.inlinesOnlyPreservingWhitespace — This option (set on line 10 of the full implementation) tells the parser to handle only inline spans — bold, italic, code, links — and leave structural whitespace intact. Use .full to also process block-level constructs like thematic breaks, but note SwiftUI's Text flattens blocks into a single paragraph regardless.
  3. Graceful fallback — The guard let result = try? … pattern on line 17 returns an unformatted AttributedString initialised from the raw string if parsing fails. The user always sees something, never a blank screen or a crash.
  4. Text(attributed) renders styling automatically — SwiftUI's Text view understands AttributedString natively since iOS 15. Bold runs become .bold(), italic runs become .italic(), and link runs become tappable with the colour set by .tint().
  5. File-loading in MarkdownDocument — The .task modifier (line 57) loads the file asynchronously on first appearance. Because String(contentsOf:) is a blocking call, it executes on the cooperative thread pool rather than blocking the main actor, keeping the UI responsive.

Variants

Custom link and code styling via AttributedString mutation

After parsing, walk the attributed string's runs to override specific attributes — for example giving inline code a monospaced font and a tinted background (via a custom SwiftUI.AttributeScopes key is not possible in Text, but you can change the font family).

private func styled(_ raw: AttributedString) -> AttributedString {
    var result = raw
    for run in result.runs {
        // Style inline code spans with a monospaced font
        if run.inlinePresentationIntent?.contains(.code) == true {
            result[run.range].font = .system(.body, design: .monospaced)
        }
        // Open links with a custom colour
        if run.link != nil {
            result[run.range].foregroundColor = UIColor.systemIndigo
        }
    }
    return result
}

// Usage inside MarkdownRenderer.body:
// Text(styled(attributed))

Full document syntax (headings, thematic breaks)

Pass syntax: .full when initialising MarkdownRenderer. The parser will process ATX headings (# H1) and horizontal rules. Note that SwiftUI's Text renders headings as bold inline spans rather than block-level elements — for true block layout (separate heading Text views above body paragraphs) you would need to pre-split the Markdown string by heading and build a VStack manually, or integrate a library like swift-markdown-ui.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a markdown renderer in SwiftUI for iOS 17+.
Use AttributedString with MarkdownParsingOptions.
Support bold, italic, inline code, strikethrough, and links.
Apply a monospaced font to inline code runs by iterating run.inlinePresentationIntent.
Make it accessible (VoiceOver labels on the Text view).
Add a #Preview with realistic sample data including all supported span types.

In Soarias, drop this prompt into the Build phase after your screen mockups are approved — Claude Code will wire the renderer into your existing NavigationStack and generate matching SwiftData model bindings if your document store is already scaffolded.

Related

FAQ

Does this work on iOS 16?

Yes — AttributedString(markdown:) is available from iOS 15 and Text(AttributedString) from iOS 15 as well. However some parsing options (allowsExtendedAttributes) and SwiftUI attribute scope keys behave differently before iOS 17. The code on this page targets iOS 17+ and is tested there; if you must support iOS 15–16, remove allowsExtendedAttributes and verify behaviour on older simulators.

Can I render GFM tables or task-list checkboxes?

Not natively. Apple's AttributedString parser implements CommonMark inlines and some block-level elements, but GitHub Flavored Markdown extensions — tables, task lists, autolinks — are not supported. For full GFM, consider rendering to HTML with a parser like cmark-gfm and displaying with WKWebView, or use the swift-markdown-ui package which maps GFM constructs to native SwiftUI views.

What's the UIKit equivalent?

In UIKit, use NSAttributedString(markdown:) (available on NSAttributedString since iOS 15) and set it on a UILabel or UITextView. The options API mirrors the Swift AttributedString one closely; the main difference is the attribute key namespace (NSAttributedString.Key vs AttributeScopes).

Last reviewed: 2026-05-12 by the Soarias team.

```