SwiftUI component for rendering streaming Markdown and KaTeX in chat-style apps on iOS 18+ and macOS 15+.
Add the package in your project’s Package.swift:
.package(url: "https://github.com/ronaldmannak/PicoMarkdownView.git", branch: "main")Then add PicoMarkdownView to the target dependencies that require it.
import PicoMarkdownView
@State private var text = "Hello **Markdown**"
var body: some View {
PicoMarkdownView(text)
}For streaming, pass chunks or an async stream:
PicoMarkdownView(chunks: ["Hello ", "world", "\n\n"])
PicoMarkdownView(stream: {
AsyncStream { continuation in
continuation.yield("Hello ")
continuation.yield("world\n\n")
continuation.finish()
}
})The view maintains continuous selection and reuses layout via a shared NSTextStorage / TextKit host under the hood.
let config = PicoTextKitConfiguration(
backgroundColor: .clear,
contentInsets: EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16),
isSelectable: true,
isScrollEnabled: false
)
PicoMarkdownView("Hello", configuration: config)MarkdownRenderTheme lets you tune typography and colors across platforms using the shared MarkdownFont / MarkdownColor aliases (which map to UIFont on iOS and NSFont on macOS):
import PicoMarkdownView
let theme = MarkdownRenderTheme(
bodyFont: MarkdownFont.preferredFont(forTextStyle: .body).withSize(18),
codeFont: MarkdownFont.monospacedSystemFont(ofSize: 16, weight: .regular),
blockquoteColor: MarkdownColor.secondaryLabel,
linkColor: MarkdownColor.systemBlue,
headingFonts: [
1: MarkdownFont.systemFont(ofSize: 30, weight: .bold),
2: MarkdownFont.systemFont(ofSize: 26, weight: .semibold),
3: MarkdownFont.systemFont(ofSize: 22, weight: .semibold)
]
)
var body: some View {
PicoMarkdownView(markdown, theme: theme)
}You can provide “Actual Size”, “Zoom In”, and “Zoom Out” controls by keeping a zoom factor in state and rebuilding the theme when it changes:
import PicoMarkdownView
struct ZoomableMarkdownView: View {
@State private var zoom: CGFloat = 1.0
private let markdown = """
## Famous Formula
Inline math: \\(E = mc^2\\)
"""
private let baseTheme = MarkdownRenderTheme.default()
private var scaledTheme: MarkdownRenderTheme {
var theme = baseTheme
theme.bodyFont = theme.bodyFont.withSize(theme.bodyFont.pointSize * zoom)
theme.codeFont = theme.codeFont.withSize(theme.codeFont.pointSize * zoom)
var headings: [Int: MarkdownFont] = [:]
for (level, font) in theme.headingFonts {
headings[level] = font.withSize(font.pointSize * zoom)
}
theme.headingFonts = headings
return theme
}
var body: some View {
VStack(spacing: 16) {
HStack(spacing: 12) {
Button("Zoom Out") { zoom = max(0.5, zoom - 0.1) }
Button("Actual Size") { zoom = 1.0 }
Button("Zoom In") { zoom = min(2.0, zoom + 0.1) }
}
PicoMarkdownView(markdown, theme: scaledTheme)
}
.padding()
}
}Code fences default to the theme’s monospaced font. To customize styling or plug in a syntax highlighter, set codeBlockTheme and codeHighlighter on MarkdownRenderTheme:
import PicoMarkdownView
import Splash
struct SplashCodeHighlighter: CodeSyntaxHighlighter {
private let splash: SyntaxHighlighter<TextOutputFormat>
init(theme: Splash.Theme) {
self.splash = SyntaxHighlighter(format: TextOutputFormat(theme: theme))
}
func highlight(_ code: String, language: String?, theme: CodeBlockTheme) -> AttributedString {
guard language != nil else {
return PlainCodeSyntaxHighlighter().highlight(code, language: language, theme: theme)
}
return AttributedString(splash.highlight(code))
}
}
var themed = MarkdownRenderTheme.default()
themed.codeBlockTheme = CodeBlockTheme.monospaced()
themed.codeHighlighter = AnyCodeSyntaxHighlighter(SplashCodeHighlighter(theme: .midnight(withFont: Splash.Font(size: 14))))
var body: some View {
PicoMarkdownView(markdown, theme: themed)
}To replace content, pass a new string/chunks/stream so the view creates a fresh input:
@State private var text = "First"
var body: some View {
PicoMarkdownView(text)
}PicoMarkdownView recognises lightweight inline tags so a host app can attach
custom interactions — Slack-style user popovers, hashtag filters, wiki
links — without forking the parser. Tags are rendered as tappable links and,
on tap, routed to your handler as a TagReference — the tag's prefix and
identifier (record ID). This mirrors how [display](url) links work: you get
the routable key, and own the lookup/display side. For @[John Doe](u-2345)
the reference is prefix: "@", identifier: "u-2345"; for the bare @behlool
it is prefix: "@", identifier: "behlool".
PicoMarkdownView(markdown, tagPrefixes: [.mention, .hashtag])
.onTagTap { tag in
// tag.prefix == "@", tag.identifier == "behlool"
showProfilePopover(for: tag.identifier)
}
.onOpenLink { url in
openURL(url) // ordinary [text](url) links still come here
}Two prefixes are registered automatically:
@— user mentions#— hashtags / topics
Pass an explicit set via the tagPrefixes: initializer parameter (forwarded to
the internal MarkdownTokenizer):
PicoMarkdownView(markdown, tagPrefixes: [
.mention, // @
.hashtag, // #
.ticker, // $ (collides with TeX; see below)
.paired(open: "[[", close: "]]") // [[wiki-link]]
])Pass tagPrefixes: [] to disable inline-tag recognition entirely.
| Modifier | Fires when | Payload |
|---|---|---|
.onTagTap { TagReference in } |
a tag is tapped/clicked | TagReference (prefix + identifier) |
.onOpenLink { URL in } |
a regular link is tapped (and tags, if no onTagTap is set — they arrive as a pico-tag:// URL) |
URL |
.onTagHover { (TagReference?, CGRect?) in } |
macOS only — hover enters/exits a tag | TagReference + bounding rect on enter, (nil, nil) on exit |
.onLinkHover { (URL?, CGRect?) in } |
macOS only — hover enters/exits a regular link | URL + bounding rect on enter, (nil, nil) on exit |
.onContentSize { CGSize in } |
rendered content size changes (e.g. streaming adds a line) | content CGSize |
The hover rect is in the text view's coordinate space — anchor a popover against it. On iOS the hover modifiers are no-ops (iOS has no hover).
The $ ticker is not in the defaults because it collides with TeX/KaTeX
inline math ($x = mc^2$). Enable it only when the content does not
contain math.
Left boundary (when an opener counts as one):
A tag opener (@, #, [[, etc.) only fires when the character
immediately preceding it is one of: beginning of input, whitespace, a
hard-stop character (see below), or a trailing-strip character (see
below). ASCII letters, digits, _, -, and + suppress the opener,
so john@example.com, v1.2+rc1, and similar word-continuations do
not become tags. Non-ASCII characters (emoji, CJK, accented letters)
never suppress, so 🎯@user and 张伟@user still recognise the mention.
A practical consequence: adjacent mentions without a separator (@beh@lool)
emit one tag followed by plain text, not two tags — matching how Slack,
Discord, and Twitter render mentions. Use a space or punctuation between
mentions to get two tags.
Paired-delimiter tags ([[wiki]]) are exempt from the left-boundary
suppression — their multi-character opening already provides a natural
boundary — so abc[[wiki]] still matches.
Right boundary (where a tag ends):
A tag ends at the first of:
- Whitespace (any Unicode whitespace, including newline).
- Hard-stop characters (the character stays in the surrounding text):
()[]{}<>"'/\|*_~`. - The opening character of any registered tag prefix.
Six characters may appear inside a tag but are stripped from the trailing
edge: . , : ; ! ?. So @behlool! matches identifier "behlool",
and @john.doe. matches "john.doe" (only the trailing . is stripped).
Everything else flows in — emoji, CJK, accented letters, digits, underscores, hyphens. The host receives the raw identifier and decides what to do with it.
In addition to the bare form, the parser also recognises the markdown-link form with a tag prefix glued on:
@[John Doe](u-2345)
This emits:
Tag(prefix: "@",
identifier: "u-2345",
displayText: "@John Doe",
rawText: "@[John Doe](u-2345)")This decouples the visible name from the lookup key — useful when the identifier is an opaque ID and the display name might contain spaces.
Tag recognition is local within a block and never violates the streaming invariants:
- A tag opener that arrives without its terminator (e.g. chunk ends mid
@behloolor mid@[John Doe]() is buffered; preceding plain text is emitted immediately, the opener waits for the next chunk. - No provisional tag events are emitted that later need correction.
- Verbatim content: no further inline parsing happens inside a tag, so
@**uncloseddoes not destabilise emphasis state later in the document.
Run the bundled tests to exercise streaming and table rendering:
swift test