Two ways to change what you see, and they never overlap:
| Use it for | Example | |
|---|---|---|
| Style object | Appearance — colours, sizes, padding, fonts | BlockQuoteStyle(barWidth: 4) |
| Builder | Structure — replace the widget entirely | blockQuoteBuilder: … |
Every component supports both.
[!TIP] If you are reaching for a builder to change a colour, stop — there is a style field for it. Builders lose the default structure, and with it every future improvement to that component.
The same object is accepted in two places.
One widget:
GptMarkdown(
text,
styleSheet: const GptMarkdownStyleSheet(
blockQuote: BlockQuoteStyle(barWidth: 4),
),
)
The whole app:
MaterialApp(
theme: ThemeData(
extensions: [
GptMarkdownThemeData(
brightness: Brightness.light,
styleSheet: const GptMarkdownStyleSheet(
blockQuote: BlockQuoteStyle(barColor: Colors.indigo),
codeBlock: CodeBlockStyle(borderRadius: Radius.circular(12)),
),
),
// Dark needs its own — the extension is per ThemeData.
],
),
)
With both of the above in force, the quote gets barWidth: 4 from the widget
and barColor: Colors.indigo from the theme.
widget field → theme field → package default
Overriding one value never discards the rest.
[!NOTE] Every field is optional, and anything left unset resolves to the value the package used before it was configurable. Adding a style sheet never changes how existing content looks. A golden suite covering eight constructs in light and dark enforces that on every commit.
blockSpacing sets the vertical gap between blocks — paragraphs, headings,
lists, code, tables, quotes — in logical pixels:
styleSheet: const GptMarkdownStyleSheet(blockSpacing: 8),
Unset, the gap is one empty line: 1.15 × the font size, 16 pixels at the
default 14. It grows with the text scale either way, so a reader who enlarges
text keeps the same proportions. Extra blank lines in the source never widen
it — two, three or ten in a row give one gap — and 0 removes it.
A block’s own margin or padding is added on top, so a quote with
BlockQuoteStyle(margin: ...) sits that much further away.
textStyle · padding · showDivider · dividerColor · dividerThickness ·
dividerPadding
GptMarkdown(
text,
styleSheet: const GptMarkdownStyleSheet(
heading: HeadingStyle(
textStyle: TextStyle(letterSpacing: -0.5),
padding: EdgeInsets.only(top: 8, bottom: 4),
showDivider: false,
),
),
)
textStyle is merged over the per-level style, so you change one property
without restating the size. Per-level sizes still come from the theme:
GptMarkdownThemeData(
brightness: Brightness.light,
h1: Theme.of(context).textTheme.headlineMedium,
h2: Theme.of(context).textTheme.titleLarge,
)
showDivider: false removes the rule an h1 draws by default. Leave it null
to keep following autoAddDividerLineAfterH1.
Restructure with a builder — for example, anchors on every heading:
GptMarkdown(
text,
headingBuilder: (context, level, content, style) => Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Flexible(child: content),
IconButton(icon: const Icon(Icons.link), onPressed: () {}),
],
),
)
level is 1–6, so one builder handles all six.
color · hoverColor · decoration · decorationThickness · fontWeight
styleSheet: const GptMarkdownStyleSheet(
link: LinkStyle(
color: Color(0xFF0B57D0),
hoverColor: Color(0xFF0842A0),
decoration: TextDecoration.none,
fontWeight: FontWeight.w500,
),
),
[!IMPORTANT] Links do nothing on tap unless you handle them. The package deliberately does not depend on a URL launcher.
GptMarkdown(text, onLinkTap: (url, title) => launchUrlString(url))
title is the label text, which is useful for confirmation dialogs:
onLinkTap: (url, title) async {
final ok = await confirm('Open "$title"?\n$url');
if (ok) await launchUrlString(url);
},
fontFamily · fontFamilyPackage · fontFamilyFallback · fontSizeFactor ·
fontWeight · color · backgroundColor · borderColor · borderWidth ·
borderRadius · padding · boxHeightStyle
Your app’s mono font:
styleSheet: const GptMarkdownStyleSheet(
inlineCode: InlineCodeStyle(fontFamily: 'GeistMono'),
),
A GitHub-ish chip:
inlineCode: InlineCodeStyle(
backgroundColor: const Color(0x14656D76),
borderColor: Colors.transparent,
borderRadius: const Radius.circular(6),
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
),
No chip at all, just monospace:
inlineCode: InlineCodeStyle(
backgroundColor: Colors.transparent,
borderWidth: 0,
padding: EdgeInsets.zero,
),
[!TIP]
fontSizeFactoris a factor, not a size, so inline code scales with whatever it sits in — a heading, a table cell, body text. Setting an absolute size breaks that.
Inline code is a real TextSpan with the chip painted underneath, once per
line fragment. It wraps across lines, stays selectable, sits on the baseline,
and works inside a link label — none of which a widget-based chip can do.
Per-code styling needs the builder:
GptMarkdown(
text,
inlineCodeBuilder: (context, code, style, codeStyle) => CodeTextSpan(
text: code,
style: style,
codeStyle: codeStyle.copyWith(
backgroundColor: code.startsWith('TODO') ? Colors.amber : null,
),
),
)
Returning CodeTextSpan keeps the painted chip. Return a plain TextSpan to
drop it.
bulletSize · bulletColor · bulletShape · markerTextStyle · indent ·
gapAfterMarker
styleSheet: const GptMarkdownStyleSheet(
list: ListStyle(
bulletSize: 5,
bulletColor: Colors.indigo,
bulletShape: BoxShape.rectangle,
indent: 12,
gapAfterMarker: 12,
markerTextStyle: TextStyle(fontWeight: FontWeight.w600),
),
),
markerTextStyle is the 1. on an ordered list. bulletSize and
bulletColor default to values derived from the surrounding text, so they
track your font size unless you pin them.
[!NOTE] Bullets and numbers keep separate spacing defaults — 7/10 for bullets, 6/6 for numbers. Setting
indentorgapAfterMarkerapplies to both.
size · checkedColor · uncheckedColor · checkColor · borderRadius ·
gapAfterBox · interactive
Applies to both - [x] task lists and (x) radio options.
styleSheet: const GptMarkdownStyleSheet(
checkbox: CheckboxStyle(
size: 18,
checkedColor: Colors.green,
borderRadius: Radius.circular(4),
gapAfterBox: 8,
),
),
[!WARNING] Checkboxes are read-only by default. A Markdown checkbox renders the source text — ticking it does not change the text, so the change would be lost on the next rebuild.
To make them interactive you must opt in and persist the result yourself:
GptMarkdown(
markdown,
styleSheet: const GptMarkdownStyleSheet(
checkbox: CheckboxStyle(interactive: true),
),
onCheckboxChanged: (value) {
// Rewrite the source, or the tick reverts on the next build.
setState(() => markdown = toggleFirstUnchecked(markdown));
},
)
barWidth · barColor · barRadius · backgroundColor · padding ·
margin · textStyle
styleSheet: const GptMarkdownStyleSheet(
blockQuote: BlockQuoteStyle(
barWidth: 4,
barColor: Color(0xFF6366F1),
barRadius: Radius.circular(2),
backgroundColor: Color(0x0A6366F1),
padding: EdgeInsetsDirectional.only(start: 12, top: 8, bottom: 8),
margin: EdgeInsets.symmetric(vertical: 8),
textStyle: TextStyle(fontStyle: FontStyle.italic),
),
),
A background is only drawn when you ask for one — no extra widget in the tree otherwise.
A callout style with a builder:
GptMarkdown(
text,
blockQuoteBuilder: (context, content, style) => Card(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Padding(padding: const EdgeInsets.all(12), child: content),
),
)
A quote whose first line is [!NOTE], [!TIP], [!IMPORTANT], [!WARNING]
or [!CAUTION] is drawn as an alert: an icon and title in an accent colour
over the body, beside a bar in the same colour, on a faint tint of that colour
with rounded corners. The marker is
case-insensitive and must be alone on its line; anything else — [!FOO], or
text after the marker — stays an ordinary quote.
> [!WARNING]
> Back up your data before upgrading.
color · backgroundColor · icon · iconSize · showIcon · title ·
titleStyle · titleGap · textStyle · barWidth · borderRadius ·
padding · margin · note · tip · important · warning · caution
The top-level fields apply to every type. note, tip, important,
warning and caution take an AlertStyle that overrides them for that type,
field by field:
styleSheet: const GptMarkdownStyleSheet(
alert: AlertStyle(
barWidth: 4,
backgroundColor: Color(0x0A000000),
borderRadius: Radius.circular(8),
warning: AlertStyle(
title: 'Heads up',
icon: Icons.bolt,
color: Colors.deepOrange,
),
tip: AlertStyle(title: '', showIcon: false), // body only
),
),
Unset, each type gets its own accent colour — a lighter one on a dark
ColorScheme — its own icon, and an English title (“Note”, “Tip”,
“Important”, “Warning”, “Caution”). Set title to translate them. An empty
title with showIcon: false hides the title row.
The background defaults to the accent colour at 8% opacity (12% on a dark
scheme), so it blends with whatever surface the alert sits on and follows a
custom color. backgroundColor: Colors.transparent turns the tint off, and
borderRadius: Radius.zero squares the corners.
Replacing the widget. alertBuilder receives AlertBuildDetails: the
type, the resolved style, the stock title row and the rendered content.
defaultAlert() returns the stock alert and asBlockQuote() the plain quote,
marker included:
GptMarkdown(
text,
alertBuilder: (context, details) {
if (details.type == MarkdownAlertType.tip) {
return details.asBlockQuote(); // no alert for tips
}
return Card(
color: details.style.color!.withValues(alpha: 0.08),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [details.title, const SizedBox(height: 4), details.content],
),
),
);
},
)
An app that sets blockQuoteBuilder and no alertBuilder keeps getting
alerts through blockQuoteBuilder, as ordinary quotes — the look it built for
quotes does not change. Add an alertBuilder to opt in.
backgroundColor · borderColor · borderWidth · borderRadius · padding ·
headerPadding · fontFamily · fontFamilyPackage · fontSize ·
textColor · showLanguageLabel · languageStyle · showCopyButton ·
copyLabel · copiedLabel · highlightWhileStreaming
styleSheet: const GptMarkdownStyleSheet(
codeBlock: CodeBlockStyle(
backgroundColor: Color(0xFF1E1E1E),
textColor: Color(0xFFD4D4D4),
borderRadius: Radius.circular(12),
padding: EdgeInsets.all(20),
fontFamily: 'GeistMono',
showLanguageLabel: true,
showCopyButton: true,
),
),
Localise the copy-button tooltip without replacing the block:
codeBlock: CodeBlockStyle(
copyLabel: AppLocalizations.of(context).copyCode,
copiedLabel: AppLocalizations.of(context).copied,
),
React to a copy:
GptMarkdown(text, onCodeCopy: (code) => analytics.log('code_copied'))
Fenced blocks are highlighted automatically when the opening fence names a recognized language:
```python
def greet(name: str) -> str:
return f"Hello, {name}!"
```
The built-in highlighter registers 189 language grammars and follows the
active light or dark brightness. Common fence aliases include js, ts,
py, python3, c++, sh and yml. Unknown tags fall back to plain
monospace code, and an omitted tag displays Code in the header.
While a fence is still open the block is highlighted again on every source
update. highlightWhileStreaming: false holds plain monospace until the closing
fence arrives and highlights once, which is worth setting when replies stream
long blocks. It defaults to true because that is what the package did before the
field existed.
No syntax-theme field is exposed. CodeBlockStyle controls the panel, font and
base/fallback text appearance; the built-in token palette is automatic. When
an application needs its own tokenizer or token colors, replace the complete
block with the existing codeBuilder.
The built-in copy action keeps the code unchanged, briefly changes its icon to
a check, ignores repeated taps during that state, and invokes onCodeCopy
after the clipboard write succeeds.
[!WARNING] Code lines do not wrap. On a phone at a raised text scale a long line overflows horizontally. The block scrolls sideways, but if you need it to wrap, replace it:
GptMarkdown(
text,
codeBuilder: (context, name, code, closed) => Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: SelectableText(code, style: const TextStyle(fontFamily: 'monospace')),
),
)
closed is false while a fence is still being streamed — useful for showing a
“generating” state.
borderColor · borderWidth · borderRadius · cellPadding ·
headerBackground · headerTextStyle · rowStripeColor · columnWidth ·
overflow
styleSheet: const GptMarkdownStyleSheet(
table: TableStyle(
borderColor: Color(0x1F000000),
borderWidth: 1,
borderRadius: Radius.circular(8),
cellPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
headerBackground: Color(0x0A000000),
headerTextStyle: TextStyle(fontWeight: FontWeight.w600),
),
),
columnWidth sets one width policy for every column. Left unset, a column is
sized to its content, which lays every cell out twice — once to measure, once
for real. columnWidth: FixedColumnWidth(120) skips that measurement, which is
the escape hatch for a large or streaming table.
A flex policy is not, with the default overflow. Tables scroll horizontally
when they exceed the available width, so the table is laid out against an
unbounded width and a flex column has no finite width to take a share of:
FlexColumnWidth() collapses the table to zero width and wraps every cell to
one character a line. comparison has the measurements.
overflow decides what a table wider than the screen does:
TableOverflow.scroll (the default) keeps columns at their content width and
scrolls the table sideways.TableOverflow.wrap fits the table to the available width and wraps the text
in its cells. Columns shrink toward their longest word, so short columns stay
whole; with more columns than the words allow, words break rather than the
table running off screen. The table is laid out against a bounded width here,
so a flex columnWidth works.styleSheet: const GptMarkdownStyleSheet(
table: TableStyle(overflow: TableOverflow.wrap),
),
borderRadius · padding · fit · maxWidth · maxHeight
styleSheet: const GptMarkdownStyleSheet(
image: ImageStyle(
borderRadius: Radius.circular(8),
padding: EdgeInsets.symmetric(vertical: 8),
maxHeight: 320,
),
),
Cached network images, with a placeholder and error state:
GptMarkdown(
text,
imageBuilder: (context, url, width, height) => CachedNetworkImage(
imageUrl: url,
width: width,
height: height,
placeholder: (context, _) => const SizedBox(
height: 120,
child: Center(child: CircularProgressIndicator()),
),
errorWidget: (context, _, __) => const Icon(Icons.broken_image),
),
onImageTap: (url) => openLightbox(url),
)
width and height come from the alt text when written as WxH.
Inline images. A data: URL works wherever a network URL does:

