flutter_md - Markdown Parser and Renderer for Flutter
A high-performance, lightweight Markdown parser and renderer specifically designed for Flutter applications. Perfect for displaying formatted text from AI assistants like ChatGPT, Gemini, and other LLMs.
š Features
- š High Performance: Hand-tuned single-pass parser with minimal allocations
- šØ Fully Customizable: Theme-based styling with complete control over appearance
- š± Flutter Native: Built from the ground up for Flutter with custom render objects
- š Interactive Elements: Clickable links with customizable tap handlers
- āļø Text Selection: Cross-block and cross-widget (chat) selection via a controller that survives list disposal and streaming updates
- š Streaming Parser:
StreamingMarkdownParserparses LLM token output incrementally ā completed blocks are frozen, only the live tail re-parses - š Cross Platform: Works on all Flutter-supported platforms
- š GitHub Flavored: Alerts (
> [!NOTE]), task lists (- [x]), tables with column alignment, thematic breaks, strikethrough, and more - š§® Inline Math: Opt-in
$...$LaTeX ā Unicode (commands + super/subscripts) - š Syntax Highlighting: Opt-in, dependency-free code-block highlighting ā 65+ languages and GitHub light/dark themes; only the languages you import are bundled (the rest tree-shake away)
- šÆ AI-Optimized: Specifically designed for AI-generated content display
- š§ Extensible: Easy to extend with custom block and span renderers
- ā Well Tested: 370+ tests; parser and node model at ~100% line coverage
š Supported Markdown Syntax
Text Formatting
- Bold:
**text** - Underline:
__text__ - Italic:
*text*or_text_ Strikethrough:~~text~~Inline code:`code`- ==Highlight==:
==text== - ||Spoiler||:
||text|| - Inline math (opt-in):
$\alpha$,$\pi \approx 3.14$,$x^2$,$H_2O$(common LaTeX commands + super/subscripts ā Unicode)
Emphasis follows CommonMark-inspired flanking rules, so stray markers
(5 * 6 = 30), intraword underscores (snake_case), and unterminated markers
(**oops) are left as literal text instead of leaking styles.
Inline math (opt-in)
Inline $...$ LaTeX math is disabled by default (so prices like $5 and
shell variables like $HOME are never altered). Enable it per parse or per
decoder:
// Per parse:
final md = Markdown.fromString(r'The angle $\alpha$ and $x^2 + y^2$.',
inlineMath: true);
// Or a reusable decoder, optionally extending the command table:
const decoder = MarkdownDecoder(
inlineMath: true,
mathReplacements: {...kMarkdownMathCommands, r'\R': 'ā'},
);
It converts LaTeX commands (\alpha, \rightarrow, ...) and super/subscripts
(x^2, H_2O, x^{10}), is code-span and code-block safe, and treats \$ as
a literal dollar. Write \$\alpha\$ to keep a literal $\alpha$.
Headers
# H1 Header
## H2 Header
### H3 Header
#### H4 Header
##### H5 Header
###### H6 Header
Lists
- Unordered list item
- Another item
- Nested item
- Deep nested item
1. Ordered list item
2. Another numbered item
1. Nested numbered item
2. Another nested item
- [x] Completed task-list item
- [ ] Pending task-list item
Task-list state is exposed on MD$ListItem.checked (true/false/null) and
MD$ListItem.isTask, and rendered as a checkbox.
Blockquotes
> This is a blockquote
> It can span multiple lines
>
> And have multiple paragraphs
Alerts (Admonitions)
GitHub-style alerts are rendered from blockquotes with a type marker:
> [!NOTE]
> Highlights information that users should take into account.
> [!TIP]
> Optional information to help a user be more successful.
> [!IMPORTANT]
> Crucial information necessary for users to succeed.
> [!WARNING]
> Critical content demanding immediate user attention.
> [!CAUTION]
> Negative potential consequences of an action.
Each alert becomes an MD$Alert block (MD$AlertType.note, .tip,
.important, .warning, .caution). Per-type accent colors are configurable
via MarkdownThemeData.alertColors / alertColorFor.
Code Blocks
```dart
void main() {
print('Hello, Markdown!');
}
```
Code blocks can be syntax-highlighted ā see Syntax Highlighting.
Tables
Column alignment is supported via the delimiter row (:--- left, :--:
center, ---: right):
| Left | Center | Right |
| :------- | :------: | -----: |
| Cell 1 | Cell 2 | Cell 3 |
| **Bold** | _Italic_ | `Code` |
Links and Images
[Link text](https://example.com)

Images currently not displayed!
Horizontal Rules
Any of ---, ***, or ___ (optionally spaced, e.g. - - -) produce a rule:
---
---
---
š Quick Start
Installation
Add this to your package's pubspec.yaml file:
dependencies:
flutter_md: ^x.x.x # Replace with the latest version
Then run:
flutter pub get
Upgrading from 0.0.x? See the Migration guide: 0.0.x ā 0.2.x. The upgrade is almost entirely backward compatible ā most apps need no code changes.
āļø Text Selection
Selection is anchored on the immutable Markdown model, not on the render
objects, so it spans multiple blocks (heading ā paragraph ā list ā table cell)
and multiple MarkdownWidgets (e.g. chat messages), and it survives widgets
being scrolled off-screen and disposed. Wrap a group of widgets in a
MarkdownSelectionScope, give each a stable documentId, and register the
models with the controller:
final controller = MarkdownSelectionController();
// Register the documents in reading order (a chat feeds this from its list).
controller.setDocuments([
for (final (i, m) in messages.indexed)
MarkdownDocumentRef(id: m.id, model: m.markdown, order: i),
]);
MarkdownSelectionScope(
controller: controller,
child: ListView.builder(
itemCount: messages.length,
itemBuilder: (context, i) => MarkdownWidget(
documentId: messages[i].id,
markdown: messages[i].markdown,
),
),
);
// Any time ā even for messages scrolled off-screen:
final String text = controller.getText(); // default formatter
final String md = controller.getText(const MarkdownMarkupFormatter()); // as Markdown
final MarkdownSelectedContent structured = controller.selectedContent();
- Every block is selectable. Paragraphs, headings, quotes, code, alerts,
lists and tables ā a drag can start or end inside a list item or table cell,
and copied text preserves the list
\n/ table\tseparators. - Get the text your way.
getText()uses the defaultMarkdownPlainTextFormatter(configurable block/document separators). For richer output pass the built-inMarkdownMarkupFormatter("Copy as Markdown"): it re-emits heading#s, nested list markers with task checkboxes, blockquote/alert>prefixes, fenced code and pipe tables for fully-selected blocks (partially-selected edges fall back to plain text). Or implement your ownMarkdownSelectionFormatter.selectedContent()returns the structured per-document / per-block result each formatter consumes. - Streaming stays anchored. Call
controller.putDocument(id, newModel)when a message grows; the defaultMarkdownReconciliationPolicy.contentAnchoredkeeps the selection (append fast-path, else relocate by content, else clamp). - One selection at a time. Share a
MarkdownSelectionGroupbetween controllers so selecting in one clears the others; callgroup.clearExternal()when a plainSelectableText/SelectionAreastarts its own selection. - Gestures. A mouse/trackpad/stylus drag selects; on touch a long-press-then-drag selects (so a plain swipe still scrolls the list).
- Native handles, magnifier & toolbar. On touch platforms the selection shows draggable handles and a magnifier (they follow the content as it scrolls, even across widgets); right-click (desktop) or long-press (mobile) shows an adaptive Copy / Select-all toolbar.
- Keyboard shortcuts. When the scope is focused:
Ctrl/Cmd+Ccopies,Ctrl/Cmd+Aselects all,Shift+arrows extend (character / word / line / document, plus vertical),Escclears ā using the ambientDefaultTextEditingShortcuts. - Customizable like
SelectableText.MarkdownSelectionScopetakesselectionColor,contextMenuBuilder,magnifierConfiguration,selectionControls,focusNode,enabledandonSelectionChanged; its publicMarkdownSelectionScopeStateexposescopySelection/selectAll/clearSelection/showToolbar/contextMenuButtonItems/contextMenuAnchorsfor a fully custom menu. - Opt-in & compatible. A
MarkdownWidgetwith nodocumentId/controller is inert ā existing usage is unchanged.
MarkdownSelectionScope(
controller: controller,
selectionColor: Colors.amber.withValues(alpha: 0.3),
onSelectionChanged: (sel) => debugPrint('selection: $sel'),
contextMenuBuilder: (context, state) => AdaptiveTextSelectionToolbar.buttonItems(
anchors: state.contextMenuAnchors,
buttonItems: [
...state.contextMenuButtonItems, // Copy, Select all
ContextMenuButtonItem(
label: 'Copy LOUD',
onPressed: () => Clipboard.setData(
ClipboardData(text: state.controller.getText().toUpperCase())),
),
],
),
child: /* ... */,
);
See the runnable Selection and Chat tabs in example/.
š Streaming (LLM output)
LLM replies arrive token by token. Re-parsing the whole accumulated buffer on
every token is O(N²) and janks long messages. StreamingMarkdownParser keeps
the accumulated source and only re-parses the still-growing tail: once a
block is provably complete (terminated by a blank line, and not inside an open
code fence) it is frozen and never parsed again.
final parser = StreamingMarkdownParser();
llmTokenStream.listen((token) {
final Markdown md = parser.add(token); // cheap, incremental
setState(() => _message = md);
});
Or transform a Stream<String> directly ā each event emits the grown document:
llmTokenStream
.toMarkdown() // Stream<String> ā Stream<Markdown>
.listen((md) => setState(() => _message = md));
- Exact, never approximate. The result of
add()/currentis always identical, block for block, toMarkdown.fromString(everythingReceivedSoFar). Blocks whose type still depends on input that hasn't arrived ā an unterminated code fence, a table header still missing its delimiter row, a list that may continue ā stay in the live tail and are re-evaluated, so they never freeze into the wrong shape. - Same options as batch. Pass a configured decoder to match
Markdown.fromString:StreamingMarkdownParser(decoder: const MarkdownDecoder(inlineMath: true))(or.toMarkdown(decoder: ...)). - Fast. Replaying a whole message token-by-token is 3ā14Ć faster than
the full-reparse approach, and the gap grows with message length
(
benchmark/streaming_benchmark.dart). - Reusable.
parser.reset()clears state for the next message;parser.sourceis the raw text accumulated so far,parser.currentthe parsed model without adding anything.
Pair it with selection: feed each grown model to controller.putDocument(id, md)
and the active selection stays anchored as the message streams in (see the
Chat tab in example/).
š Syntax Highlighting
Fenced code blocks can be syntax-highlighted with a tree-shakeable,
dependency-free highlighter ā 65+ languages and ready-made GitHub light/dark
themes. Highlighting is opt-in and off by default (code renders as plain
monospace): assign a SyntaxHighlighter to MarkdownThemeData.highlighter.
import 'package:flutter_md/highlight.dart'; // engine + MarkdownHighlighter
import 'package:flutter_md/highlight/dart.dart'; // one import per language
import 'package:flutter_md/highlight/sql.dart';
import 'package:flutter_md/highlight/themes.dart'; // HighlightThemes
final theme = MarkdownThemeData.mergeTheme(
Theme.of(context),
highlighter: MarkdownHighlighter(
// You assemble the map, so only these grammars are compiled in.
languages: {
'dart': HighlightDart.grammar,
'sql': HighlightSql.grammar,
},
theme: isDark ? HighlightThemes.githubDark : HighlightThemes.githubLight,
),
);
MarkdownWidget(markdown: doc, theme: theme);
- Only the languages you import ship. Each language is its own library
exposing a single grammar, referenced through lazy thunks with no central
registry/
Map/enum. Importinghighlight/dart.dartnever references any other language, so unused grammars are removed by tree-shaking ā a Dart-only app adds ~0 beyond the engine, and all 65 languages together add ~62 KB (gzipped) only if you deliberately bundle them all. - Selection- and copy-safe. The highlighter only partitions the source into colored spans; the concatenated text is byte-identical, so cross-block selection, offsets and "Copy as Markdown" stay aligned.
- Themes. Use
HighlightThemes.githubDark/githubLight, or implementCodeHighlightTheme(aswitchfrom token type āTextStyle) for your own palette. Itsbackgroundis applied to the code-block surface. - Demos & tooling.
import 'package:flutter_md/highlight/all.dart';exposesallHighlightLanguages, a ready map of every grammar keyed by tag and alias (js,ts,sh, ā¦). It references everything, so unused languages can no longer be tree-shaken away ā use it for demos, not production. - Robust. Unknown languages fall back to plain text; a pattern Dart's regex engine rejects disables just that one rule instead of breaking the language.
The grammars are generated by tool/highlight_codegen (see its README to
regenerate or add languages). See the runnable Highlight tab in example/.
Syntax grammars are adapted from Prism (MIT License).
// Custom theme: map token types to styles.
final class MyCodeTheme implements CodeHighlightTheme {
const MyCodeTheme();
@override
Color? get background => const Color(0xFF1E1E1E);
@override
Color? get foreground => const Color(0xFFD4D4D4);
@override
TextStyle? styleFor(String tokenType) => switch (tokenType) {
'comment' => const TextStyle(color: Color(0xFF6A9955)),
'keyword' => const TextStyle(color: Color(0xFF569CD6)),
'string' || 'string-literal' => const TextStyle(color: Color(0xFFCE9178)),
_ => null,
};
}
šØ Customization
Theme Configuration
MarkdownTheme(
data: MarkdownThemeData(
textStyle: TextStyle(fontSize: 16.0, color: Colors.black87),
h1Style: TextStyle(
fontSize: 24.0,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
h2Style: TextStyle(
fontSize: 22.0,
fontWeight: FontWeight.bold,
color: Colors.blueGrey,
),
quoteStyle: TextStyle(
fontSize: 14.0,
fontStyle: FontStyle.italic,
color: Colors.grey[600],
),
// Customize link text styling (merged on top of linkColor)
linkStyle: const TextStyle(
decoration: TextDecoration.underline,
),
// Per-type accent colors for GitHub alert blocks
alertColors: const {
MD$AlertType.warning: Color(0xFF9A6700),
},
// Handle link taps
onLinkTap: (title, url) {
print('Tapped link: $title -> $url');
// Launch URL or navigate
},
// Filter blocks (e.g., hide code blocks)
blockFilter: (block) => block is! MD$Code,
// Filter spans (e.g., exclude images or certain styles)
spanFilter: (span) => !span.style.contains(MD$Style.image),
),
child: MarkdownWidget(
markdown: yourMarkdown,
),
)
Or you can use the MarkdownThemeData.mergeTheme(Theme.of(context)) factory to create a theme that inherits from the application's theme.
This approach allows you to easily support both light and dark themes, and keeps your markdown styling consistent with the rest of your application.
Custom Block Painters
For advanced customization, you can provide custom block painters:
MarkdownThemeData(
builder: (block, theme) {
if (block is MD$Code && block.language == 'dart') {
// Return custom painter for Dart code blocks
return CustomDartCodePainter(block: block, theme: theme);
}
return null; // Use default painter
},
)
Performance Optimization
For large markdown documents or frequently changing content:
class MyWidget extends StatefulWidget {
@override
_MyWidgetState createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
late final Markdown _markdown;
@override
void initState() {
super.initState();
// Parse markdown once during initialization
_markdown = Markdown.fromString(yourMarkdownString);
}
@override
Widget build(BuildContext context) {
return MarkdownWidget(markdown: _markdown);
}
}
š Performance
flutter_md is built for speed: a single-pass, lookup-table parser (regex-free
hot path with a plain-text fast path) and a custom render object that lays the
whole document into one cached ui.Picture instead of a deep tree of per-block
widgets.
Head-to-head against flutter_markdown and gpt_markdown on the same machine
(i7-13700K) with identical styles ā full tables and methodology in
benchmark_compare/RESULTS.md:
- Parsing ā ~18Ć faster than the
markdownpackage that backsflutter_markdown(gpt_markdownhas no standalone parser), sustaining ~60 MB/s on mixed documents. - Rendering (string ā painted pixels) ā ~6.6Ć faster than
flutter_markdownand ~15.6Ć faster thangpt_markdownon a large document (1.2ā1.6Ć on small chat bubbles). - Scrolling ā 60 fps with zero dropped frames in profile mode, and the lowest UI-thread frame-build cost of the three (99th-percentile frame build 1.6 ms vs 4.8 / 9.4 ms).
# In-repo parser micro-benchmarks:
dart run benchmark/parser_benchmark.dart # multi-scenario, vs. `markdown`
dart run benchmark/compare.dart --save # low-noise before/after tool
# Head-to-head vs flutter_markdown & gpt_markdown (parser + render):
cd benchmark_compare && flutter pub get
dart run benchmark/parser_benchmark.dart
flutter test test/render_benchmark_test.dart
š§ Advanced Features
Custom Styles
// Access individual style components
final span = MD$Span(
text: 'Custom text',
style: MD$Style.bold | MD$Style.italic, // Combine styles
);
// Check for specific styles
if (span.style.contains(MD$Style.link)) {
// Handle link styling
}
Block Filtering
MarkdownThemeData(
blockFilter: (block) {
// Only show paragraphs and headers
return block is MD$Paragraph || block is MD$Heading;
},
)
Span Filtering
MarkdownThemeData(
spanFilter: (span) {
// Exclude images and spoilers
return !span.style.contains(MD$Style.image) &&
!span.style.contains(MD$Style.spoiler);
},
)
š± Platform Support
- ā Android
- ā iOS
- ā Web
- ā Windows
- ā macOS
- ā Linux
š¤ Contributing
Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
Development Setup
git clone https://github.com/DoctorinaAI/md.git md
cd md
flutter pub get
flutter test
Running the Example
cd example
flutter run
š License
This project is licensed under the MIT License - see the LICENSE file for details.
š Links
š” Why Choose md?
Unlike other Markdown packages that rely on HTML rendering or web views, flutter_md is built specifically for Flutter using custom render objects. This provides:
- Better Performance: No HTML parsing or web view overhead
- Native Feel: Fully integrated with Flutter's rendering pipeline
- Customization: Complete control over styling and behavior
- Reliability: Consistent rendering across all platforms
- Small Size: Minimal package size with no external dependencies
Perfect for chat applications, documentation viewers, note-taking apps, and any Flutter application that needs to display rich formatted text from AI assistants or user input.
Libraries
- flutter_md
- highlight
- Tree-shakeable syntax highlighting for fenced code blocks.
- highlight/all
- highlight/apacheconf
- highlight/bash
- highlight/batch
- highlight/c
- highlight/clike
- highlight/clojure
- highlight/coffeescript
- highlight/cpp
- highlight/csharp
- highlight/css
- highlight/dart
- highlight/diff
- highlight/docker
- highlight/elixir
- highlight/elm
- highlight/erlang
- highlight/fsharp
- highlight/git
- highlight/go
- highlight/graphql
- highlight/groovy
- highlight/handlebars
- highlight/haskell
- highlight/html
- highlight/http
- highlight/ini
- highlight/java
- highlight/js
- highlight/json
- highlight/json5
- highlight/jsx
- highlight/julia
- highlight/kotlin
- highlight/latex
- highlight/less
- highlight/lua
- highlight/makefile
- highlight/markdown
- highlight/markup_templating
- highlight/nginx
- highlight/objectivec
- highlight/ocaml
- highlight/perl
- highlight/php
- highlight/plain
- highlight/powershell
- highlight/protobuf
- highlight/python
- highlight/r
- highlight/regex
- highlight/ruby
- highlight/rust
- highlight/sass
- highlight/scala
- highlight/scss
- highlight/solidity
- highlight/sql
- highlight/swift
- highlight/themes
- highlight/toml
- highlight/tsx
- highlight/typescript
- highlight/vim
- highlight/wasm
- highlight/xml
- highlight/yaml