Build a Rich Text Editor in SwiftUI with AttributedString and iOS 26

Learn how to build a production-ready rich text editor in SwiftUI using the new TextEditor AttributedString support in iOS 26 — with formatting toolbar, Markdown parsing, and SwiftData persistence. No UIKit required.

Before iOS 26, building a rich text editor in SwiftUI was, frankly, an exercise in frustration. You'd get tantalizingly close with TextEditor, then slam into a wall the moment you needed bold text, custom colors, or any kind of inline formatting. The workaround? Drop into UIKit, wrap UITextView with UIViewRepresentable, juggle NSAttributedString, wire up delegates, and hope nothing broke when SwiftUI decided to redraw.

That era is over.

With iOS 26, Apple gave TextEditor first-class support for AttributedString, along with a powerful trio of new APIs: AttributedTextSelection, FontResolutionContext, and transformAttributes(in:). Together, they let you build a fully featured rich text editor — complete with a formatting toolbar, toolbar state synchronization, and rich text persistence — entirely in SwiftUI. No UIKit. No bridging. Just Swift.

In this guide, you'll build a production-ready rich text editor from scratch. Every code example compiles and runs. By the end, you'll have a reusable component that handles bold, italic, underline, strikethrough, text color, font size, Markdown parsing, and saving to disk.

Prerequisites

  • Xcode 26 or later
  • iOS 26, macOS 26, or a compatible simulator
  • Basic familiarity with SwiftUI views and state management

Understanding the Core APIs

Before writing any code, let's look at the four building blocks that make this possible.

AttributedString: The Foundation

AttributedString is a Swift-native, value-type string that carries rich formatting metadata — font, color, underline, background, links, and more. Unlike its Objective-C ancestor NSAttributedString, it's fully Codable, uses copy-on-write for performance, and integrates seamlessly with SwiftUI views.

When you bind an AttributedString to a TextEditor, the editor automatically supports system formatting shortcuts (Cmd+B for bold, Cmd+I for italic) and system menu controls — with zero additional code from you. Honestly, it just works.

AttributedTextSelection: Tracking the Cursor and Selection

AttributedTextSelection tracks where the user's cursor sits or what text range they've highlighted. Instead of exposing raw String.Index ranges that can drift out of sync when text changes, SwiftUI wraps selection state in a safe, always-consistent abstraction.

This type is essential for two things: applying formatting to exactly the text the user selected, and reading the current formatting at the cursor position to keep your toolbar in sync.

FontResolutionContext: Adaptive Font Intelligence

This one's a bit more subtle. FontResolutionContext is an environment value that resolves abstract SwiftUI fonts into their concrete traits — bold, italic, weight, size, and design. Fonts in SwiftUI are adaptive resources, not fixed values. They respond to Dynamic Type, accessibility settings, and platform-specific typography. Without FontResolutionContext, a bold toggle could behave inconsistently across devices and font configurations.

transformAttributes(in:): The Formatting Engine

transformAttributes(in:) modifies attributes on the selected text safely and efficiently. It takes an inout reference to the selection because the transformation might cause adjacent attribute runs to merge, requiring the selection boundaries to adjust. SwiftUI handles this automatically — you never worry about stale indices.

Step 1: A Minimal Rich Text Editor in Five Lines

The simplest possible rich text editor requires almost no code:

import SwiftUI

struct MinimalEditorView: View {
    @State private var text = AttributedString("Start typing here...")

    var body: some View {
        TextEditor(text: $text)
            .padding()
    }
}

That's it. Seriously. By switching from String to AttributedString, you unlock built-in formatting support. Users can select text and use keyboard shortcuts (Cmd+B, Cmd+I, Cmd+U) or the system Edit menu to apply bold, italic, and underline. The attributes get captured directly in your AttributedString state.

But most apps need custom toolbar buttons, not just keyboard shortcuts. Let's build that next.

Step 2: Adding Selection Tracking

To build custom formatting controls, you need to know what text the user has selected. Add an AttributedTextSelection binding:

struct EditorWithSelectionView: View {
    @State private var text = AttributedString("Select some text to format it.")
    @State private var selection = AttributedTextSelection()

    var body: some View {
        TextEditor(text: $text, selection: $selection)
            .padding()
    }
}

