flutter_markdown_stream 0.5.0 copy "flutter_markdown_stream: ^0.5.0" to clipboard
flutter_markdown_stream: ^0.5.0 copied to clipboard

Flicker-free streaming Markdown widget for Flutter. Renders partial LLM token streams progressively, handling unclosed syntax gracefully.

flutter_markdown_stream #

pub package license

Flicker-free, crash-safe streaming Markdown widget for Flutter. Drop it into your chat UI, point it at a Stream<String> of LLM token chunks, and get smooth, progressive Markdown rendering — even when the syntax is half-typed.

Demo #

MarkdownStream rendering a streamed LLM response with a blinking cursor

A simulated LLM response streamed token-by-token. Bold, italic, fenced code, lists, and links all render progressively without flicker, even while their closing syntax is still in flight.

Why? #

flutter_markdown_plus parses each rebuild from scratch. When an LLM emits **bold before the closing **, or opens a ```dart fence before the rest of the code arrives, the widget either throws, flashes, or renders the remainder of your document as code until the closing token appears.

flutter_markdown_stream fixes that with a small sanitizer that projects the current buffer into a syntactically-safe form at render time, then falls back to the raw buffer once the stream completes.

Features #

  • Handles unclosed bold, italic, strikethrough, inline code, fenced code blocks, autolinks, inline links, incomplete GFM tables, and LaTeX delimiters mid-stream — with CommonMark-correct flanking rules, so bullet lists, thematic breaks, and stray */$ in prose are never mangled.
  • One-frame debounce coalesces bursts of tokens into a single rebuild, plus optional output smoothing that paces bursty tokens into an even, ChatGPT/Claude-style flow.
  • Opt-in AutoScroll stick-to-bottom for chat UIs, modelled on Claude mobile (follows the bottom, disengages when you scroll up, never yanks).
  • Ready-made CodeBlockView with a language label and copy button, or bring your own codeBuilder.
  • Optional incremental parsing for long answers — settled blocks are parsed once, not re-parsed every frame.
  • Eight cursor widgets, all screen-reader friendly (ExcludeSemantics + optional semanticLabel).
  • onTextChanged for live progress, and a StreamingTextController for pause / resume / skip / restart.
  • Pure Dart sanitizer — fully unit-tested, zero platform channels.

Install #

dependencies:
  flutter_markdown_stream: ^0.5.0

Usage #

import 'package:flutter/material.dart';
import 'package:flutter_markdown_stream/flutter_markdown_stream.dart';

class ChatBubble extends StatelessWidget {
  const ChatBubble({super.key, required this.stream});
  final Stream<String> stream;

  @override
  Widget build(BuildContext context) {
    return MarkdownStream(
      stream: stream,
      onDone: (fullText) => debugPrint('Finished: $fullText'),
      cursorWidget: const BlinkingCursor(),
      styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)),
      codeBuilder: (code, language) => Container(
        padding: const EdgeInsets.all(12),
        decoration: BoxDecoration(
          color: Colors.black87,
          borderRadius: BorderRadius.circular(8),
        ),
        child: Text(
          code,
          style: const TextStyle(
            color: Colors.greenAccent,
            fontFamily: 'monospace',
          ),
        ),
      ),
    );
  }
}

Streaming feel & chat-UI helpers (0.5.0) #

Smooth by default #

Since 0.5.0, MarkdownStream smooths and word-fades out of the box — the ChatGPT/Claude feel with no configuration. For instant, un-animated text:

MarkdownStream(
  stream: llmStream,
  wordFadeIn: false,
  config: StreamingPresets.instant,
);

Per-word fade-in (wordFadeIn) #

The words in the paragraph currently being streamed fade in (opacity only, no blur) as they arrive — settled text stays full-fidelity, and everything is opaque once the stream completes.

MarkdownStream(stream: llmStream);                 // on by default
MarkdownStream(stream: llmStream, wordFadeWindow: 6); // longer, softer fade

It applies to prose (with inline bold/italic/code/links); code blocks, tables, lists, headings, and blockquotes stream without a fade.

Output smoothing #

Debounce only coalesces bursty tokens; smoothing paces them, so text flows at a steady rate no matter how lumpy the network delivery is. It's on by default; tune or swap the feel with a config or preset:

MarkdownStream(stream: llmStream, config: StreamingPresets.claude); // or .chatGPT / .smooth
MarkdownStream(
  stream: llmStream,
  config: const StreamingTextConfig(smoothingEnabled: true, charsPerSecond: 140),
);

Auto-scroll (opt-in, Claude-mobile behaviour) #

Nothing scrolls unless you ask. Wrap your streaming content in AutoScroll and it follows the bottom while you're already there, disengages the instant you scroll up to read, and re-engages when you return — it never yanks you down.

Following is automatic: AutoScroll watches the scrollable's ScrollMetricsNotification, so it re-follows every time the content grows — including while a MarkdownStream streams tokens inside it. You do not wire up a trigger.

AutoScroll(
  child: Column(
    children: [
      for (final message in messages) MessageBubble(message),
      MarkdownStream(stream: llmStream), // grows → AutoScroll follows
    ],
  ),
);

Set enabled: false at any time to freeze following without unwrapping.

StickToBottomController

AutoScroll owns its scroll + stick-to-bottom controllers internally. Supply your own StickToBottomController when you need to read its state or drive it from elsewhere in the tree — e.g. to render a "jump to latest" button:

final stick = StickToBottomController();

AutoScroll(controller: stick, child: messageColumn);

// Elsewhere: show a button only while the user has scrolled away.
ValueListenableBuilder<bool>(
  valueListenable: stick.showScrollToBottomButtonListenable,
  builder: (context, show, _) => show
      ? FloatingActionButton.small(
          onPressed: stick.jumpToBottom, // not gated by `enabled`
          child: const Icon(Icons.arrow_downward),
        )
      : const SizedBox.shrink(),
);

Key surface:

Member What it does
bool get isPinnedToBottom Whether the view is within threshold (default 32px) of the bottom. Tracks reality even while enabled == false.
pinnedToBottomListenable ValueListenable<bool> of the above.
showScrollToBottomButtonListenable ValueListenable<bool>true only when enabled and not pinned. Render your own button from it.
follow({animate}) Follow to the bottom if enabled and currently pinned. Called automatically on content growth.
jumpToBottom() / animateToBottom() Imperative scroll to bottom and re-pin. Not gated by enabled (this is a user tapping "jump").
enabled (get/set) Runtime toggle. false = transparent passthrough: no following, no button state, never fights the user.

You can also attach a StickToBottomController to your own ScrollView (pass its scrollController) instead of using AutoScroll.

Code blocks with a copy button #

MarkdownStream(
  stream: llmStream,
  codeBuilder: CodeBlockView.builder(), // language label + copy button
);

For syntax highlighting, pass a highlightBuilder — bring your own tokenizer (e.g. package:highlight / package:flutter_highlight) so this package stays dependency-free:

CodeBlockView.builder(
  highlightBuilder: (code, language, baseStyle) {
    // return an InlineSpan (TextSpan tree) with per-token colours
    return myHighlighter.toTextSpan(code, language, baseStyle);
  },
);

Code blocks render full width (with the copy control at the top-right) when the surrounding MarkdownStream fills its width — pass fitContent: false if your layout otherwise shrink-wraps.

Incremental parsing (long answers) #

Re-parsing the whole buffer every frame is O(n²) over a long response. Turn on incremental parsing to parse settled blocks once and reuse them — the settled output is identical, it's a pure performance switch.

MarkdownStream(stream: llmStream, incrementalParsing: true);

Accessible cursors #

MarkdownStream(
  stream: llmStream,
  cursorWidget: const TypingDotsCursor(semanticLabel: 'Assistant is typing'),
);

Edge cases handled #

Case Input mid-stream Rendered safely as
Unclosed bold This is **bold This is **bold**
Unclosed italic (* or _) so *cool so *cool*
Unclosed fenced code ```dart\nfinal x ```dart\nfinal x\n```
Unclosed inline code run \dart test` run \dart test``
Partial inline link see [docs](ht see
Partial autolink visit <https://exa visit
Unclosed strikethrough gone ~~away gone ~~away~~
Intra-word underscore snake_case_var left alone
CRLF / lone CR line endings a\r\nb a\nb

See test/safe_markdown_parser_test.dart for the exhaustive matrix.

Architecture #

The widget keeps two strings:

  1. Raw buffer — the exact concatenation of every chunk received. This is what onDone reports and what's used for the final render once the stream closes.
  2. Rendered projection — the sanitized form, produced by SafeMarkdownParser.sanitize(raw) on every debounced rebuild.

Sanitization is non-destructive: once the closing token (e.g. **) arrives in a later chunk, re-sanitizing the now-complete raw buffer naturally produces the correct Markdown, and the synthetic closer added on the previous frame disappears.

Rebuilds are throttled with a Timer set to rebuildDebounce (default one frame ≈ 16ms). This eliminates the flicker and layout jumps you'd otherwise see when dozens of tokens per second each trigger a rebuild.

Cursor widgets #

Eight built-in cursors, all with the same constructor shape (color, size knobs, period) so you can swap them freely:

Widget What it looks like
BlinkingCursor Square-wave block; the classic.
BarCursor Thin I-beam (traditional text cursor).
FadingCursor Block that fades in/out sinusoidally — no hard edges.
PulsingCursor Circular dot that breathes.
TypingDotsCursor Three dots activating in sequence (iMessage style).
WaveDotsCursor Three dots bouncing in a wave.
SpinnerCursor Small circular spinner.
ShimmerCursor Bar with a highlight sliding across it.
MarkdownStream(
  stream: ...,
  cursorWidget: const PulsingCursor(color: Colors.indigo),
)

All cursors default to the ambient DefaultTextStyle colour so they match your theme automatically. Each manages its own AnimationController and disposes it cleanly on unmount — drop them anywhere, including inside ListView.builder items.

You can also supply any custom widget — cursorWidget accepts anything.

Non-string streams #

MarkdownStream is generic over the chunk type. The common case is Stream<String> — just pass it, no extractor needed:

MarkdownStream(stream: plainStringStream)

For any other chunk type, supply a chunkToText function. A few common shapes:

Typed SDK chunks (openai_dart, anthropic_sdk_dart, etc.) #

MarkdownStream<CreateChatCompletionStreamResponse>(
  stream: client.createChatCompletionStream(request: ...),
  chunkToText: (chunk) => chunk.choices.first.delta?.content ?? '',
)

Raw HTTP bytes → SSE → JSON delta #

Chain the three StreamAdapters — they're composable:

final http.StreamedResponse response = await client.send(request);

final text = StreamAdapters.jsonField(
  StreamAdapters.serverSentEvents(
    StreamAdapters.utf8Bytes(response.stream),
  ),
  <Object>['choices', 0, 'delta', 'content'],
);

MarkdownStream(stream: text, cursorWidget: const BlinkingCursor())

Anthropic Messages API #

final text = StreamAdapters.jsonField(
  StreamAdapters.serverSentEvents(
    StreamAdapters.utf8Bytes(response.stream),
  ),
  <Object>['delta', 'text'],
);

Pre-parsed JSON events #

MarkdownStream<Map<String, dynamic>>(
  stream: jsonEventStream,
  chunkToText: (event) => event['delta']?['text'] as String? ?? '',
)

What's in StreamAdapters #

Adapter Does
utf8Bytes Decodes Stream<List<int>> as UTF-8, safe across byte splits.
serverSentEvents Parses SSE. Buffers lines across chunks. Honours [DONE].
jsonField Decodes each chunk as JSON and extracts a nested string path.

For anything else, Stream.map() is always the right tool.

Customization #

MarkdownStream is a superset of flutter_markdown_plus's MarkdownBody — every hook exposed by MarkdownBody is forwarded verbatim. If you can style, theme, or customize it with MarkdownBody, you can do the same thing with MarkdownStream.

Stream-specific parameters #

Parameter Purpose
stream Stream<String> of token chunks (required).
onDone Called with the full raw text when the stream ends.
cursorWidget Shown at the tail while streaming.
rebuildDebounce Coalesces bursts of tokens; default 16ms.
codeBuilder Sugar for a block-code builder. See below.

All MarkdownBody parameters, passed through #

styleSheet, styleSheetTheme, syntaxHighlighter, onTapLink, onTapText, imageDirectory, blockSyntaxes, inlineSyntaxes, extensionSet, imageBuilder, checkboxBuilder, bulletBuilder, builders, paddingBuilders, listItemCrossAxisAlignment, fitContent, shrinkWrap, softLineBreak, selectable, padding.

Heavily-customised example #

MarkdownStream(
  stream: llmResponseStream,
  onDone: (text) => debugPrint('Done: $text'),
  cursorWidget: const BlinkingCursor(),
  rebuildDebounce: const Duration(milliseconds: 16),

  // Theming
  styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
    h1: Theme.of(context).textTheme.displaySmall,
    code: const TextStyle(fontFamily: 'FiraCode', backgroundColor: Colors.black12),
  ),

  // Custom code block (syntax highlighting, copy button, etc.)
  codeBuilder: (code, language) => MyCodeBlock(code: code, language: language),

  // Custom image loading (e.g. cached_network_image)
  imageBuilder: (uri, title, alt) => CachedNetworkImage(imageUrl: uri.toString()),

  // Custom checkbox for GFM task lists
  checkboxBuilder: (checked) => Icon(checked ? Icons.check_box : Icons.check_box_outline_blank),

  // Custom bullet rendering
  bulletBuilder: (params) => Text('→ ', style: TextStyle(color: Colors.teal)),

  // GFM extensions (tables, task lists, strikethrough)
  extensionSet: md.ExtensionSet.gitHubFlavored,

  // Full control for any element — takes precedence over codeBuilder
  builders: {
    'my-custom-tag': MyCustomElementBuilder(),
    'h1': MyH1Builder(),
  },

  // Per-tag padding overrides
  paddingBuilders: {
    'blockquote': MyBlockquotePaddingBuilder(),
  },

  // Layout knobs
  listItemCrossAxisAlignment: MarkdownListItemCrossAxisAlignment.start,
  softLineBreak: true,
  shrinkWrap: true,
  selectable: true,
  padding: const EdgeInsets.all(16),
  onTapLink: (text, href, title) => launchUrl(Uri.parse(href!)),
)

codeBuilder vs builders['code'] #

  • codeBuilder is sugar — it fires only for block fenced code (elements with a language-* class), never for inline `code`.
  • If you need to intercept inline code, element-level attributes, or any other tag, use builders directly. A builders['code'] entry takes precedence over codeBuilder.

License #

MIT — see LICENSE.

4
likes
160
points
2.56k
downloads

Documentation

API reference

Publisher

verified publishernarek-manukyan.dev

Weekly Downloads

Flicker-free streaming Markdown widget for Flutter. Renders partial LLM token streams progressively, handling unclosed syntax gracefully.

Repository (GitHub)
View/report issues

Topics

#markdown #streaming #llm #chat #ai

License

MIT (license)

Dependencies

flutter, flutter_markdown_plus, markdown

More

Packages that depend on flutter_markdown_stream