Customization

Every component below comes with a copy-paste recipe. Use style objects for appearance — colours, sizes, padding, fonts — and builders when the structure itself must change. Every component supports both.

The one rule: appearance vs. structure

UseForExample
Style objectColours, sizes, padding, fontsBlockQuoteStyle(barWidth: 4)
BuilderA replacement widget or structureblockQuoteBuilder: …
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.

Where a style goes

The same object is accepted in two places. On one widget:

per_widget.dart
1GptMarkdown( 2 text, 3 styleSheet: const GptMarkdownStyleSheet( 4 blockQuote: BlockQuoteStyle(barWidth: 4), 5 ), 6)

Or for the whole app:

app_theme.dart
1MaterialApp( 2 theme: ThemeData( 3 extensions: [ 4 GptMarkdownThemeData( 5 brightness: Brightness.light, 6 styleSheet: const GptMarkdownStyleSheet( 7 blockQuote: BlockQuoteStyle(barColor: Colors.indigo), 8 codeBlock: CodeBlockStyle(borderRadius: Radius.circular(12)), 9 ), 10 ), 11 // Dark needs its own — the extension is per ThemeData. 12 ], 13 ), 14)

The merge is per field — widget field → theme field → package default. With both of the above in force, the quote gets barWidth: 4 from the widget and barColor: Colors.indigo from the theme. Overriding one value never discards the rest, and adding a style sheet never changes how existing content looks.

HeadingStyle

textStyle · padding · showDivider · dividerColor · dividerThickness · dividerPadding

heading_style.dart
1GptMarkdown( 2 text, 3 styleSheet: const GptMarkdownStyleSheet( 4 heading: HeadingStyle( 5 textStyle: TextStyle(letterSpacing: -0.5), 6 padding: EdgeInsets.only(top: 8, bottom: 4), 7 showDivider: false, 8 ), 9 ), 10)

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:

heading_levels.dart
1GptMarkdownThemeData( 2 brightness: Brightness.light, 3 h1: Theme.of(context).textTheme.headlineMedium, 4 h2: Theme.of(context).textTheme.titleLarge, 5)

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. level is 1–6, so one builder handles all six:

heading_anchors.dart
1GptMarkdown( 2 text, 3 headingBuilder: (context, level, content, style) => Row( 4 crossAxisAlignment: CrossAxisAlignment.baseline, 5 textBaseline: TextBaseline.alphabetic, 6 children: [ 7 Flexible(child: content), 8 IconButton(icon: const Icon(Icons.link), onPressed: () {}), 9 ], 10 ), 11)

LinkStyle

color · hoverColor · decoration · decorationThickness · fontWeight

link_style.dart
1GptMarkdown( 2 text, 3 styleSheet: const GptMarkdownStyleSheet( 4 link: LinkStyle( 5 color: Color(0xFF0B57D0), 6 hoverColor: Color(0xFF0842A0), 7 decoration: TextDecoration.none, 8 fontWeight: FontWeight.w500, 9 ), 10 ), 11)
Links do nothing on tap unless you handle them.
The package deliberately does not depend on a URL launcher.
link_tap.dart
1GptMarkdown(text, onLinkTap: (url, title) => launchUrlString(url))

title is the label text, which is useful for confirmation dialogs:

link_confirm.dart
1onLinkTap: (url, title) async { 2 final ok = await confirm('Open "$title"?\n$url'); 3 if (ok) await launchUrlString(url); 4},

InlineCodeStyle

fontFamily · fontFamilyPackage · fontFamilyFallback · fontSizeFactor · fontWeight · color · backgroundColor · borderColor · borderWidth · borderRadius · padding · boxHeightStyle

Your app's mono font:

inline_code_font.dart
1GptMarkdown( 2 text, 3 styleSheet: const GptMarkdownStyleSheet( 4 inlineCode: InlineCodeStyle(fontFamily: 'GeistMono'), 5 ), 6)

Two ready-made chip treatments:

inline_code_chips.dart
1// A GitHub-ish chip: 2inlineCode: InlineCodeStyle( 3 backgroundColor: const Color(0x14656D76), 4 borderColor: Colors.transparent, 5 borderRadius: const Radius.circular(6), 6 padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), 7), 8 9// No chip at all, just monospace: 10inlineCode: InlineCodeStyle( 11 backgroundColor: Colors.transparent, 12 borderWidth: 0, 13 padding: EdgeInsets.zero, 14),
fontSizeFactor is a factor, not a size.
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. Per-code styling needs the builder — returning CodeTextSpan keeps the painted chip; return a plain TextSpan to drop it:

inline_code_builder.dart
1GptMarkdown( 2 text, 3 inlineCodeBuilder: (context, code, style, codeStyle) => CodeTextSpan( 4 text: code, 5 style: style, 6 codeStyle: codeStyle.copyWith( 7 backgroundColor: code.startsWith('TODO') ? Colors.amber : null, 8 ), 9 ), 10)

ListStyle

bulletSize · bulletColor · bulletShape · markerTextStyle · indent · gapAfterMarker

list_style.dart
1GptMarkdown( 2 text, 3 styleSheet: const GptMarkdownStyleSheet( 4 list: ListStyle( 5 bulletSize: 5, 6 bulletColor: Colors.indigo, 7 bulletShape: BoxShape.rectangle, 8 indent: 12, 9 gapAfterMarker: 12, 10 markerTextStyle: TextStyle(fontWeight: FontWeight.w600), 11 ), 12 ), 13)

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.

Bullets and numbers keep separate spacing defaults.
7/10 for bullets, 6/6 for numbers. Setting indent or gapAfterMarker applies to both.

CheckboxStyle

size · checkedColor · uncheckedColor · checkColor · borderRadius · gapAfterBox · interactive

Applies to both - [x] task lists and (x) radio options.

checkbox_style.dart
1GptMarkdown( 2 text, 3 styleSheet: const GptMarkdownStyleSheet( 4 checkbox: CheckboxStyle( 5 size: 18, 6 checkedColor: Colors.green, 7 borderRadius: Radius.circular(4), 8 gapAfterBox: 8, 9 ), 10 ), 11)
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:
interactive_checkbox.dart
1GptMarkdown( 2 markdown, 3 styleSheet: const GptMarkdownStyleSheet( 4 checkbox: CheckboxStyle(interactive: true), 5 ), 6 onCheckboxChanged: (value) { 7 // Rewrite the source, or the tick reverts on the next build. 8 setState(() => markdown = toggleFirstUnchecked(markdown)); 9 }, 10)

BlockQuoteStyle

barWidth · barColor · barRadius · backgroundColor · padding · margin · textStyle

block_quote_style.dart
1GptMarkdown( 2 text, 3 styleSheet: const GptMarkdownStyleSheet( 4 blockQuote: BlockQuoteStyle( 5 barWidth: 4, 6 barColor: Color(0xFF6366F1), 7 barRadius: Radius.circular(2), 8 backgroundColor: Color(0x0A6366F1), 9 padding: EdgeInsetsDirectional.only(start: 12, top: 8, bottom: 8), 10 margin: EdgeInsets.symmetric(vertical: 8), 11 textStyle: TextStyle(fontStyle: FontStyle.italic), 12 ), 13 ), 14)

A background is only drawn when you ask for one — no extra widget in the tree otherwise. A callout style with a builder:

quote_callout.dart
1GptMarkdown( 2 text, 3 blockQuoteBuilder: (context, content, style) => Card( 4 color: Theme.of(context).colorScheme.surfaceContainerHighest, 5 child: Padding(padding: const EdgeInsets.all(12), child: content), 6 ), 7)

CodeBlockStyle

backgroundColor · borderColor · borderWidth · borderRadius · padding · headerPadding · fontFamily · fontFamilyPackage · fontSize · textColor · showLanguageLabel · languageStyle · showCopyButton · copyLabel · copiedLabel

code_block_style.dart
1GptMarkdown( 2 text, 3 styleSheet: const GptMarkdownStyleSheet( 4 codeBlock: CodeBlockStyle( 5 backgroundColor: Color(0xFF1E1E1E), 6 textColor: Color(0xFFD4D4D4), 7 borderRadius: Radius.circular(12), 8 padding: EdgeInsets.all(20), 9 fontFamily: 'GeistMono', 10 showLanguageLabel: true, 11 showCopyButton: true, 12 ), 13 ), 14)
code_block_copy.dart
1// Localise the copy button without replacing the block: 2codeBlock: CodeBlockStyle( 3 copyLabel: AppLocalizations.of(context).copyCode, 4 copiedLabel: AppLocalizations.of(context).copied, 5), 6 7// React to a copy: 8GptMarkdown(text, onCodeCopy: (code) => analytics.log('code_copied'))
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. closed is false while a fence is still being streamed — useful for showing a "generating" state:
wrapping_code_block.dart
1GptMarkdown( 2 text, 3 codeBuilder: (context, name, code, closed) => Container( 4 width: double.infinity, 5 padding: const EdgeInsets.all(12), 6 color: Theme.of(context).colorScheme.surfaceContainerHighest, 7 child: SelectableText(code, style: const TextStyle(fontFamily: 'monospace')), 8 ), 9)

TableStyle

borderColor · borderWidth · borderRadius · cellPadding · headerBackground · headerTextStyle · rowStripeColor

table_style.dart
1GptMarkdown( 2 text, 3 styleSheet: const GptMarkdownStyleSheet( 4 table: TableStyle( 5 borderColor: Color(0x1F000000), 6 borderWidth: 1, 7 borderRadius: Radius.circular(8), 8 cellPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), 9 headerBackground: Color(0x0A000000), 10 headerTextStyle: TextStyle(fontWeight: FontWeight.w600), 11 ), 12 ), 13)

Tables already scroll horizontally when they exceed the available width.

ImageStyle

borderRadius · padding · fit · maxWidth · maxHeight

image_style.dart
1GptMarkdown( 2 text, 3 styleSheet: const GptMarkdownStyleSheet( 4 image: ImageStyle( 5 borderRadius: Radius.circular(8), 6 padding: EdgeInsets.symmetric(vertical: 8), 7 maxHeight: 320, 8 ), 9 ), 10)

Cached network images, with a placeholder and error state. width and height come from the alt text when written as WxH:

cached_images.dart
1GptMarkdown( 2 text, 3 imageBuilder: (context, url, width, height) => CachedNetworkImage( 4 imageUrl: url, 5 width: width, 6 height: height, 7 placeholder: (context, _) => const SizedBox( 8 height: 120, 9 child: Center(child: CircularProgressIndicator()), 10 ), 11 errorWidget: (context, _, __) => const Icon(Icons.broken_image), 12 ), 13 onImageTap: (url) => openLightbox(url), 14)

HrStyle

thickness · color · padding

hr_style.dart
1GptMarkdown( 2 text, 3 styleSheet: const GptMarkdownStyleSheet( 4 hr: HrStyle( 5 thickness: 2, 6 color: Color(0x1F000000), 7 padding: EdgeInsets.symmetric(vertical: 16), 8 ), 9 ), 10)

A dotted rule:

dotted_rule.dart
1GptMarkdown( 2 text, 3 hrBuilder: (context, style) => const Padding( 4 padding: EdgeInsets.symmetric(vertical: 12), 5 child: DottedLine(), 6 ), 7)

SourceTagStyle

backgroundColor · textStyle · size · shape · padding

The chip drawn for a [1] citation, common in RAG answers:

source_tag.dart
1GptMarkdown( 2 text, 3 styleSheet: const GptMarkdownStyleSheet( 4 sourceTag: SourceTagStyle( 5 size: 18, 6 backgroundColor: Color(0xFFE8DEF8), 7 shape: BoxShape.rectangle, 8 textStyle: TextStyle(fontSize: 11, fontWeight: FontWeight.w600), 9 ), 10 ), 11 onSourceTagTap: (content) => showSource(content), 12)

LatexStyle

textStyle · padding · backgroundColor · borderRadius · scrollBlockHorizontally

latex_style.dart
1GptMarkdown( 2 text, 3 styleSheet: const GptMarkdownStyleSheet( 4 latex: LatexStyle( 5 scrollBlockHorizontally: true, 6 padding: EdgeInsets.symmetric(vertical: 8), 7 backgroundColor: Color(0x08000000), 8 borderRadius: Radius.circular(6), 9 ), 10 ), 11)
Rendered maths cannot wrap.
Without scrollBlockHorizontally: true, a wide formula overflows on a phone. This is the single most common LaTeX complaint. Maths still needs a renderer — see LaTeX support.

Builders receive resolved styles

Each builder receives the fully resolved style, so it never has to guess a default or restate a theme colour. Reuse the style you are given rather than hard-coding:

resolved_style.dart
1blockQuoteBuilder: (context, content, style) => DecoratedBox( 2 decoration: BoxDecoration( 3 border: BorderDirectional( 4 start: BorderSide( 5 // Follows the theme, because the resolved style is passed in. 6 color: style.barColor ?? Colors.grey, 7 width: style.barWidth ?? 3, 8 ), 9 ), 10 ), 11 child: content, 12),
headingBuilder(context, int level, Widget content, HeadingStyle style)
blockQuoteBuilder(context, Widget content, BlockQuoteStyle style)
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)
latexBuilder(context, String tex, TextStyle style, bool inline)
linkBuilder(context, InlineSpan label, String url, TextStyle style)
inlineCodeBuilder(context, String code, TextStyle style, InlineCodeStyle codeStyle)
sourceTagBuilder(context, String content, TextStyle style)
orderedListBuilder(context, String no, Widget child, GptMarkdownConfig config)
unOrderedListBuilder(context, Widget child, GptMarkdownConfig config)
inlineCodeBuilder returns a span, not a widget — deliberately.
A Widget has to be wrapped in a WidgetSpan, which cannot wrap across lines, is skipped by text selection, and sits off the baseline. If you genuinely need a widget, baselineWidgetSpan aligns it on the text baseline and handles text-scale compensation — a bare WidgetSpan does neither:
baseline_widget_span.dart
1inlineCodeBuilder: (context, code, style, codeStyle) => 2 baselineWidgetSpan(MyChip(code: code, style: style)),

Callbacks

callbacks.dart
1GptMarkdown( 2 text, 3 onLinkTap: (url, title) => launchUrlString(url), 4 onImageTap: (url) => openLightbox(url), 5 onCodeCopy: (code) => analytics.log('code_copied'), 6 onSourceTagTap: (content) => showSource(content), 7 onCheckboxChanged: (value) => persist(value), // needs interactive: true 8)

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.

Common mistakes

  • Changing a builder at runtime does nothing. Builders are closures and cannot be compared when spans are cached. Give the widget a key that changes with the builder, or set it once. Styles, patterns, and component lists are compared and do update live.
  • A raw WidgetSpan scales 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 in MediaQuery.withNoTextScaling.
  • Dark mode needs its own extension. GptMarkdownThemeData lives on ThemeData, so theme: and darkTheme: each need one — with brightness: set to match, or the derived defaults will be wrong.