Skip to main content
MarkdownView provides comprehensive VoiceOver accessibility support, ensuring all markdown content—including text, images, math expressions, code blocks, and tables—is accessible to users with visual impairments.

Overview

The accessibility implementation in MarkdownView:
  • Converts all visual content to spoken descriptions
  • Replaces attachment characters with meaningful text
  • Exposes content via accessibilityValue and accessibilityLabel
  • Configures proper accessibility traits
  • Supports both UIKit (iOS/visionOS) and AppKit (macOS)

Accessibility Architecture

LTXLabel Configuration

The underlying LTXLabel component is configured as an accessibility element during initialization:
// UIKit (LTXLabel+Accessibility.swift:41-44)
func configureAccessibility() {
    isAccessibilityElement = true
    accessibilityTraits = .staticText
}

// AppKit (LTXLabel+Accessibility.swift:61-64)
func configureAccessibility() {
    setAccessibilityElement(true)
    setAccessibilityRole(.staticText)
}
The view is marked as .staticText since markdown content is read-only. Interactive elements like links are handled separately.

MarkdownTextView Configuration

The main MarkdownTextView is configured to let its child LTXLabel handle accessibility:
// UIKit (MarkdownTextView.swift:59-60)
isAccessibilityElement = false
accessibilityTraits = .staticText

// AppKit (MarkdownTextView.swift:177-178)
setAccessibilityElement(false)
setAccessibilityRole(.group)
This prevents double-reading of content—VoiceOver focuses on the LTXLabel directly.

Building Accessible Strings

The core of accessibility support is the buildAccessibleString() method, which converts the visual attributed string into a plain string suitable for VoiceOver.

Attachment Replacement

Markdown content uses the Unicode replacement character (\u{FFFC}) as a placeholder for attachments (images, math). The accessibility system replaces these with meaningful text:
func buildAccessibleString() -> String {
    let attrText = attributedText
    let raw = attrText.string
    
    guard raw.contains("\u{FFFC}") else { return raw }
    
    var result = raw
    // Walk backwards so replacement indices remain valid
    attrText.enumerateAttribute(
        LTXAttachmentAttributeName,
        in: fullRange,
        options: .reverse
    ) { value, range, _ in
        guard let attachment = value as? LTXAttachment else { return }
        let replacement = attachment.attributedStringRepresentation().string
        result.replaceSubrange(swiftRange, with: replacement)
    }
    return result
}
The method walks backward through the string to avoid invalidating indices as replacements are made.

Accessibility for Different Content Types

Text Content

Plain text, bold, italic, strikethrough, and other inline styles are naturally accessible. VoiceOver reads the text content directly:
This is **bold** and *italic* text.
VoiceOver reads: “This is bold and italic text.” (without announcing formatting)

Images

