Basic Usage

Everything from install to a production-ready render, with GptMarkdown v1.2.1.

Install

pubspec.yaml
1# pubspec.yaml 2dependencies: 3 gpt_markdown: ^1.2.1
import
1import 'package:gpt_markdown/gpt_markdown.dart';

One import brings in the widget, every style class, all builder typedefs, and GptMarkdownConfig.

Minimal render

One positional argument — the markdown string. Everything else is optional.

main.dart
1// Minimum: one positional argument — the markdown string. 2GptMarkdown('# Hello\n\nSome **bold** text and `inline code`.')

What it supports

FeatureSyntax or behavior
Headings# through ######
Emphasis**bold**, *italic*, ~~strike~~, <u>underline</u>
Code`inline` and fenced code blocks
ListsBulleted, ordered, nested, task lists, and radio options
TablesGFM-style tables, including :---: alignment
BlocksQuotes, rules, images, links, citations, and bare autolinks
Math\( inline \) and \[ block \]; dollar signs are opt-in

The package has no platform-specific plugins and is suitable for Flutter Web, including WebAssembly-targeted builds. Your app's other dependencies must still support the WebAssembly target.

Scrollable replies

Important: GptMarkdown sizes itself to its content. Put it inside something scrollable for anything longer than a sentence — otherwise a long reply overflows the screen.
reply_view.dart
1import 'package:flutter/material.dart'; 2import 'package:gpt_markdown/gpt_markdown.dart'; 3 4/// GptMarkdown sizes itself to its content and does not scroll. 5/// Wrap it whenever the reply may exceed the screen height. 6class ReplyView extends StatelessWidget { 7 final String reply; 8 const ReplyView({super.key, required this.reply}); 9 10 11 Widget build(BuildContext context) { 12 return Scaffold( 13 appBar: AppBar(title: const Text('Reply')), 14 body: SingleChildScrollView( 15 padding: const EdgeInsets.all(16), 16 child: GptMarkdown(reply), 17 ), 18 ); 19 } 20}

In a chat list where the ListView already scrolls:

chat_list.dart
1// One GptMarkdown per bubble — no scroll wrapper needed 2// because the ListView itself scrolls. 3ListView.builder( 4 itemCount: messages.length, 5 itemBuilder: (context, i) => Padding( 6 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 7 child: GptMarkdown(messages[i].text), 8 ), 9)

Text selection

Wrap in SelectionArea the same way you would any Flutter text. Inline code stays on the text baseline and is selectable. Copying across a list or table currently yields cells run together with no separators — prose, headings, links, and inline code copy correctly.

selectable.dart
1// Wrap in SelectionArea to make all text inside selectable. 2SelectionArea( 3 child: SingleChildScrollView( 4 padding: const EdgeInsets.all(16), 5 child: GptMarkdown(reply), 6 ), 7)

Handling link taps

Links render as tappable but do nothing on their own — opening a URL is your decision. LLM output can contain any URL, so validate before launching.

link_tap.dart
1// Links do nothing unless you handle them. 2// The package does not depend on a URL launcher — that choice is yours. 3GptMarkdown( 4 reply, 5 onLinkTap: (url, title) { 6 final uri = Uri.tryParse(url); 7 if (uri == null) return; 8 if (uri.scheme != 'https' && uri.scheme != 'mailto') return; 9 launchUrl(uri); 10 }, 11)

Image, code, citation, and checkbox callbacks

The remaining interactions are opt-in too. Markdown checkboxes are read-only by default; when enablingCheckboxStyle(interactive: true), persist a rewritten source string or the visible state will revert during the next rebuild.

