How to render markdown in SwiftUI

Issue #1057

Markdown is everywhere. Documentation, chat messages, AI responses, README files, most text-heavy apps eventually need to render it. SwiftUI has no built-in markdown view, so you have to build one. This article walks through the tools available and how they fit together.

Image

Apple swift-markdown

Apple ships a Swift package called swift-markdown that parses markdown into a structured syntax tree. It uses GitHub Flavored Markdown’s cmark-gfm under the hood, so the parsing behavior closely follows the GFM spec.

Parse a string with Document(parsing:):

import Markdown

let source = "This is **bold** and *italic* text."
let document = Document(parsing: source)
print(document.debugDescription())
// Document
// └─ Paragraph
//    ├─ Text "This is "
//    ├─ Strong
//    │  └─ Text "bold"
//    ├─ Text " and "
//    ├─ Emphasis
//    │  └─ Text "italic"
//    └─ Text " text."

The result is an immutable tree of value types. You can walk it with a MarkupVisitor, transform it, or build new documents programmatically. What swift-markdown does not do is render. It gives you the structure; what you do with it is up to you. To display markdown in a SwiftUI app, you need to walk that tree and produce views, or you need a different approach entirely.

AttributedString and the built-in markdown parser

iOS 15 and macOS 12 introduced AttributedString, a Swift-native replacement for NSAttributedString. One of its most useful initializers parses a markdown string directly:

let attrStr = try AttributedString(
    markdown: "This is **bold** and *italic* text.",
    options: .init(interpretedSyntax: .full)
)

The interpretedSyntax option controls what markdown is parsed. .full handles all block-level constructs: headings, lists, code blocks, blockquotes, tables, and thematic breaks. .inlineOnly and .inlineOnlyPreservingWhitespace parse just the inline spans, which is useful for short labels where you want bold and italic but not paragraphs.

Once you have an AttributedString, you can pass it directly to Text:

Text(attrStr)

SwiftUI’s Text renders AttributedString natively. Bold, italic, strikethrough, inline code, and links all work out of the box. For simple inline formatting, this is often enough.

Runs: how AttributedString encodes structure

An AttributedString is not a flat string. It is a collection of runs, where each run is a substring with a consistent set of attributes. When you parse markdown, the parser encodes both the text content and the structural metadata as attributes on those runs.

Iterate over runs with a for loop:

for run in attrStr.runs {
    let text = attrStr[run.range]
    print(text)
}

Each run exposes key attributes through typed properties. The two most important for markdown rendering are inlinePresentationIntent and presentationIntent.

InlinePresentationIntent: inline formatting

InlinePresentationIntent is an OptionSet that captures the inline formatting applied to a run. A single run can carry multiple inline intents at once.

for run in attrStr.runs {
    guard let inline = run.inlinePresentationIntent else { continue }

    if inline.contains(.stronglyEmphasized) { /* bold */ }
    if inline.contains(.emphasized)         { /* italic */ }
    if inline.contains(.code)               { /* inline code */ }
    if inline.contains(.strikethrough)      { /* strikethrough */ }
    if inline.contains(.softBreak)          { /* soft line break */ }
    if inline.contains(.lineBreak)          { /* hard line break */ }
}

Text already respects InlinePresentationIntent when rendering an AttributedString, so bold and italic work automatically. The reason to read it yourself is when you want to override the default styling, for example applying a custom font or color to code spans.

for run in attrStr.runs {
    var runText = Text(AttributedString(attrStr[run.range]))
    if run.inlinePresentationIntent?.contains(.code) == true {
        runText = runText.font(.system(.body, design: .monospaced))
                         .foregroundColor(.orange)
    }
    result = result + runText
}

Concatenating Text values with + produces a single Text that flows naturally with the surrounding layout.

PresentationIntent: block structure

PresentationIntent describes the block-level structure of a run. Where InlinePresentationIntent tells you about formatting within a line, PresentationIntent tells you which structural container the run belongs to: a paragraph, a heading, a list item, a code block, and so on.

for run in attrStr.runs {
    guard let intent = run.presentationIntent else { continue }
    // inspect intent.components
}