Images use their alt text for accessibility. When an image is rendered, its LTXAttachment stores the alt text:
let representedText = altText.isEmpty ? source : altText
let attachment = LTXAttachment.hold(attrString: .init(string: representedText))
For this markdown:
![A sunset over mountains](https://example.com/sunset.jpg)
VoiceOver reads: “A sunset over mountains” If no alt text is provided:
![](https://example.com/image.jpg)
VoiceOver reads the URL: “https://example.com/image.jpg
Always provide meaningful alt text for images to ensure a good accessibility experience.

Math Expressions

LaTeX math expressions store their LaTeX source code for VoiceOver:
let attachment = LTXAttachment.hold(attrString: .init(string: latexContent))
For this markdown:
The formula $E = mc^2$ represents energy.
VoiceOver reads: “The formula E = mc^2 represents energy.”
LaTeX is read character-by-character. For complex equations, consider adding a text description nearby for better accessibility.

Code Blocks

Code blocks are rendered as custom views (CodeView), which are separate subviews. These should implement their own accessibility support by:
  1. Setting isAccessibilityElement = true
  2. Providing the code content as accessibilityValue
  3. Optionally announcing the language: accessibilityLabel = "Swift code"
The code view’s accessibility is independent of the main markdown view.

Tables

Tables are rendered as custom views (TableView), which are also separate subviews. For proper accessibility, tables should:
  1. Set isAccessibilityElement = false on the container
  2. Make each cell an accessibility element
  3. Use accessibilityLabel to describe row/column positions
  4. Consider reading order (left-to-right, top-to-bottom)
For example: “Row 1, Column 1: Name” → “Row 1, Column 2: Age” Links are accessible as tappable elements within the text. The link destination is announced as a hint:
Visit [Apple](https://apple.com) for more info.
VoiceOver reads: “Visit Apple for more info.” with a hint: “Link available”

Accessibility Value vs Label

Both accessibilityValue and accessibilityLabel return the same built accessible string:
// UIKit
override public var accessibilityValue: String? {
    get { buildAccessibleString() }
    set { /* read-only */ }
}

override public var accessibilityLabel: String? {
    get { buildAccessibleString() }
    set { /* read-only */ }
}
Providing both ensures compatibility with different VoiceOver reading modes and iOS versions.

Dynamic Content Updates

When markdown content changes, the accessibility system is automatically updated because:
  1. The attributedText property changes
  2. buildAccessibleString() is called lazily when VoiceOver queries the view
  3. VoiceOver receives the updated accessible string
No manual notification is required—the getter-based approach ensures VoiceOver always sees current content.

Selection and Editing

Since MarkdownView displays read-only content, it doesn’t implement:
  • Text editing accessibility APIs
  • Cursor position announcements
  • Text field traits
For editable markdown, consider using a UITextView with a custom NSLayoutManager or implement the full UIAccessibilityReadingContent protocol.

Multi-Platform Support

Accessibility implementations differ between UIKit and AppKit:

iOS/visionOS (UIKit)

isAccessibilityElement = true
accessibilityTraits = .staticText
override var accessibilityValue: String? { buildAccessibleString() }
override var accessibilityLabel: String? { buildAccessibleString() }

macOS (AppKit)

setAccessibilityElement(true)
setAccessibilityRole(.staticText)
override func accessibilityValue() -> Any? { buildAccessibleString() }
override func accessibilityLabel() -> String? { buildAccessibleString() }
override func isAccessibilityElement() -> Bool { true }
AppKit uses methods instead of properties for accessibility, but the logic is identical.

Testing Accessibility

Xcode Accessibility Inspector

  1. Open XcodeDeveloper ToolsAccessibility Inspector
  2. Select your app process
  3. Inspect the MarkdownView
  4. Verify accessibilityValue contains readable text
  5. Check that attachments are replaced with text

VoiceOver Testing

iOS/visionOS:
  1. Enable VoiceOver: Settings → Accessibility → VoiceOver
  2. Swipe through your markdown content
  3. Verify all text, images, and math are announced correctly
macOS:
  1. Enable VoiceOver: System Settings → Accessibility → VoiceOver
  2. Use Cmd+F5 to toggle VoiceOver
  3. Navigate with Ctrl+Option+Arrow keys

Best Practices

Provide Alt Text

Always include descriptive alt text for images:
✅ ![Chart showing sales growth from 2020 to 2025](sales-chart.png)
❌ ![](sales-chart.png)

Simplify Math Expressions

For complex equations, provide a text description:
The quadratic formula is $x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$, which solves for x in any quadratic equation.

Structure Content Logically

Use headings, lists, and paragraphs to provide structure:
## Section Title

Introductory paragraph.

- Point one
- Point two
VoiceOver users can navigate by headings for faster content discovery.

Test with Real Users

Automated testing catches technical issues, but usability testing with VoiceOver users reveals:
  • Unclear alt text
  • Confusing reading order
  • Missing context
  • Overly verbose descriptions

Limitations

  • Code blocks: Accessibility depends on CodeView implementation (outside LTXLabel)
  • Tables: Accessibility depends on TableView implementation
  • Interactive elements: Only basic link detection is provided
  • Math expressions: LaTeX is read character-by-character, which may be unclear
For advanced accessibility needs, consider implementing custom accessibility containers or providing alternative text descriptions for complex content.

Accessibility Traits

Currently, the view uses .staticText trait. Consider adding:
  • .header for heading content
  • .link for link-heavy content
  • .allowsDirectInteraction for interactive elements
These require custom implementation based on your app’s needs.

Build docs developers (and LLMs) love