How to Build a Markdown Renderer in SwiftUI
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
-
Parsing with
AttributedString(markdown:options:)— The Foundation initialiser converts a raw Markdown string into a typedAttributedStringwhose runs carry attributes like.inlinePresentationIntent(bold, italic, code) and.link. All parsing happens synchronously and throws on malformed input rather than silently failing. -
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.fullto also process block-level constructs like thematic breaks, but note SwiftUI'sTextflattens blocks into a single paragraph regardless. -
Graceful fallback — The
guard let result = try? …pattern on line 17 returns an unformattedAttributedStringinitialised from the raw string if parsing fails. The user always sees something, never a blank screen or a crash. -
Text(attributed)renders styling automatically — SwiftUI'sTextview understandsAttributedStringnatively since iOS 15. Bold runs become.bold(), italic runs become.italic(), and link runs become tappable with the colour set by.tint(). -
File-loading in
MarkdownDocument— The.taskmodifier (line 57) loads the file asynchronously on first appearance. BecauseString(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
-
iOS 15 vs iOS 17 attribute scope changes.
AttributedString(markdown:)landed in iOS 15, butallowsExtendedAttributesand someSwiftUI.AttributeScopeskeys were refined in iOS 16–17. Wrap attribute access in availability guards if you need to support below iOS 17, or simply deploy with a minimum of iOS 17 to avoid the delta. -
Block-level elements are flattened by
Text. SwiftUI'sTextis fundamentally an inline layout engine — it cannot render a<ul>or<blockquote>as a distinct block. Lists, blockquotes, and code fences all collapse into one paragraph. If your content relies heavily on lists, consider splitting by newline and building aVStack. -
Large documents cause layout thrashing. Parsing a multi-thousand-line Markdown file synchronously in a computed property runs on every SwiftUI render pass. For large files, cache the result in
@Stateor@Observableand parse once in a.task. Also ensure VoiceOver gets the.accessibilityLabelapplied toTextso the full attributed content is announced correctly.
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.