PresentationIntent carries an array of components. Each component has a kind and an identity integer. The array is ordered innermost-first: the most specific structural node is at index 0, and the containing structures follow. A run inside a list item inside an ordered list would produce three components: listItem at index 0, then orderedList, then the enclosing structure.

The available kinds are:

switch component.kind {
case .header(level: let level):        // h1 through h6
case .paragraph:                        // plain paragraph
case .listItem(ordinal: let n):        // item within a list
case .orderedList:                      // numbered list container
case .unorderedList:                    // bullet list container
case .codeBlock(languageHint: let l):  // fenced code block
case .blockQuote:                       // > quoted text
case .table(columns: let cols):        // table container
case .tableHeaderRow:                  // first row of a table
case .tableRow(rowIndex: let i):       // subsequent rows
case .tableCell(columnIndex: let j):   // individual cell
case .thematicBreak:                   // horizontal rule ---
}

The identity integer is a stable identifier for each structural node across runs. Two runs with the same identity belong to the same block. You can use this to group runs before rendering them.

One subtlety: both ordered and unordered list items receive a non-zero ordinal from the parser. To tell them apart, look one step further in the components array. If the component after listItem is orderedList, it is numbered. If it is unorderedList, it is a bullet item.

func isOrdered(for intent: PresentationIntent) -> Bool {
    let components = intent.components
    guard let listItemIndex = components.firstIndex(where: {
        if case .listItem = $0.kind { return true }
        return false
    }) else { return false }

    let parentIndex = components.index(after: listItemIndex)
    guard parentIndex < components.endIndex else { return false }

    if case .orderedList = components[parentIndex].kind { return true }
    return false
}

Grouping runs into blocks

To render blocks as distinct views, group the runs by their structural identity first. Walk all runs, extract the innermost structurally significant component from each, and accumulate runs that share the same identity into a single chunk.

extension AttributedString {
    func blockGroups() -> [BlockGroup] {
        var groups: [Int: BlockGroup] = [:]
        var order: [Int] = []

        for run in runs {
            let identity = representativeIdentity(for: run)
            if groups[identity] == nil {
                let kind = blockKind(for: run)
                groups[identity] = BlockGroup(id: identity, kind: kind, content: AttributedString())
                order.append(identity)
            }
            groups[identity]?.content += AttributedString(self[run.range])
        }

        return order.compactMap { groups[$0] }
    }
}

With a BlockGroup array in hand, you can render each one as its own view: a heading, a paragraph, a list item, a code block. Each view reads the group’s content and applies the appropriate styling.

Rendering blocks in SwiftUI

A minimal block renderer dispatches on the group kind inside a VStack:

struct BlockRenderer: View {
    let groups: [BlockGroup]

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            ForEach(groups) { group in
                switch group.kind {
                case .heading(let level):
                    Text(group.content)
                        .font(headingFont(level))
                case .paragraph:
                    Text(group.content)
                case .listItem(let ordinal, let depth, let ordered):
                    ListItemView(group: group, ordinal: ordinal, depth: depth, ordered: ordered)
                case .codeBlock(let language):
                    CodeBlockView(group: group, language: language)
                case .blockQuote:
                    QuoteBlockView(group: group)
                default:
                    Text(group.content)
                }
            }
        }
    }
}

Parsing is expensive relative to layout. Move it out of body and into a one-time calculation. In SwiftUI, onChange(of:initial:) with initial: true fires once on first appearance and again whenever the input changes, making it a clean place to trigger parsing without duplicating it across init and body:

struct MarkdownView: View {
    let markdown: String

    @State private var groups: [BlockGroup] = []

    var body: some View {
        BlockRenderer(groups: groups)
            .onChange(of: markdown, initial: true) { _, newValue in
                groups = parse(newValue)
            }
    }
}

Putting it together

Foundation’s markdown parser is available starting from iOS 15 and macOS 12, with no additional dependencies. swift-markdown is the right tool if you need to programmatically inspect or transform a document structure. For rendering in SwiftUI, AttributedString combined with PresentationIntent run-walking covers most cases: headings, paragraphs, lists, code blocks, blockquotes, and tables.

The full implementation of this approach, including theming via a single MarkdownTheme struct, is available at onmyway133/swiftui-markdown.

Written by

I’m open source contributor, writer, speaker and product maker.

Start the conversation