The default image widget decodes it — base64 or percent-encoded — and keeps the decoded bytes, so a streaming reply that rebuilds constantly does not decode or flicker again. Data that does not decode, or is not an image, shows the broken-image icon. A large image is decoded on the UI thread the first time it appears, which is fine at chart and screenshot sizes.
An imageBuilder receives the data: URL as written. CachedNetworkImage
and other network loaders cannot open one, so a builder that uses them should
hand data: URLs to Image.memory(UriData.parse(url).contentAsBytes()).
thickness · color · padding
styleSheet: const GptMarkdownStyleSheet(
hr: HrStyle(
thickness: 2,
color: Color(0x1F000000),
padding: EdgeInsets.symmetric(vertical: 16),
),
),
A dotted rule:
GptMarkdown(
text,
hrBuilder: (context, style) => const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: DottedLine(),
),
)
backgroundColor · textStyle · size · shape · padding
The chip drawn for a [1] citation, common in RAG answers.
styleSheet: const GptMarkdownStyleSheet(
sourceTag: SourceTagStyle(
size: 18,
backgroundColor: Color(0xFFE8DEF8),
shape: BoxShape.rectangle,
textStyle: TextStyle(fontSize: 11, fontWeight: FontWeight.w600),
),
),
GptMarkdown(text, onSourceTagTap: (content) => showSource(content))
textStyle · padding · backgroundColor · borderRadius ·
scrollBlockHorizontally
styleSheet: const GptMarkdownStyleSheet(
latex: LatexStyle(
scrollBlockHorizontally: true,
padding: EdgeInsets.symmetric(vertical: 8),
backgroundColor: Color(0x08000000),
borderRadius: Radius.circular(6),
),
),
[!WARNING] Rendered maths cannot wrap. Without
scrollBlockHorizontally: true, a wide formula overflows on a phone. This is the single most common LaTeX complaint.
The renderer itself is built in. inlineLatexBuilder and blockLatexBuilder
replace it, and onLatexTap handles taps — see
getting started.
Where a builder is handed a style-sheet object, it is the fully resolved
one, so the builder never has to guess a default or restate a theme colour.
Check the signature first, though: codeBuilder, imageBuilder,
tableBuilder, orderedListBuilder and unOrderedListBuilder are handed no
style-sheet object — they replace the component outright, and CodeBlockStyle,
ImageStyle, TableStyle and ListStyle never reach them. The TextStyle
tableBuilder does receive is the ambient body style, empty when the widget
sets none.
Three of the deprecated builders carry an unresolved style as well, kept that
way because the builders written against them expect it: sourceTagBuilder is
handed an empty TextStyle whenever SourceTagStyle.textStyle is unset,
linkBuilder the ambient body style rather than the resolved link style its
replacement is given, and highlightBuilder the ambient body style — the
resolved code style reaches it only where the surrounding style is null.
| Builder | Signature |
|---|---|
headingBuilder |
(context, int level, Widget content, HeadingStyle style) |
blockQuoteBuilder |
(context, Widget content, BlockQuoteStyle style) |
alertBuilder |
(context, AlertBuildDetails details) |
checkboxBuilder |
(context, bool checked, Widget content, CheckboxStyle style) |
radioOptionBuilder |
(context, bool selected, Widget content, CheckboxStyle style) |
hrBuilder |
(context, HrStyle style) |
codeBuilder |
(context, String name, String code, bool closed) |
tableBuilder |
(context, rows, TextStyle style, GptMarkdownConfig config) |
imageBuilder |
(context, String url, double? width, double? height) |
inlineLatexBuilder |
(InlineLatexBuildDetails details) → InlineSpan |
blockLatexBuilder |
(BlockLatexBuildDetails details) → Widget |
latexBuilder |
Deprecated. (context, String tex, TextStyle style, bool inline) |
inlineLinkBuilder |
(LinkBuildDetails details) → InlineSpan |
linkBuilder |
Deprecated. (context, InlineSpan label, String url, TextStyle style) |
inlineCodeBuilder |
(context, String code, TextStyle style, InlineCodeStyle codeStyle) |
highlightBuilder |
Deprecated. (context, String text, TextStyle style) |
inlineSourceTagBuilder |
(SourceTagBuildDetails details) → InlineSpan |
sourceTagBuilder |
Deprecated. (context, String content, TextStyle style) |
orderedListBuilder |
(context, String no, Widget child, GptMarkdownConfig config) |
unOrderedListBuilder |
(context, Widget child, GptMarkdownConfig config) |
Reuse the style you are given rather than hard-coding:
blockQuoteBuilder: (context, content, style) => DecoratedBox(
decoration: BoxDecoration(
border: BorderDirectional(
start: BorderSide(
// Follows the theme, because the resolved style is passed in.
color: style.barColor ?? Colors.grey,
width: style.barWidth ?? 3,
),
),
),
child: content,
),
inlineCodeBuilder, inlineLinkBuilder and inlineSourceTagBuilder all
return an InlineSpan. Deliberate. A Widget has to be wrapped in a
WidgetSpan, which cannot wrap across lines, is skipped by text selection,
sits off the baseline, and is one opaque character to the streaming reveal.
Migrating a linkBuilder, term by term:
| old positional argument | new |
|---|---|
context |
details.context |
label (one span) |
details.labelSpans — already parsed, already styled |
url |
details.url |
style |
details.style — now the resolved link style |
| — | details.linkStyle, the resolved LinkStyle |
| — | details.isAutolink |
| — | details.onTap, which calls onLinkTap for you |
| — | details.hoverStyle |
| — | details.config |
Because a builder receives one details object rather than positional arguments, a later release adds a field here instead of a parameter — so nothing you write today stops compiling.
// keep the stock link, change one thing
inlineLinkBuilder: (link) =>
link.defaultSpan(style: link.style.copyWith(fontWeight: FontWeight.bold)),
A
GestureRecognizerfires only on aTextSpanthat carries its owntext, never on one that only haschildren. A parsed link label is the second kind, soTextSpan(children: link.labelSpans, recognizer: tap)renders correctly and is silently never tapped. Returnlink.defaultSpan(), aTappableTextSpan, orlink.asWidgetSpan(). A debug assert catches it.
If you genuinely need a widget:
inlineCodeBuilder: (context, code, style, codeStyle) =>
baselineWidgetSpan(MyChip(code: code, style: style)),
inlineLinkBuilder: (link) => link.asWidgetSpan(MyLinkChip(url: link.url)),
inlineSourceTagBuilder: (tag) => tag.asWidgetSpan(MyChip(tag.id)),
baselineWidgetSpan aligns it on the text baseline and handles text-scale
compensation. A bare WidgetSpan does neither.
A style changes appearance and a builder replaces a widget. Neither teaches the parser a syntax it does not already know, which is what an extension is for:
| What you are adding | Use |
|---|---|
A block syntax such as :::warning |
blockComponents |
An inline token such as @name |
inlinePatterns |
| A payload that must not be parsed at all | inlineDirectives |
[!WARNING] The older extension arguments,
componentsandinlineComponents, are deprecated in 1.3.0 and scheduled for removal in 2.0.0. They still work, but passing either — even an empty list — switches the widget to the legacy regex parser, which ignoresblockComponentsand loses the incremental segment cache, the span-level streaming reveal and lazy sliver rendering.incremental: falsedoes the same.
Nothing else on this page is affected by that choice: the style objects and builders above are honoured on both parsers.
Custom components has the detail, and migration the before and after.
GptMarkdown(
text,
onLinkTap: (url, title) => launchUrlString(url),
onImageTap: (url) => openLightbox(url),
onCodeCopy: (code) => analytics.log('code_copied'),
onSourceTagTap: (content) => showSource(content),
onCheckboxChanged: (value) => persist(value), // needs interactive: true
)
Every style class implements lerp, so a theme transition animates rather than
snapping — colours, widths, radii and padding all interpolate. Nothing to
configure; it follows ThemeData like any other extension.
[!WARNING] Changing a builder at runtime does nothing.
GptMarkdownConfig.isSamedecides whether spans are regenerated, and it cannot compare closures — any consumer writing them inline creates a new one every build, so comparing them would defeat the cache entirely.Give the widget a
keythat changes with the builder, or set it once. Styles,inlinePatterns,blockComponentsand the deprecated component lists are compared and do update live.
[!WARNING] A raw
WidgetSpanscales twice. A paragraph lays inline children out in scaled space and multiplies their reported size back. A child that also scales its own text reserves far more room than it needs at a raised system font setting.Use
baselineWidgetSpan, or wrap the child inMediaQuery.withNoTextScaling.
[!NOTE] Dark mode needs its own extension.
GptMarkdownThemeDatalives onThemeData, sotheme:anddarkTheme:each need one — withbrightness:set to match, or the derived defaults will be wrong.