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
| Use | For | Example |
|---|---|---|
| Style object | Colours, sizes, padding, fonts | BlockQuoteStyle(barWidth: 4) |
| Builder | A replacement widget or structure | blockQuoteBuilder: … |
Where a style goes
The same object is accepted in two places. On one widget:
1GptMarkdown(
2 text,
3 styleSheet: const GptMarkdownStyleSheet(
4 blockQuote: BlockQuoteStyle(barWidth: 4),
5 ),
6)Or for the whole app:
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
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:
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:
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
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)1GptMarkdown(text, onLinkTap: (url, title) => launchUrlString(url))title is the label text, which is useful for confirmation dialogs:
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:
1GptMarkdown(
2 text,
3 styleSheet: const GptMarkdownStyleSheet(
4 inlineCode: InlineCodeStyle(fontFamily: 'GeistMono'),
5 ),
6)Two ready-made chip treatments:
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),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:
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
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.
indent or gapAfterMarker applies to both.CheckboxStyle
size · checkedColor · uncheckedColor · checkColor · borderRadius · gapAfterBox · interactive
Applies to both - [x] task lists and (x) radio options.
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)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
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:
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
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)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'))closed is false while a fence is still being streamed — useful for showing a "generating" state: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
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
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:
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
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:
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:
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
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)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:
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) |
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:1inlineCodeBuilder: (context, code, style, codeStyle) =>
2 baselineWidgetSpan(MyChip(code: code, style: style)),Callbacks
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
keythat 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 inMediaQuery.withNoTextScaling. - Dark mode needs its own extension.
GptMarkdownThemeDatalives onThemeData, sotheme:anddarkTheme:each need one — withbrightness:set to match, or the derived defaults will be wrong.