The selection property now tracks the cursor position and any highlighted range in real time. You can inspect it to figure out what formatting is active and use it as the target when applying new formatting.

Step 3: Building the Formatting Toolbar

Here's where the real power shows up. You'll build a toolbar with bold, italic, underline, and strikethrough toggles that apply formatting to selected text and reflect the current state at the cursor position.

Bold and Italic Toggles

Both bold and italic require resolving the current font to check its traits, then flipping the relevant trait:

struct RichTextEditorView: View {
    @Environment(\.fontResolutionContext) private var fontContext
    @State private var text = AttributedString("Welcome to your rich text editor!")
    @State private var selection = AttributedTextSelection()

    var body: some View {
        VStack(spacing: 0) {
            toolbar
            TextEditor(text: $text, selection: $selection)
                .padding()
        }
    }

    private var toolbar: some View {
        HStack(spacing: 12) {
            Button {
                toggleBold()
            } label: {
                Image(systemName: "bold")
                    .fontWeight(isBold ? .bold : .regular)
                    .foregroundStyle(isBold ? .primary : .secondary)
            }

            Button {
                toggleItalic()
            } label: {
                Image(systemName: "italic")
                    .fontWeight(isItalic ? .bold : .regular)
                    .foregroundStyle(isItalic ? .primary : .secondary)
            }

            Divider().frame(height: 20)

            Button {
                toggleUnderline()
            } label: {
                Image(systemName: "underline")
                    .foregroundStyle(isUnderlined ? .primary : .secondary)
            }

            Button {
                toggleStrikethrough()
            } label: {
                Image(systemName: "strikethrough")
                    .foregroundStyle(isStrikethrough ? .primary : .secondary)
            }
        }
        .padding(.horizontal)
        .padding(.vertical, 8)
        .background(.bar)
    }
}

The toolbar uses computed properties to reflect the current state. Let's implement the toggle methods and state properties next.

Toggle Methods

Each toggle follows the same pattern: call transformAttributes(in:) with an inout reference to the selection, read the current attribute, and flip it:

// MARK: - Formatting Actions
extension RichTextEditorView {
    private func toggleBold() {
        text.transformAttributes(in: &selection) { container in
            let font = container.font ?? .body
            let resolved = font.resolve(in: fontContext)
            container.font = font.bold(!resolved.isBold)
        }
    }

    private func toggleItalic() {
        text.transformAttributes(in: &selection) { container in
            let font = container.font ?? .body
            let resolved = font.resolve(in: fontContext)
            container.font = font.italic(!resolved.isItalic)
        }
    }

    private func toggleUnderline() {
        text.transformAttributes(in: &selection) { container in
            container.underlineStyle = container.underlineStyle == nil
                ? .single
                : nil
        }
    }

    private func toggleStrikethrough() {
        text.transformAttributes(in: &selection) { container in
            container.strikethroughStyle = container.strikethroughStyle == nil
                ? .single
                : nil
        }
    }
}

Notice how underline and strikethrough are simpler than bold and italic. They don't require font resolution — they're standalone attributes on the container. Bold and italic, however, are traits of the font, which is why you need to resolve the font first.

Step 4: Keeping the Toolbar in Sync with typingAttributes

A formatting toolbar that doesn't reflect the current state is confusing (we've all used editors where the bold button lies to us). When the user moves their cursor into bold text, the Bold button should appear active. SwiftUI provides typingAttributes(in:) on AttributedTextSelection for exactly this.

Typing attributes represent the attributes that will be applied to any newly inserted text at the current cursor position. They update automatically as the user moves the cursor or changes the selection.

// MARK: - Toolbar State
extension RichTextEditorView {
    private var isBold: Bool {
        let attrs = selection.typingAttributes(in: text)
        guard let font = attrs.font else { return false }
        return font.resolve(in: fontContext).isBold
    }

    private var isItalic: Bool {
        let attrs = selection.typingAttributes(in: text)
        guard let font = attrs.font else { return false }
        return font.resolve(in: fontContext).isItalic
    }

    private var isUnderlined: Bool {
        selection.typingAttributes(in: text).underlineStyle != nil
    }

    private var isStrikethrough: Bool {
        selection.typingAttributes(in: text).strikethroughStyle != nil
    }
}

