A SwiftUI-backed Expo module that renders and animates rich text on iOS. Pairs a streaming-aware incremental compiler on the JS side with a Text-based SwiftUI renderer on the native side — designed for LLM token streams, live markdown previews, and typewriter / fade-trail / shader reveal effects.
| iOS | Android | Web |
|---|---|---|
| ✅ (iOS 26+) | ❌ | ❌ |
Important
This is an iOS-only module. The podspec targets iOS 26 and uses SwiftUI TextRenderer plus Metal shaders, so older SDKs won't build. It depends on @expo/ui — the view is hosted inside @expo/ui/swift-ui's Host.
npx expo install expo-rich-text @expo/uiOr with your package manager of choice:
npm install expo-rich-text @expo/ui
# yarn add expo-rich-text @expo/ui
# pnpm add expo-rich-text @expo/ui
# bun add expo-rich-text @expo/uiThis module ships native iOS code and is not compatible with Expo Go — you need a custom development client. After installing, regenerate the native project and rebuild:
npx expo prebuild --clean
npx expo run:ios- Expo SDK 55 or newer
- React Native 0.83+
- iOS 26 deployment target (set
ios.deploymentTargetto"26.0"inapp.json/ thePodfile) @expo/ui~55.0.11 (peer dependency)
import { ExpoRichText } from "expo-rich-text";
export function Hello() {
return (
<ExpoRichText
itemId="hello"
text="# Hello\n\nSome **bold** markdown and `inline code`."
contentType="markdown"
fontSize={17}
lineHeight={24}
/>
);
}Pass the raw Server-Sent Events payload — the module parses data: frames and accumulates text for you.
import { ExpoRichText } from "expo-rich-text";
export function Stream({ sseStream, isStreaming }: Props) {
return (
<ExpoRichText
itemId="assistant-reply"
sseStream={sseStream}
isStreaming={isStreaming}
contentType="markdown"
animationSettings={{ unitMode: "word", wordsPerMinute: 320 }}
onPlaybackStateChange={(e) => console.log(e.nativeEvent.phase)}
/>
);
}When isStreaming flips to false the view finishes revealing whatever text is already buffered, then emits phase: "settled" via onPlaybackStateChange.
ExpoRichTextPreview loops the given text with the configured animation, pausing restartDelayMs between runs. No itemId or streaming plumbing required.
import { ExpoRichTextPreview } from "expo-rich-text";
export function PresetPreview() {
return (
<ExpoRichTextPreview
text="Matrix rain with a shader preset."
contentType="plain"
animationSettings={{ shaderPreset: "matrix", unitMode: "glyph" }}
restartDelayMs={1200}
/>
);
}import {
ExpoRichText,
ExpoRichTextPreview,
DEFAULT_RICH_TEXT_ANIMATION_SETTINGS,
resolveRichTextAnimationSettings,
resolveRichTextSourceText,
resolveSplitRichTextAnimationPreset,
} from "expo-rich-text";Subpath entries let you import lighter slices without pulling in the native view:
| Path | Contents |
|---|---|
expo-rich-text |
Root — components, types, helpers |
expo-rich-text/animation |
Animation settings types, defaults, resolvers |
expo-rich-text/engine |
Incremental compiler, SSE helper, content-type detection |
expo-rich-text/preview |
Just ExpoRichTextPreview and its props |
expo-rich-text/fixtures/cases |
Canonical fixture case list (for tests) |
expo-rich-text/fixtures/*.json |
Raw fixture / scenario JSON |
The main view. Renders text produced by the incremental compiler into a SwiftUI Text (with an optional overlay for shader effects), sized automatically to fit its content.
| Prop | Type | Default | Description |
|---|---|---|---|
itemId |
string |
required | Stable identity. Changing it resets the compiler and starts a fresh reveal. |
text |
string |
— | Full source text. Use either text or sseStream, not both. |
sseStream |
string |
— | Raw SSE body; parsed by resolveRichTextSourceText. |
isStreaming |
boolean |
false |
Keeps the reveal animation primed. Flip to false to let it settle. |
contentType |
"plain" | "markdown" | "auto" |
"auto" |
Parser selection. "auto" sniffs common markdown syntax. |
fontFamily |
string |
system | Applied uniformly. Code spans / blocks always use a monospaced font. |
fontSize |
number |
17 |
Body font size (pt). |
lineHeight |
number |
22 |
Line height (pt) — passed to SwiftUI as line spacing. |
codeFontSize |
number |
15 |
Font size for inline code and fenced code blocks. |
textColor |
string |
system label | Any CSS-style color string ("#rrggbb", "rgba(...)", named). |
blockquoteAccentColor |
string |
system label dim | Left accent bar on > blockquote lines. |
codeBackgroundColor |
string |
system secondary fill | Background of inline code and fenced code blocks. |
selectable |
boolean |
true |
When true, long-press to select / copy. |
animationSettings |
RichTextEngineSettings |
see DEFAULT_RICH_TEXT_ANIMATION_SETTINGS |
Partial override of reveal / shader configuration. |
style |
ViewStyle |
— | Forwarded to the hosting @expo/ui container. |
modifiers |
CommonViewModifierProps["modifiers"] |
— | Apply SwiftUI modifiers from @expo/ui/swift-ui/modifiers. |
testID |
string |
— | Forwarded to the native view. |
All events fire with a standard NativeSyntheticEvent.
| Event | Payload | Fires |
|---|---|---|
onHeightChange |
{ height: number } |
When the rendered content height changes (use for row sizing). |
onRevealProgress |
{ revealedCount: number } |
On each reveal tick while animating. |
onRevealStateChange |
{ active: boolean, revealedCount: number } |
When the reveal animation starts / stops. |
onPlaybackStateChange |
{ phase: RichTextPlaybackPhase, revealedCount: number } |
On playback phase transitions ("idle" | "revealing" | "settling" | "settled"). |
onLinkPress |
{ href: string } |
When a markdown link span is tapped. |
Wraps ExpoRichText with a loop controller — useful for picking animation presets in settings.
type RichTextPreviewProps = Omit<
RichTextViewProps,
"itemId" | "text" | "sseStream" | "isStreaming"
| "onPlaybackStateChange" | "onRevealStateChange"
> & {
text: string;
restartDelayMs?: number; // default 1400
isEnabled?: boolean; // default true — set false to show static text
};When isEnabled is false the preview renders text statically (no animation, no loop).
All animation behaviour is controlled by a single RichTextEngineSettings object (a Partial<RichTextAnimationSettings>). Unspecified fields fall back to DEFAULT_RICH_TEXT_ANIMATION_SETTINGS.
type RichTextAnimationSettings = {
enabled: boolean; // master toggle — false renders static
revealPreset: "typewriter" | "fade-trail";
shaderPreset:
| "none"
| "ember" | "matrix" | "neon" | "ghost" | "smoke" | "disintegrate"
| "shader-glow" | "shader-wave" | "shader-crt" | "shader-noise";
shaderStrength: number; // 0..1
effectColor: string; // CSS color; "" inherits textColor
smoothReveal: boolean; // easing between reveal steps
smoothNewLine: boolean; // fade in new lines as a group
unitMode: "glyph" | "word" | "token";
unitsPerStep: number; // 1..12
unitsPerSecond: number; // 1..80 (used when unitMode !== "word")
wordsPerMinute: number; // 40..720 (used when unitMode === "word")
fadeDurationMs: number; // 0..1200
fadeStartOpacity: number; // 0.05..1
cursorEnabled: boolean;
cursorGlyph: string; // default "▍"
tailLength: number; // 1..12 — size of the fading trail
};Defaults:
const DEFAULT_RICH_TEXT_ANIMATION_SETTINGS = {
enabled: true,
revealPreset: "fade-trail",
shaderPreset: "none",
shaderStrength: 1,
effectColor: "",
smoothReveal: false,
smoothNewLine: false,
unitMode: "glyph",
unitsPerStep: 2,
unitsPerSecond: 18,
wordsPerMinute: 220,
fadeDurationMs: 180,
fadeStartOpacity: 0.22,
cursorEnabled: true,
cursorGlyph: "▍",
tailLength: 4,
};| Function | Purpose |
|---|---|
resolveRichTextAnimationSettings(partial?) |
Merge + clamp a partial settings object into a fully-resolved RichTextAnimationSettings. |
resolveSplitRichTextAnimationPreset(preset) |
Map a single RichTextAnimationPreset (e.g. "matrix") to the pair { revealPreset, shaderPreset }. |
resolveRichTextSourceText({ text, sseStream }) |
Derive the effective source string — prefers sseStream when set, otherwise returns text ?? "". |
Exported from the root entry:
RichTextViewProps,RichTextPreviewPropsRichTextContentType,RichTextPlaybackPhaseRichTextEngineSettingsRichTextAnimationSettings,RichTextAnimationPreset,RichTextAnimationRevealPreset,RichTextAnimationShaderPreset,RichTextAnimationUnitMode- Event payloads:
RichTextHeightEvent,RichTextRevealProgressEvent,RichTextRevealStateEvent,RichTextPlaybackStateEvent,RichTextLinkPressEvent
Role-specific tinting is intentionally not part of the public API. Use the explicit color props (textColor, blockquoteAccentColor, codeBackgroundColor) when you need to override the default styling — there is no global theme hook.
Supported markdown constructs:
- Headings (
# … ######) - Paragraphs with soft/hard line breaks
**bold**,*italic*,inline code,~~strikethrough~~,[links](https://…)> blockquotes(single- and multi-line)-/*/+bullet lists and1.ordered lists- Fenced code blocks (
lang …)
ExpoRichText uses an incremental compiler (RichTextIncrementalCompiler — exported from expo-rich-text/engine) that emits one of three update kinds to native:
- reset — full document replaced (first render, or non-prefix edits)
- append — new tail added to an already-stable prefix (the common streaming case)
- replace — prefix preserved, suffix swapped (rare edits that alter only the tail)
This means only the appended bytes cross the bridge during a stream, and the SwiftUI side can animate just the new units.
MIT — see package.json. Issues and PRs: https://github.com/PanicIsReal/expo-rich-text/issues.