flutter_streaming_text_markdown 1.11.0
flutter_streaming_text_markdown: ^1.11.0 copied to clipboard
A Flutter package for beautiful LLM text streaming with markdown support. Perfect for ChatGPT-like interfaces with typing animations, RTL support, and customizable effects.
Flutter Streaming Text Markdown #
Perfect for LLM Applications! A Flutter package optimized for beautiful AI text streaming with ChatGPT and Claude-style animations.
โจ Features #
- ๐ค LLM Optimized - Built specifically for ChatGPT, Claude, and AI text streaming
- ๐ฎ Programmatic Control - Pause, resume, skip, and restart animations
- โก Ready-to-Use Presets - ChatGPT, Claude, typewriter, and more animation styles
- ๐ Markdown Support - Full markdown rendering with streaming animations
- ๐ข LaTeX Support - Mathematical expressions and formulas with proper rendering
- ๐ RTL Support - Comprehensive right-to-left language support
- ๐ญ Multiple Animation Types - Character-by-character, word-by-word, and chunk-based
- โฑ๏ธ Real-time Streaming - Direct
Stream<String>integration - ๐ฏ Interactive Controls - Tap-to-skip and programmatic control
- โฟ Accessibility - Reduced-motion support, single-announcement semantics, and selectable text
- โ ๏ธ Stream error handling -
errorBuilderplus a controller error state, with revealed text kept on screen
๐ฌ Demo #
Full-quality video: doc/demo.mp4
โถ๏ธ Try the live web demo โ or run it locally: cd example && flutter run
The example app walks through every feature: presets (ChatGPT / Claude / typewriter), live Stream<String> input, markdown & LaTeX rendering, RTL/Arabic, theming, and the animation controller.
Installation #
Add this to your package's pubspec.yaml file:
dependencies:
flutter_streaming_text_markdown: ^1.11.0
๐ Quick Start #
ChatGPT-Style Streaming #
StreamingTextMarkdown.chatGPT(
text: '''# Flutter Development Tips
**1. State Management**
- Use **Provider** for simple apps
- **Riverpod** for complex state
- **BLoC** for enterprise applications
**2. Performance**
- Use `const` constructors
- Implement `ListView.builder` for long lists
- Avoid unnecessary widget rebuilds''',
)
Claude-Style Streaming #
StreamingTextMarkdown.claude(
text: '''# Understanding Flutter Architecture
I'd be happy to explain Flutter's widget tree and how it impacts performance.
## Widget Tree Fundamentals
Flutter's architecture revolves around three core trees:
- **Widget Tree**: Configuration and description
- **Element Tree**: Lifecycle management
- **Render Tree**: Layout and painting
This separation enables Flutter's excellent performance...''',
)
Programmatic Control #
final controller = StreamingTextController();
StreamingTextMarkdown.claude(
text: llmResponse,
controller: controller,
onComplete: () => print('Streaming complete!'),
)
// Control the animation
ElevatedButton(
onPressed: controller.isAnimating ? controller.pause : controller.resume,
child: Text(controller.isAnimating ? 'Pause' : 'Resume'),
)
ElevatedButton(
onPressed: controller.skipToEnd,
child: Text('Skip to End'),
)
๐ค Streaming from an LLM API (OpenAI, Anthropic, Ollama, โฆ) #
For real LLM chat UIs where tokens arrive over HTTP/SSE, pass a Stream<String> straight into StreamingTextMarkdown. Each yielded chunk is appended to the rendered text and animated. Markdown and LaTeX are re-parsed as the buffer grows.
import 'package:flutter_streaming_text_markdown/flutter_streaming_text_markdown.dart';
StreamingTextMarkdown(
stream: chatService.streamReply(prompt), // your Stream<String>
markdownEnabled: true,
latexEnabled: true,
trailingFadeEnabled: true, // recommended for streams
onComplete: () => setState(() => _isStreaming = false),
)
stream:input defaults toRevealMode.smoothFade(word-unit reveal + fade) paced byStreamPacing.catchUp()(smooths bursty token arrivals instead of a fixedtypingSpeed) โtypingSpeedhas no effect on stream input unless you passrevealMode: nullor an explicitpacing: StreamPacing.fixed(...). See Configuration below.
The preset constructors (
StreamingTextMarkdown.chatGPT(stream: ...),.claude(stream: ...), etc.) acceptstream:too. If you need lower-level control (no auto-scroll, no shimmer, no theme resolution), the underlyingStreamingTextwidget is also exported.
TTFT shimmer (Time-To-First-Token) #
Show a skeleton while you wait for the first token, then swap to the streamed widget:
StreamingTextMarkdown(
text: _buffer,
isLoading: _waitingForFirstToken, // shimmer while true
trailingFadeEnabled: true,
)
Handling stream errors #
If the Stream<String> you pass emits an error, the text revealed so far
stays on screen either way. Pass errorBuilder to render your own view; the
controller (if any) also transitions to StreamingTextState.error via
markError, and controller.error exposes the error object:
StreamingTextMarkdown(
stream: chatService.streamReply(prompt),
controller: controller,
errorBuilder: (context, error) => Row(
children: [
const Icon(Icons.error_outline),
const SizedBox(width: 8),
Expanded(child: Text('Something went wrong: $error')),
],
),
)
Without an errorBuilder, the default view shows the text revealed so far
plus a trailing Error: $error line in Theme.of(context).colorScheme.error.
Bridging OpenAI / Anthropic SSE to Stream<String> #
Most LLM HTTP APIs return Server-Sent Events. Convert their token stream to a plain Stream<String> of text deltas โ then hand it to StreamingText.
// OpenAI chat completions (stream: true) โ yield content deltas
Stream<String> openAiChat(String prompt) async* {
final req = http.Request('POST', Uri.parse('https://api.openai.com/v1/chat/completions'))
..headers.addAll({
'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json',
})
..body = jsonEncode({
'model': 'gpt-4o-mini',
'stream': true,
'messages': [{'role': 'user', 'content': prompt}],
});
final res = await http.Client().send(req);
await for (final line in res.stream.transform(utf8.decoder).transform(const LineSplitter())) {
if (!line.startsWith('data: ')) continue;
final payload = line.substring(6);
if (payload == '[DONE]') break;
final delta = (jsonDecode(payload)['choices'][0]['delta']['content']) as String?;
if (delta != null && delta.isNotEmpty) yield delta;
}
}
// Anthropic Messages API (stream: true) โ yield content_block_delta text
Stream<String> anthropicChat(String prompt) async* {
final req = http.Request('POST', Uri.parse('https://api.anthropic.com/v1/messages'))
..headers.addAll({
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
})
..body = jsonEncode({
'model': 'claude-sonnet-4-5',
'max_tokens': 1024,
'stream': true,
'messages': [{'role': 'user', 'content': prompt}],
});
final res = await http.Client().send(req);
await for (final line in res.stream.transform(utf8.decoder).transform(const LineSplitter())) {
if (!line.startsWith('data: ')) continue;
final json = jsonDecode(line.substring(6));
if (json['type'] == 'content_block_delta') {
final text = json['delta']?['text'] as String?;
if (text != null && text.isNotEmpty) yield text;
}
}
}
fadeInEnabledis safe for streams too: the per-character fade is driven by a singleTicker(not oneAnimationControllerper glyph), so it has constant memory regardless of stream length. It only applies in plain-text mode though; markdown content (the common case for LLM output) already gets a paint-only per-word fade viaMarkdownFadeMaskin the defaultsmoothFade/wordFadereveal modes, andtrailingFadeEnabledcan add a bottom-edge gradient on top.
When to use which widget #
| Use case | Widget |
|---|---|
Default โ static String or Stream<String> with auto-scroll, theme, and TTFT shimmer |
StreamingTextMarkdown (or its .chatGPT/.claude/.typewriter/.instant presets) |
| Lower-level control โ no auto-scroll, no shimmer, no theme inheritance | StreamingText |
Both widgets accept the same text: / stream: pair. Pick StreamingTextMarkdown unless you need to opt out of the convenience scaffolding.
๐จ Animation Presets #
Built-in Constructors #
| Constructor | Speed | Style | Best For |
|---|---|---|---|
.chatGPT() |
Fast (15ms) | Character-by-character with fade | ChatGPT-like responses |
.claude() |
Smooth (80ms) | Word-by-word with gentle fade | Claude-like detailed explanations |
.typewriter() |
Classic (50ms) | Character-by-character, no fade | Retro typewriter effect |
.instant() |
Immediate | No animation | When speed is priority |
Custom Presets #
// Using preset configurations
StreamingTextMarkdown.fromPreset(
text: response,
preset: LLMAnimationPresets.professional,
)
// Available presets
LLMAnimationPresets.chatGPT // Fast, character-based
LLMAnimationPresets.claude // Smooth, word-based
LLMAnimationPresets.typewriter // Classic typing
LLMAnimationPresets.gentle // Slow, elegant
LLMAnimationPresets.bouncy // Playful bounce effect
LLMAnimationPresets.chunks // Fast chunk-based
LLMAnimationPresets.rtlOptimized // Optimized for Arabic/RTL
LLMAnimationPresets.professional // Business presentations
// Speed-based presets
LLMAnimationPresets.bySpeed(AnimationSpeed.fast)
LLMAnimationPresets.bySpeed(AnimationSpeed.medium)
LLMAnimationPresets.bySpeed(AnimationSpeed.slow)
๐ฎ Controller API #
final controller = StreamingTextController();
// Control methods
controller.pause(); // Pause animation
controller.resume(); // Resume from pause
controller.restart(); // Start over
controller.skipToEnd(); // Jump to end
controller.stop(); // Stop and reset
// State monitoring
controller.isAnimating; // Currently running?
controller.isPaused; // Currently paused?
controller.isCompleted; // Animation finished?
controller.progress; // Progress (0.0 to 1.0)
controller.state; // Current state enum
// Callbacks
controller.onStateChanged((state) => print('State: $state'));
controller.onProgressChanged((progress) => print('Progress: $progress'));
controller.onCompleted(() => print('Finished!'));
// Speed control โ divides typingSpeed, so higher is faster
controller.speedMultiplier = 2.0; // 2x speed (half the typingSpeed duration)
controller.speedMultiplier = 0.5; // Half speed (double the typingSpeed duration)
// Error handling โ set when a stream's Stream<String> emits an error
controller.markError(someError);
controller.error; // The Object passed to markError, or null
onCompleted fires at most once per revealingโcomplete transition,
however it was triggered โ updateProgress(1.0), markCompleted(), and
skipToEnd() all route through the same completion latch, so wiring more
than one of them (or calling one twice) never double-fires your callback.
restart()/stop() re-arm it for the next cycle.
โ๏ธ Configuration #
StreamingTextMarkdown Parameters #
| Property | Type | Description |
|---|---|---|
text |
String |
The text content to display. Optional โ defaults to '' so you can pass only stream: when streaming from an LLM. |
stream |
Stream<String>? |
Optional stream of text chunks from an LLM API. When non-null, content arrives via the stream instead of text. |
controller |
StreamingTextController? |
Controller for programmatic control |
onComplete |
VoidCallback? |
Callback when animation completes |
completeAnimationOnTap |
bool |
Whether tapping the widget jumps the animation to completion. Defaults to true; set false to let it play through regardless of taps. |
revealMode |
RevealMode? |
How revealed text arrives on screen: {smoothFade, wordFade, typewriter, instant}. Defaults to RevealMode.smoothFade on this constructor, .chatGPT() and .claude(); .typewriter()/.instant() default to their own matching mode. Pass revealMode: null to opt out entirely and use the legacy typingSpeed/wordByWord/fadeInEnabled/fadeInDuration/fadeInCurve/chunkSize parameters below instead โ see doc/MIGRATION.md. |
pacing |
StreamPacing? |
How a Stream<String> (or static text) source is paced: StreamPacing.catchUp(...) (the default for stream:, smoothing bursty token arrivals) or StreamPacing.fixed(typingSpeed) (the default for static text, and the pre-2.0 behavior). An explicit value always overrides the default. |
typingSpeed |
Duration |
Speed of typing animation. Only meaningful with revealMode: null, .typewriter(), or an explicit StreamPacing.fixed(...). |
wordByWord |
bool |
Whether to animate word by word. Ignored unless revealMode: null (smoothFade/wordFade always reveal word-by-word; .typewriter()/.instant() always reveal by chunkSize). |
chunkSize |
int |
Number of characters to reveal at once. Ignored in word-unit modes. |
fadeInEnabled |
bool |
Legacy per-character fade-in, opacity-only, driven by a single Ticker (constant memory). Only applies in plain-text mode (markdownEnabled: false), only takes effect with revealMode: null, and is suppressed for Arabic/RTL. Use revealMode: RevealMode.smoothFade (the default) for a fade that also covers Arabic and markdown content (markdown fades paint-only, per word, via MarkdownFadeMask). |
fadeInDuration |
Duration |
Duration of fade-in animation (also used for trailing-fade dismiss) |
trailingFadeEnabled |
bool |
Bottom-edge gradient fade while streaming. Animates away on completion. Recommended for Stream<String> and markdown content. |
textDirection |
TextDirection? |
Text direction (LTR or RTL) |
textAlign |
TextAlign? |
Text alignment |
markdownEnabled |
bool |
Enable markdown rendering |
latexEnabled |
bool |
Enable LaTeX mathematical expressions |
latexStyle |
TextStyle? |
Style for LaTeX expressions |
latexScale |
double |
Scale factor for LaTeX rendering |
latexFadeInEnabled |
bool? |
Deprecated, no-op. LaTeX rendering is delegated to gpt_markdown, which has no per-run fade hook; use latexBuilder instead. |
imageBuilder |
Widget Function(BuildContext, String)? |
Custom widget for markdown images |
onLinkTap |
void Function(String url, String title)? |
Callback when a link is tapped |
codeBuilder |
Widget Function(BuildContext, String name, String code, bool closed)? |
Custom widget for code blocks |
latexBuilder |
Widget Function(BuildContext, String tex, TextStyle, bool inline)? |
Custom widget for LaTeX expressions |
linkBuilder |
Widget Function(BuildContext, InlineSpan label, String path, TextStyle)? |
Custom widget for links |
components |
List<MarkdownComponent>? |
Deprecated โ use markdownOptions.blockComponents. Block-level component overrides. Passing this at all (even an empty list) drops gpt_markdown's incremental segment cache. |
inlineComponents |
List<MarkdownComponent>? |
Deprecated โ use markdownOptions.inlinePatterns. Inline-level component overrides, with the same segment-cache cost as components. |
markdownOptions |
MarkdownRenderOptions? |
Bundles every gpt_markdown 1.3 pass-through without its own top-level parameter โ see MarkdownRenderOptions below. |
selectable |
bool |
Wraps the rendered output in a SelectionArea so users can select/copy text. Defaults to false. Tap-to-complete still works. |
showCursor |
bool? |
Shows an 8px pulsing dot caret while revealing. null (default) resolves to stream != null โ on for live streams, off for static text. Hidden automatically on completion. |
cursorColor |
Color? |
Color of the caret shown while showCursor resolves to true. Defaults to the theme's text-primary token. |
semanticsLabel |
String? |
Accessibility label announced/exposed to assistive technology instead of the revealed text itself โ see Accessibility below. |
errorBuilder |
Widget Function(BuildContext, Object error)? |
Called when stream emits an error โ see Handling stream errors below. |
Choosing a fade for streaming content
The default revealMode: RevealMode.smoothFade already fades plain text,
Stream<String> input, markdown content, AND Arabic/RTL content. Plain
text fades via a single Ticker regardless of how much text there is (at
most 2 transient tickers); markdown content gets a paint-only per-word
fade via MarkdownFadeMask with no GptMarkdown rebuilds (disabled
under reduced motion or when animationsEnabled is false). The table
below compares the defaults with the legacy (revealMode: null) fade
parameters:
| Source | Recommended | Why |
|---|---|---|
Static or streamed text, markdown off |
revealMode: RevealMode.smoothFade (default) or fadeInEnabled: true with revealMode: null |
Single-ticker fade, looks great |
| Arabic/RTL content | revealMode: RevealMode.smoothFade (default) |
The legacy fadeInEnabled (revealMode: null) is suppressed for Arabic (shaping risk); smoothFade isn't |
| Markdown-enabled content | revealMode: RevealMode.smoothFade (default) |
MarkdownFadeMask applies a paint-only per-word fade by default; add trailingFadeEnabled: true for a bottom-edge gradient on top |
Markdown Support #
The widget supports common markdown syntax:
- Headers (
#,##,###) - Bold text (
**text**or__text__) - Italic text (
*text*or_text_) - Lists (ordered and unordered)
- Line breaks
๐งฉ MarkdownRenderOptions #
components / inlineComponents are deprecated because passing either one
(even an empty list) drops gpt_markdown's incremental segment cache.
markdownOptions bundles every other gpt_markdown 1.3 pass-through โ style
sheet, per-component builders, autolink config, and more โ without that
cost:
StreamingTextMarkdown(
text: llmResponse,
markdownOptions: MarkdownRenderOptions(
styleSheet: GptMarkdownStyleSheet(
inlineCode: InlineCodeStyle(color: Colors.deepPurple),
),
autolink: true,
maxLines: 200,
// Recolour link labels without changing anything else about how
// links render โ see LinkBuildDetails.defaultSpan/.asWidgetSpan.
inlineLinkBuilder: (link) => link.defaultSpan(),
),
)
Every field maps 1:1 onto a GptMarkdown constructor parameter of the same
name; a null field simply falls back to gpt_markdown's own default. See
the class docs on MarkdownRenderOptions for the full field list โ
styleSheet, inlineCodeStyle, the block-level builders (headingBuilder,
tableBuilder, blockQuoteBuilder, orderedListBuilder,
unOrderedListBuilder, hrBuilder, checkboxBuilder,
radioOptionBuilder), the on* callbacks, autolink/autolinkSchemes,
maxLines/overflow/followLinkColor, blockComponents/inlinePatterns/
inlineDirectives, the inline*Builders, imageBuilder, and
useDollarSignsForLatex.
๐ข LaTeX Support #
The package includes comprehensive LaTeX support for mathematical expressions and formulas, perfect for educational content, scientific documentation, and technical explanations.
Basic LaTeX Usage #
StreamingTextMarkdown(
text: '''# Mathematical Equations
Inline equations work great: \$E = mc^2\$ and \$x = 5\$.
Block equations are perfect for complex formulas:
\$\$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}\$\$
This is the quadratic formula!''',
latexEnabled: true,
markdownEnabled: true,
)
LaTeX Configuration #
StreamingTextMarkdown(
text: 'Mathematical content with \$x^2 + y^2 = z^2\$',
latexEnabled: true, // Enable LaTeX rendering
latexStyle: TextStyle( // Style for LaTeX expressions
color: Colors.blue,
fontSize: 18,
),
latexScale: 1.2, // Scale factor for LaTeX
markdownEnabled: true,
)
latexFadeInEnabled(both the widget parameter andStreamingTextTheme.latexFadeInEnabled) is deprecated and now a no-op โ LaTeX rendering is delegated togpt_markdown, which has no per-run fade hook of its own. Use alatexBuilderif you need to control LaTeX rendering directly.
LaTeX Theme Support #
// Global LaTeX styling through theme
final customTheme = StreamingTextTheme(
inlineLatexStyle: TextStyle(color: Colors.blue),
);
StreamingTextMarkdown(
text: 'Themed math: \$\\alpha + \\beta = \\gamma\$',
theme: customTheme,
latexEnabled: true,
)
Supported LaTeX Features #
Inline Math: $x = 5$, $E = mc^2$, $\alpha + \beta$
Block Math:
$$\sum_{i=1}^{n} i = \frac{n(n+1)}{2}$$
Common Symbols:
- Greek letters:
\alpha,\beta,\gamma,\pi,\sigma - Operations:
\pm,\cdot,\times,\div,\neq - Relations:
\leq,\geq,\approx,\equiv - Fractions:
\frac{a}{b} - Powers:
x^2,a^{n+1} - Subscripts:
x_1,a_{i,j} - Roots:
\sqrt{x},\sqrt[3]{x}
Advanced Features:
- Integrals:
\int_0^1 x dx - Summations:
\sum_{i=1}^n x_i - Matrices:
\begin{matrix} a & b \\ c & d \end{matrix} - Derivatives:
\frac{d}{dx}[f(x)]
LaTeX Animation Behavior #
- LaTeX expressions are treated as atomic units during streaming โ the
cursor never lands strictly inside a
$โฆ$/$$โฆ$$/\(โฆ\)/\[โฆ\]span - They appear completely when their turn comes in the animation
- Per-run fade is suppressed for LaTeX spans by default for performance
- Works seamlessly with word-by-word and character-by-character modes
Performance Tips #
- Mix with regular text: Combine LaTeX with markdown for rich content
$VARS-style text in code fences is safe: content inside fenced or inline code is never mistaken for LaTeX, even when it looks like a dollar-sign variable
Example: Scientific Documentation #
StreamingTextMarkdown.claude(
text: '''# Physics Fundamentals
## Newton's Laws
Newton's second law states that force equals mass times acceleration:
\$\$F = ma\$\$
## Energy Conservation
The relationship between kinetic and potential energy:
\$\$KE + PE = \\text{constant}\$\$
Where kinetic energy is \$KE = \\frac{1}{2}mv^2\$ and potential energy varies by system.
## Wave Equation
The fundamental wave equation in physics:
\$\$\\frac{\\partial^2 y}{\\partial t^2} = \\frac{1}{v^2}\\frac{\\partial^2 y}{\\partial x^2}\$\$
This describes how waves propagate through different media.''',
latexEnabled: true,
)
RTL Support #
For right-to-left languages:
StreamingTextMarkdown(
text: '''# ู
ุฑุญุจุงู ุจูู
! ๐
ูุฐุง **ุนุฑุถ ุชูุถูุญู** ูููุต ุงูู
ุชุฏูู.''',
textDirection: TextDirection.rtl,
textAlign: TextAlign.right,
)
Styling and Theming #
Using the Theme System #
The package now supports a professional theme system that allows you to customize both normal text and markdown styling:
// Create a custom theme
final customTheme = StreamingTextTheme(
textStyle: TextStyle(fontSize: 16, color: Colors.blue),
markdownStyleSheet: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: Colors.black87,
),
defaultPadding: EdgeInsets.all(20),
);
// Apply theme to a single widget
StreamingTextMarkdown(
text: '# Hello\nThis is a test',
theme: customTheme,
)
// Or apply globally through your app's theme
MaterialApp(
theme: ThemeData(
extensions: [
StreamingTextTheme(
textStyle: TextStyle(/* ... */),
markdownStyleSheet: TextStyle(/* ... */),
),
],
),
// ...
)
Theme Inheritance #
The theme system follows Flutter's standard inheritance pattern:
- Widget-level theme (if provided)
- Global theme extension
- Default theme based on the current context
โฟ Accessibility #
StreamingTextMarkdown(
stream: chatService.streamReply(prompt),
semanticsLabel: 'Assistant response',
selectable: true,
)
- Reduced motion: when the platform's reduce-motion setting is on
(
MediaQuery.maybeDisableAnimationsOf), text reveals instantly with a static caret and no fade โ no extra configuration needed. - Semantics: partial, mid-stream text is excluded from the accessibility
tree, and assistive technology gets exactly one announcement โ of the
full revealed text, or of
semanticsLabelwhen you set one โ on completion. - Selectable text:
selectable: truewraps the rendered output in aSelectionArea. Tap-to-complete keeps working alongside it. - Caret:
showCursor(nullby default, resolving tostream != null) shows an 8px pulsing dot while revealing, styled bycursorColor(falls back to the theme's text-primary token) and hidden automatically once complete, so the final text always matches the source exactly โ including in markdown mode.
Contributing #
Contributions are welcome! Please feel free to submit a Pull Request.
License #
This project is licensed under the MIT License - see the LICENSE file for details.