These computed properties re-evaluate whenever selection or text changes, so the toolbar always reflects reality. No manual state juggling. No NotificationCenter observers. Just pure SwiftUI reactivity.

Step 5: Adding Text Color and Font Size Controls

A rich text editor isn't complete without color and font size controls. Let's add both.

Foreground Color Picker

struct ColorPickerButton: View {
    let text: Binding<AttributedString>
    let selection: Binding<AttributedTextSelection>

    @State private var selectedColor: Color = .primary
    @State private var showingPicker = false

    var body: some View {
        Button {
            showingPicker.toggle()
        } label: {
            Image(systemName: "paintbrush")
        }
        .popover(isPresented: $showingPicker) {
            ColorPicker("Text Color", selection: $selectedColor)
                .padding()
                .onChange(of: selectedColor) {
                    applyColor()
                }
        }
    }

    private func applyColor() {
        var sel = selection.wrappedValue
        text.wrappedValue.transformAttributes(in: &sel) { container in
            container.foregroundColor = selectedColor
        }
        selection.wrappedValue = sel
    }
}

Font Size Stepper

struct FontSizeControl: View {
    let text: Binding<AttributedString>
    let selection: Binding<AttributedTextSelection>
    let fontContext: FontResolutionContext

    @State private var fontSize: Double = 17

    var body: some View {
        HStack(spacing: 4) {
            Button {
                adjustSize(by: -1)
            } label: {
                Image(systemName: "minus")
            }

            Text("\(Int(fontSize))")
                .monospacedDigit()
                .frame(width: 30)

            Button {
                adjustSize(by: 1)
            } label: {
                Image(systemName: "plus")
            }
        }
    }

    private func adjustSize(by delta: Double) {
        fontSize = max(9, min(96, fontSize + delta))
        var sel = selection.wrappedValue
        text.wrappedValue.transformAttributes(in: &sel) { container in
            let currentFont = container.font ?? .body
            let resolved = currentFont.resolve(in: fontContext)
            container.font = .system(size: fontSize, weight: resolved.isBold ? .bold : .regular)
        }
        selection.wrappedValue = sel
    }
}

The font size control reads the current resolved size and adjusts by one point in either direction, clamped between 9 and 96 points. Same transformAttributes pattern — safe, inout-based attribute mutation that keeps everything in sync.

Step 6: Loading Markdown Content

AttributedString has built-in Markdown parsing, which makes it trivial to initialize your editor with pre-formatted content:

struct MarkdownEditorView: View {
    @State private var text: AttributedString
    @State private var selection = AttributedTextSelection()

    init() {
        let markdown = """
        # Meeting Notes

        **Date:** February 25, 2026

        ## Action Items
        - *Review* the new API design
        - ~~Cancel the old sprint~~ — already done
        - Check the [documentation](https://developer.apple.com)

        > This is a blockquote with **bold** and *italic* text.
        """
        do {
            _text = State(initialValue: try AttributedString(markdown: markdown))
        } catch {
            _text = State(initialValue: AttributedString(markdown))
        }
    }

    var body: some View {
        TextEditor(text: $text, selection: $selection)
            .padding()
    }
}

The Markdown parser supports bold, italic, strikethrough, links, inline code, and blockquotes. This is particularly useful for note-taking apps, documentation editors, or really any interface that accepts Markdown input from users or APIs.

Step 7: Persisting Rich Text

A rich text editor is only useful if users can save their work. Since AttributedString conforms to Codable, you've got a straightforward path to persistence.

JSON Encoding and Decoding

The simplest approach encodes the entire AttributedString as JSON data:

struct DocumentStorage {
    private static let encoder = JSONEncoder()
    private static let decoder = JSONDecoder()

    static func save(_ text: AttributedString, to url: URL) throws {
        let data = try encoder.encode(text)
        try data.write(to: url, options: .atomic)
    }

    static func load(from url: URL) throws -> AttributedString {
        let data = try Data(contentsOf: url)
        return try decoder.decode(AttributedString.self, from: data)
    }
}

This works well for local documents, draft storage, or syncing with CloudKit. The JSON representation preserves all formatting attributes including fonts, colors, underline styles, and links.

Integrating with SwiftData

For apps that use SwiftData for persistence, store the encoded data as a Data property on your model:

import SwiftData

@Model
class Document {
    var title: String
    var richTextData: Data?
    var createdAt: Date
    var updatedAt: Date