interactions.dart
1GptMarkdown( 2 reply, 3 onImageTap: (url) => openLightbox(url), 4 onCodeCopy: (code) => analytics.log('code_copied'), 5 onSourceTagTap: (content) => showSource(content), 6 // Requires CheckboxStyle(interactive: true). 7 onCheckboxChanged: (value) => persistCheckbox(value), 8)

Right-to-left

Inline widgets — LaTeX, images, links — are placed in the correct visual order in mixed-direction paragraphs, working around flutter#54400 which the framework does not handle on its own.

rtl.dart
1GptMarkdown( 2 reply, 3 textDirection: TextDirection.rtl, 4)

Text scaling

Set the base font size once via style. Heading sizes, inline code, and list bullets all derive from it proportionally. Components rendered as inline widgets scale correctly at any system font setting.

text_style.dart
1// Base text style — component sizes derive from this. 2// Set it once here; do not set sizes in individual style classes. 3GptMarkdown( 4 reply, 5 style: const TextStyle(fontSize: 16, height: 1.6), 6) 7 8// Or pull from the theme: 9GptMarkdown( 10 reply, 11 style: Theme.of(context).textTheme.bodyMedium, 12)

Streaming AI output

Accumulate tokens in a StringBuffer, pass the string to GptMarkdown, and set isStreaming: true while the stream is open. The settled prefix is cached so cost per token stays flat regardless of reply length. See the Streaming guide for pacing, performance details, and pitfalls.

streaming_reply.dart
1// Streaming is data, not a Stream — rebuild with a longer string each token. 2class ReplyView extends StatefulWidget { 3 const ReplyView({super.key, required this.stream}); 4 final Stream<String> stream; 5 6 7 State<ReplyView> createState() => _ReplyViewState(); 8} 9 10class _ReplyViewState extends State<ReplyView> { 11 final _buffer = StringBuffer(); 12 bool _generating = true; 13 14 15 void initState() { 16 super.initState(); 17 widget.stream.listen( 18 (chunk) => setState(() => _buffer.write(chunk)), 19 onDone: () => setState(() => _generating = false), 20 ); 21 } 22 23 24 Widget build(BuildContext context) => SingleChildScrollView( 25 padding: const EdgeInsets.all(16), 26 child: GptMarkdown( 27 _buffer.toString(), 28 animation: GptMarkdownAnimation.fade, 29 isStreaming: _generating, // flip to false when the stream ends 30 ), 31 ); 32}

LaTeX rendering

The package renders LaTeX by default. Use latexBuilder only when your app needs a different math widget or error treatment. See LaTeX Support for delimiters and display-math scroll.

latex.dart
1// LaTeX renders with the package default. Use latexBuilder only to replace it. 2// flutter_math_fork is the most common choice. 3import 'package:flutter_math_fork/flutter_math.dart'; 4 5GptMarkdown( 6 reply, 7 latexBuilder: (context, tex, textStyle, inline) => Math.tex( 8 tex, 9 textStyle: textStyle, 10 onErrorFallback: (err) => Text(tex, style: textStyle), 11 ), 12)

Custom code block

Use codeBuilder to replace the default code block widget. The closed flag is false while the closing fence has not yet arrived during streaming.

code_block.dart
1// Replace the default code block widget with your own. 2GptMarkdown( 3 reply, 4 codeBuilder: (context, name, code, closed) { 5 // name = language identifier ("dart", "python", …) — may be empty 6 // code = raw code string 7 // closed = false while the closing fence hasn't arrived yet (streaming) 8 return MyCodeBlock(language: name, code: code, isClosed: closed); 9 }, 10)

Custom image renderer

The imageBuilder callback receives the URL plus optional width and height from the alt text (parsed as WxH).

image_builder.dart
1GptMarkdown( 2 reply, 3 imageBuilder: (context, url, width, height) { 4 return ClipRRect( 5 borderRadius: BorderRadius.circular(8), 6 child: Image.network( 7 url, 8 width: width, 9 height: height, 10 fit: BoxFit.cover, 11 ), 12 ); 13 }, 14)

Custom link renderer

Use linkBuilder for complete control over how links are drawn. For tap-only needs, prefer the simpler onLinkTap.

link_builder.dart
1GptMarkdown( 2 reply, 3 linkBuilder: (context, text, url, style) { 4 return InkWell( 5 onTap: () => launchUrlString(url), 6 child: Text.rich(TextSpan(children: [text]), 7 style: style.copyWith(decoration: TextDecoration.underline)), 8 ); 9 }, 10)

Common mistakes

Wrapping in Expanded without a scroll view. The widget reports its content height; constraining it with Expanded without a scroll view clips the reply. Always put a SingleChildScrollView between them.
Streaming a long response without the streaming path. The package's measured 7.7 kB / 120-append case is 14.6 ms per token with animation: none versus 11.0 ms with the split/cached fade path, which stays flat as the reply grows. Use the streaming guide for generated replies.
Rebuilding builder closures on every frame. Builders (latexBuilder, codeBuilder, etc.) are not compared when deciding whether to re-render, so a changed closure is silently ignored until the widget remounts. Define them once, outside build, or key the widget if you genuinely need to swap them.
Leaving isStreaming: true after the reply finishes. The ticker keeps running and the tail keeps rebuilding for nothing. Always flip it on onDone — including error paths.