    init(title: String, richTextData: Data? = nil) {
        self.title = title
        self.richTextData = richTextData
        self.createdAt = .now
        self.updatedAt = .now
    }

    var richText: AttributedString {
        get {
            guard let data = richTextData else {
                return AttributedString()
            }
            return (try? JSONDecoder().decode(AttributedString.self, from: data))
                ?? AttributedString()
        }
        set {
            richTextData = try? JSONEncoder().encode(newValue)
            updatedAt = .now
        }
    }
}

The computed richText property gives you a clean API for reading and writing AttributedString while storing the raw Data in SwiftData. Much nicer than the old NSKeyedArchiver approach, if you remember that era.

Step 8: Building the Complete Reusable Component

So, let's bring everything together. Here's a single, reusable RichTextEditor component you can drop into any project:

struct RichTextEditor: View {
    @Binding var text: AttributedString
    @Environment(\.fontResolutionContext) private var fontContext
    @State private var selection = AttributedTextSelection()
    @State private var textColor: Color = .primary
    @State private var showColorPicker = false

    var body: some View {
        VStack(spacing: 0) {
            formattingToolbar
            Divider()
            TextEditor(text: $text, selection: $selection)
        }
    }

    // MARK: - Toolbar

    private var formattingToolbar: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 12) {
                formatButton("bold", isActive: isBold, action: toggleBold)
                formatButton("italic", isActive: isItalic, action: toggleItalic)
                formatButton("underline", isActive: isUnderlined, action: toggleUnderline)
                formatButton("strikethrough", isActive: isStrikethrough, action: toggleStrikethrough)

                Divider().frame(height: 20)

                fontSizeControls

                Divider().frame(height: 20)

                Button {
                    showColorPicker.toggle()
                } label: {
                    Image(systemName: "paintbrush.fill")
                        .foregroundStyle(textColor)
                }
                .popover(isPresented: $showColorPicker) {
                    ColorPicker("Text Color", selection: $textColor, supportsOpacity: false)
                        .padding()
                        .frame(minWidth: 200)
                        .onChange(of: textColor) { applyTextColor() }
                }

                Divider().frame(height: 20)

                Button("Clear", systemImage: "eraser") {
                    clearFormatting()
                }
            }
            .padding(.horizontal)
            .padding(.vertical, 8)
        }
        .background(.bar)
    }

    private func formatButton(
        _ systemImage: String,
        isActive: Bool,
        action: @escaping () -> Void
    ) -> some View {
        Button(action: action) {
            Image(systemName: systemImage)
                .fontWeight(isActive ? .bold : .regular)
                .foregroundStyle(isActive ? .accent : .secondary)
        }
    }

    private var fontSizeControls: some View {
        HStack(spacing: 4) {
            Button {
                adjustFontSize(by: -1)
            } label: {
                Image(systemName: "textformat.size.smaller")
            }

            Button {
                adjustFontSize(by: 1)
            } label: {
                Image(systemName: "textformat.size.larger")
            }
        }
    }

    // MARK: - Toolbar State

    private var isBold: Bool {
        guard let font = selection.typingAttributes(in: text).font else { return false }
        return font.resolve(in: fontContext).isBold
    }

    private var isItalic: Bool {
        guard let font = selection.typingAttributes(in: text).font else { return false }
        return font.resolve(in: fontContext).isItalic
    }

    private var isUnderlined: Bool {
        selection.typingAttributes(in: text).underlineStyle != nil
    }

    private var isStrikethrough: Bool {
        selection.typingAttributes(in: text).strikethroughStyle != nil
    }

    // MARK: - Formatting Actions

    private func toggleBold() {
        text.transformAttributes(in: &selection) { container in
            let font = container.font ?? .body
            let resolved = font.resolve(in: fontContext)
            container.font = font.bold(!resolved.isBold)
        }
    }

    private func toggleItalic() {
        text.transformAttributes(in: &selection) { container in
            let font = container.font ?? .body
            let resolved = font.resolve(in: fontContext)
            container.font = font.italic(!resolved.isItalic)
        }
    }

    private func toggleUnderline() {
        text.transformAttributes(in: &selection) { container in
            container.underlineStyle = container.underlineStyle == nil ? .single : nil
        }
    }

    private func toggleStrikethrough() {
        text.transformAttributes(in: &selection) { container in
            container.strikethroughStyle = container.strikethroughStyle == nil ? .single : nil
        }
    }

    private func applyTextColor() {
        text.transformAttributes(in: &selection) { container in
            container.foregroundColor = textColor
        }
    }

    private func adjustFontSize(by delta: CGFloat) {
        text.transformAttributes(in: &selection) { container in
            let currentFont = container.font ?? .body
            let resolved = currentFont.resolve(in: fontContext)
            let newSize = max(9, min(96, resolved.pointSize + delta))
            container.font = .system(size: newSize)
        }
    }

    private func clearFormatting() {
        text.transformAttributes(in: &selection) { container in
            container.font = .body
            container.foregroundColor = nil
            container.backgroundColor = nil
            container.underlineStyle = nil
            container.strikethroughStyle = nil
        }
    }
}

Use this component anywhere in your app by passing an AttributedString binding:

struct NoteDetailView: View {
    @State private var noteText = AttributedString("Start writing your note...")

    var body: some View {
        RichTextEditor(text: $noteText)
            .navigationTitle("New Note")
    }
}

Supported Formatting Attributes Reference

Here's the complete list of attributes that TextEditor supports with AttributedString in iOS 26:

  • Font traits: bold, italic, and bold-italic via .font with .bold() and .italic()
  • Font size and design: .system(size:weight:design:) for custom sizes, weights, and designs (rounded, serif, monospaced)
  • Decoration: .underlineStyle and .strikethroughStyle with patterns like .single, .double, .thick
  • Colors: .foregroundColor and .backgroundColor
  • Links: .link with a URL value
  • Typography: .kern, .tracking, .baselineOffset
  • Paragraph styling: line height, text alignment, and base writing direction
  • Special: Genmoji support

Performance Considerations

AttributedString is a value type, but it uses copy-on-write internally. Only the ranges you actually modify get copied, so even large documents perform smoothly. SwiftUI's observation system further optimizes things — the TextEditor only re-renders the affected text regions, not the entire document.

For documents exceeding several thousand words, keep these in mind:

  • Debounce auto-save: Don't encode and write to disk on every keystroke. Use a Task with a short delay to batch saves.
  • Skip manual undo history: SwiftUI's TextEditor manages its own undo stack. You don't need to duplicate it.
  • Profile with Instruments: Use the SwiftUI Instruments template to verify that your toolbar state computations aren't causing excessive view updates.

Frequently Asked Questions

How do I build a rich text editor in SwiftUI without UIKit?

Starting with iOS 26, you can build a fully native rich text editor using SwiftUI's TextEditor with an AttributedString binding. Just change your state property from String to AttributedString and the editor gains built-in support for bold, italic, underline, and other formatting. Add custom toolbar buttons using transformAttributes(in:) to apply formatting to the user's selection. No UIKit wrapping or UIViewRepresentable needed.

What is the difference between AttributedString and NSAttributedString in SwiftUI?

AttributedString is a Swift-native value type introduced in iOS 15 that's Codable, works with copy-on-write, and integrates directly with SwiftUI. NSAttributedString is an Objective-C reference type from Foundation that requires bridging. For new SwiftUI code targeting iOS 26, always use AttributedString — it's safer, more performant, and has direct TextEditor support.

How do I save AttributedString to SwiftData?

Since AttributedString conforms to Codable, encode it to JSON Data using JSONEncoder and store the Data property in your SwiftData model. Create a computed property on your model that encodes on set and decodes on get — it gives you a clean API for reading and writing rich text while SwiftData handles the raw bytes.

Can I use Markdown with SwiftUI TextEditor?

Yes. AttributedString has a built-in Markdown initializer: try AttributedString(markdown: yourString). It parses bold, italic, strikethrough, links, inline code, and blockquotes. You can initialize your TextEditor with Markdown content and the user can continue editing with full rich text support.

Does the new TextEditor rich text support work on macOS?

Yes. The TextEditor with AttributedString support is available on macOS 26, iOS 26, iPadOS 26, visionOS 26, and all other Apple platforms that ship with the 2025 SDK cycle. The API is identical across platforms, so your rich text editor component works everywhere without any platform-specific code.

About the Author Editorial Team

Our team of expert writers and editors.