Detailed reference for the core composables and configuration types in the
markdown-multiplatformmodule.
- For an overview, installation and feature tour, see README.md.
- Core Composables
- Configuration
- Plugins & Extension Points
- Plugin Modules
- Other APIs
- Common Usage Patterns
The main entry point for rendering Markdown content. Parses and renders a Markdown string.
Signature (from MarkdownView.kt):
@Composable
fun MarkdownView(
text: String,
modifier: Modifier = Modifier,
markdownRenderConfig: MarkdownRenderConfig =
remember { MarkdownRenderConfig.Builder().build() },
actionHandler: ActionHandler? = null,
renderDependencies: Map<String, Any> = emptyMap(),
showNotSupported: Boolean = false,
isStreaming: Boolean = false,
)Parameters
text: The Markdown text to render.modifier: Standard ComposeModifierfor sizing, padding, etc.markdownRenderConfig: Rendering configuration. Remember custom instances at the call site.actionHandler: Optional handler for interactions.renderDependencies: Dependencies exposed to custom renderers and string builders.showNotSupported: Whether unsupported elements display fallback text.
MarkdownView(
text = "# Hello\n\nThis is **Markdown**.",
modifier = Modifier.fillMaxSize(),
)For background parsing, use the overload with a required dispatcher:
MarkdownView(
text = markdown,
parseDispatcher = Dispatchers.Default,
onLoading = { CircularProgressIndicator() },
onError = { error -> Text(error.message.orEmpty()) },
)Changing text, the parser, or parseDispatcher cancels the previous parse and starts a new one.
Set isStreaming = true while text grows only by appending. Every successful update reuses all
top-level nodes except the previous final block, reparses from that block's source-line start, and
rebases the new tail's SourceSpan line/input indices. Earlier edits or missing source spans fall
back to a full parse. Set isStreaming = false when the stream completes to force one final full
parse. MarkdownText supports the same behavior.
The parser is configured through MarkdownRenderConfig.Builder.streamingMarkdownParserFactory.
Each component receives its own stateful parser instance. A custom StreamingMarkdownParser fully
controls caching, incremental parsing, source positions, AST merging, fallback, and final parsing;
its parsing method receives only the complete content and isStreaming.
The factory is null by default. Without one, isStreaming = true falls back to the normal parser
and performs a full parse for every update. Configure ::DefaultStreamingMarkdownParser
explicitly to enable the built-in incremental workflow. The default streaming implementation
creates its own parser from the render configuration and forces at least block source spans.
For each changed input, a custom streaming parser must return a new root Document instance.
Returning the same root object can make Compose treat the parse result as unchanged. Preserve the
identity of completed, unchanged top-level child blocks so keyed block renderers can skip
recomposing the stable prefix; only the replaced tail should receive new node instances.
val config = MarkdownRenderConfig.Builder()
.streamingMarkdownParserFactory(::DefaultStreamingMarkdownParser)
.build()MarkdownView(
text = streamedMarkdown,
parseDispatcher = Dispatchers.Default,
isStreaming = streamInProgress,
)Parses the complete Markdown string up front, then renders each top-level block as a lazy item.
@Composable
fun LazyMarkdownColumn(
text: String,
modifier: Modifier = Modifier,
markdownRenderConfig: MarkdownRenderConfig =
remember { MarkdownRenderConfig.Builder().build() },
actionHandler: ActionHandler? = null,
renderDependencies: Map<String, Any> = emptyMap(),
showNotSupported: Boolean = false,
lazyListState: LazyListState = rememberLazyListState(),
)LazyMarkdownColumn(
text = longMarkdownContent,
modifier = Modifier.fillMaxSize(),
)Incrementally reads and parses a line-oriented source. Parsed top-level nodes far from the viewport are recycled in bounded batches and reloaded by source line range when the user scrolls back.
@Composable
fun LazyMarkdownView(
source: MarkdownLineSource,
modifier: Modifier = Modifier,
markdownRenderConfig: MarkdownRenderConfig =
remember { MarkdownRenderConfig.Builder().build() },
actionHandler: ActionHandler? = null,
renderDependencies: Map<String, Any> = emptyMap(),
showNotSupported: Boolean = false,
chunkLoaderConfig: MarkdownChunkLoaderConfig = MarkdownChunkLoaderConfig(),
nestedPrefetchItemCount: Int = 3,
lazyListState: LazyListState = rememberLazyListState(),
onLoadingChanged: (Boolean) -> Unit = {},
onStateChanged: (LazyMarkdownViewState) -> Unit = {},
onError: (Throwable) -> Unit = {},
)An overload accepting text: String is also available and internally uses
StringMarkdownLineSource, making it easy to compare the same Markdown text with the Android API.
fun interface MarkdownLineSource {
suspend fun readLines(startLine: Int, lineCount: Int): List<String>
}The source uses zero-based line indices, must remain immutable, and must support rereading old
ranges. Returning fewer lines than requested signals end-of-source. Use
StringMarkdownLineSource for content already held in memory, or implement the interface over a
file, asset, database, or range API for true lazy I/O.
MarkdownChunkLoaderConfig controls read batch sizes, minNodesAhead / minNodesBehind watermarks,
node and source-line cache limits, minimum recycle batch, and source/parser dispatchers.
nestedPrefetchItemCount separately controls Compose lazy-item precomposition.
Eviction never removes the visible anchor or its safety margin. Stable source-span keys keep the
same first visible item when nodes before it are inserted or removed. If no safe recycle batch is
available, the cache may temporarily exceed its target instead of moving the viewport.
maxCachedSourceLines is also a hard limit for one unconfirmed trailing block or source context.
val source = remember(markdown) { StringMarkdownLineSource(markdown) }
LazyMarkdownView(
source = source,
modifier = Modifier.fillMaxSize(),
chunkLoaderConfig = MarkdownChunkLoaderConfig(
initialLineCount = 1000,
incrementalLineCount = 500,
minNodesAhead = 100,
minNodesBehind = 30,
maxCachedNodes = 500,
maxCachedSourceLines = 10_000,
),
)onLoadingChanged reports only initial loading while no content is available. Background parsing
is silent by default. onStateChanged optionally reports InitialLoading, LoadingBefore,
LoadingAfter, and Idle; AST recycling never reports loading.
Chunk parses do not share reference-definition state. Use inline links, or choose
LazyMarkdownColumn when full-document semantics are required. Custom parsers must emit unique,
source-spanned top-level nodes.
LazyMarkdownView creates a dedicated parser from MarkdownRenderConfig and forces at least
IncludeSourceSpans.BLOCKS, even if regular rendering is configured with NONE.
Renders a single parsed AST node and dispatches to the appropriate block renderer.
Signature (from MarkdownContent.kt):
@Composable
fun MarkdownContent(
node: Node,
modifier: Modifier = Modifier,
)Typically used internally or within custom IBlockRenderer implementations when you need to recursively render child nodes.
A utility composable for iterating and rendering all children of a parent node with proper spacing.
Signature (from MarkdownContent.kt):
@Composable
fun MarkdownChildren(
parent: Node,
modifier: Modifier = Modifier,
children: List<Node>? = null,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
spacerHeight: Dp = currentTheme().spacerTheme.spacerHeight,
showSpacer: Boolean = currentTheme().spacerTheme.showSpacer,
childModifierFactory: (child: Node) -> Modifier = {
Modifier.wrapContentHeight().fillMaxWidth()
},
onBeforeChild: (@Composable (child: Node, parent: Node) -> Unit)? = null,
onAfterChild: (@Composable (child: Node, parent: Node) -> Unit)? = null,
onBeforeAll: (@Composable (parent: Node) -> Unit)? = null,
onAfterAll: (@Composable (parent: Node) -> Unit)? = null,
)Parameters
parent: TheNodewhose children to render.children: Override list of children to render (defaults toparent's children).spacerHeight: Vertical spacing between children. Defaults totheme.spacerTheme.spacerHeight.showSpacer: Whether to insert spacers. Defaults totheme.spacerTheme.showSpacer.onBeforeChild/onAfterChild: Optional composable callbacks before/after each child.onBeforeAll/onAfterAll: Optional composable callbacks before/after all children.
Use Case
When implementing a custom IBlockRenderer (e.g., a custom container block) and you need to render nested content with standard spacing rules.
Renders the inline children of a block node as styled text using AnnotatedString.
Signature (from MarkdownInlineText.kt):
@Composable
fun MarkdownInlineText(
parent: Node,
modifier: Modifier = Modifier,
textAlign: TextAlign = TextAlign.Start,
textStyle: TextStyle? = null,
)Parameters
parent: The block node whose inline children to render as text.textAlign: Text alignment.textStyle: Override text style (defaults to theme style).
Both this component and MarkdownText use rememberMarkdownAnnotatedStringResult(...). The helper
reads the active registry from ProvideMarkdownLocals; it does not accept a registry directly.
MarkdownText provides the cached text-mode registry as an override, while regular inline rendering
uses the base registry from MarkdownRenderConfig.
Text-based rendering that renders the entire Markdown document through a single RichText composable, enabling cross-paragraph text selection. Unlike MarkdownView which renders each block as a separate composable in a Column, MarkdownText merges text blocks into a single AnnotatedString and embeds non-text blocks (code blocks, block quotes, lists, etc.) as inline content.
Signature (from MarkdownText.kt):
@Composable
fun MarkdownText(
text: String,
modifier: Modifier = Modifier,
markdownRenderConfig: MarkdownRenderConfig =
remember { MarkdownRenderConfig.Builder().build() },
actionHandler: ActionHandler? = null,
renderDependencies: Map<String, Any> = emptyMap(),
showNotSupported: Boolean = false,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
textAlign: TextAlign? = null,
maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
isStreaming: Boolean = false,
onTextLayout: (TextLayoutResult) -> Unit = {},
)Parameters
text: The Markdown string to parse and render.markdownRenderConfig: The configuration for parsing and rendering.actionHandler: Optional handler for link clicks and other actions.showNotSupported: Whether to show text for unsupported elements.overflow: How visual overflow is handled (TextOverflow.Clip,Ellipsis, etc.).softWrap: Whether to break text at soft line breaks.textAlign: Text alignment.maxLines/minLines: Line count constraints for the rendered text.letterSpacing: Spacing between characters.textDecoration: Text decorations (underline, strikethrough).onTextLayout: Callback invoked withTextLayoutResultafter text layout.
Example
SelectionContainer {
MarkdownText(
text = markdownContent,
markdownRenderConfig = config,
modifier = Modifier.padding(16.dp),
maxLines = 10,
overflow = TextOverflow.Ellipsis,
)
}The async overload adds a required parseDispatcher plus onLoading and onError:
MarkdownText(
text = markdownContent,
parseDispatcher = Dispatchers.Default,
onLoading = { CircularProgressIndicator() },
onError = { error -> Text(error.message.orEmpty()) },
)MarkdownRenderConfig holds everything needed for parsing and rendering Markdown:
- A
MarkdownThemedescribing typography, colors and component styles. - A
MarkdownParser(powered bycommonmark-kotlin). - A
RenderRegistrymapping node types to renderers. - An
IncludeSourceSpanspolicy. It defaults toBLOCKSand can be changed with the builder.
Instances are created via MarkdownRenderConfig.Builder:
When creating one inside a Composable, wrap the complete builder expression in remember so parser
and renderer instances remain stable across recompositions.
The regular markdownParser is created lazily on first access from the stored extensions and
IncludeSourceSpans policy. Dedicated streaming and lazy parsers are created separately when needed.
fun includeSourceSpans(includeSourceSpans: IncludeSourceSpans): Builder
fun streamingMarkdownParserFactory(
factory: ((MarkdownRenderConfig) -> StreamingMarkdownParser)?,
): Builder
val config = MarkdownRenderConfig.Builder()
// configure theme, plugins, renderers...
.build()Builder for customizing parsing, theming and rendering behavior using a fluent API.
Key methods (from MarkdownRenderConfig.kt):
class MarkdownRenderConfig {
class Builder {
fun markdownTheme(markdownTheme: MarkdownTheme): Builder
fun addPlugin(plugin: IMarkdownRenderPlugin): Builder
fun <T : Node> addInlineNodeStringBuilder(
nodeClass: KClass<T>,
builder: IInlineNodeStringBuilder<T>,
): Builder
fun <T : Node> addBlockRenderer(
nodeClass: KClass<T>,
renderer: IBlockRenderer<T>,
): Builder
fun addExtension(extension: Extension): Builder
fun markdownTextRenderer(renderer: MarkdownTextRenderer): Builder
fun markdownContentRenderer(renderer: MarkdownContentRenderer): Builder
fun build(): MarkdownRenderConfig
}
}Sets the visual theme. If not set, uses default MarkdownTheme().
Registers a rendering plugin. Plugins can provide custom parser extensions, block renderers and inline string builders.
Low-level hooks for customizing rendering of specific node types:
addInlineNodeStringBuilder(nodeClass, builder): Defines how an inline node type is converted to styled text spans.addBlockRenderer(nodeClass, renderer): Defines how a block node type is rendered as Compose UI.
Register a commonmark parser extension directly (e.g., TablesExtension.create()). Extensions registered via plugins are also collected automatically.
Advanced overrides:
markdownTextRenderer: Override how text nodes are rendered.markdownContentRenderer: Override how content nodes are rendered.
MarkdownTheme is the core theme model controlling how Markdown content appears in Compose.
@Stable
data class MarkdownTheme(
val breakLineHeight: Dp = 1.dp,
val breakLineColor: Color = Color(0xFFE0E0E0),
val textStyle: TextStyle = TextStyle(
fontSize = 18.sp,
fontFamily = FontFamily.Default,
color = Color.Black,
lineHeight = 20.sp,
),
val strongEmphasis: SpanStyle = SpanStyle(fontWeight = FontWeight.Bold),
val emphasis: SpanStyle = SpanStyle(fontStyle = FontStyle.Italic),
val code: TextStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
color = Color(0xFF37474F),
background = Color(0xFFF5F5F5),
),
val strikethrough: SpanStyle = SpanStyle(textDecoration = TextDecoration.LineThrough),
val subscript: SpanStyle = SpanStyle(baselineShift = Subscript),
val link: TextLinkStyles = TextLinkStyles(...),
val headStyle: Map<Int, TextStyle> = mapOf(
HEAD1 to TextStyle(fontSize = 32.sp, fontWeight = FontWeight.Bold, lineHeight = 36.sp),
HEAD2 to TextStyle(fontSize = 28.sp, fontWeight = FontWeight.Bold, lineHeight = 32.sp),
HEAD3 to TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold, lineHeight = 28.sp),
HEAD4 to TextStyle(fontSize = 20.sp, fontWeight = FontWeight.Bold, lineHeight = 24.sp),
HEAD5 to TextStyle(fontSize = 18.sp, fontWeight = FontWeight.Bold, lineHeight = 22.sp),
HEAD6 to TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold, lineHeight = 20.sp),
),
val listTheme: ListTheme = ListTheme(),
val blockQuoteTheme: BlockQuoteTheme = BlockQuoteTheme(),
val spacerTheme: SpacerTheme = SpacerTheme(),
val codeBlockTheme: CodeBlockTheme = CodeBlockTheme(),
) {
companion object {
const val HEAD1 = 1
const val HEAD2 = 2
// ... through HEAD6
}
}Headings are configured via the headStyle map. Keys are integers 1-6, exposed as constants:
MarkdownTheme.HEAD1 // H1
MarkdownTheme.HEAD2 // H2
// ... through HEAD6@Immutable
data class BlockQuoteTheme(
val borderColor: Color = Color.LightGray,
val borderWidth: Dp = 5.dp,
val backgroundColor: Color = Color(0xFFF5F5F5),
val shape: Shape = RoundedCornerShape(topEnd = 8.dp, bottomEnd = 8.dp),
val padding: PaddingValues = PaddingValues(horizontal = 12.dp),
val textStyle: TextStyle? = TextStyle(fontStyle = FontStyle.Italic),
)@Immutable
data class SpacerTheme(
val showSpacer: Boolean = true,
val spacerHeight: Dp = 12.dp,
)@Immutable
data class ListTheme(
val markerSpacerWidth: Dp = 4.dp,
val showSpacerInTightList: Boolean = true,
val tightListSpacerHeight: Dp = 8.dp,
val markerTextStyle: TextStyle? = TextStyle(
lineHeight = 24.sp,
fontSize = 17.sp,
textAlign = TextAlign.End,
),
)@Immutable
data class CodeBlockTheme(
val backgroundColor: Color = Color.White,
val shape: Shape = RoundedCornerShape(size = 16.dp),
val borderColor: Color = Color.LightGray,
val borderWidth: Dp = 0.5.dp,
val contentTheme: CodeContentTheme = CodeContentTheme(),
val codeTitleTextStyle: TextStyle = TextStyle(fontSize = 12.sp, color = Color.Gray),
val codeCopyTextStyle: TextStyle = TextStyle(fontSize = 12.sp, color = Color.Blue),
val blockModifier: Modifier = Modifier.padding(vertical = 12.dp),
val headerModifier: Modifier = Modifier.padding(horizontal = 17.dp),
val showHeader: Boolean = true,
val showCopyButton: Boolean = true,
)@Immutable
data class CodeContentTheme(
val showLineNumber: Boolean = true,
val softWrap: Boolean = true,
val maxLines: Int = Int.MAX_VALUE,
val minLines: Int = 1,
val contentPadding: PaddingValues = PaddingValues(4.dp),
val lineNumberPadding: PaddingValues = PaddingValues(
start = 4.dp, top = 4.dp, bottom = 4.dp, end = 16.dp,
),
val overflow: TextOverflow = TextOverflow.Clip,
val codeTextStyle: TextStyle = TextStyle(fontSize = 14.sp, lineHeight = 18.sp),
val lineNumberTextStyle: TextStyle = TextStyle(
fontSize = 14.sp, lineHeight = 18.sp, color = Color.Gray, textAlign = TextAlign.End,
),
val modifier: Modifier = Modifier.padding(start = 17.dp, end = 17.dp, top = 17.dp),
val height: Dp? = null,
val disableSelection: Boolean = false,
)Example
val theme = MarkdownTheme(
textStyle = TextStyle(fontSize = 16.sp, lineHeight = 24.sp),
headStyle = mapOf(
MarkdownTheme.HEAD1 to TextStyle(fontSize = 32.sp, fontWeight = FontWeight.Bold),
),
codeBlockTheme = CodeBlockTheme(
backgroundColor = Color(0xFF1E1E1E),
contentTheme = CodeContentTheme(
codeTextStyle = TextStyle(color = Color.White, fontSize = 14.sp),
),
),
)
val config = MarkdownRenderConfig.Builder()
.markdownTheme(theme)
.build()The entry point for adding functionality to the Markdown engine.
interface IMarkdownRenderPlugin {
fun parserExtensions(): List<Extension> = emptyList()
fun blockRenderers(): Map<KClass<out Node>, IBlockRenderer<*>> = emptyMap()
fun inlineNodeStringBuilders(): Map<KClass<out Node>, IInlineNodeStringBuilder<*>> = emptyMap()
}A plugin can:
- Provide custom parser extensions via
parserExtensions()(e.g.,TablesExtension.create()for GFM tables). - Register block renderers and inline string builders.
Plugins are added via MarkdownRenderConfig.Builder.addPlugin().
You can also extend AbstractMarkdownRenderPlugin for convenience:
abstract class AbstractMarkdownRenderPlugin : IMarkdownRenderPlugin {
override fun parserExtensions(): List<Extension> = emptyList()
override fun blockRenderers(): Map<KClass<out Node>, IBlockRenderer<*>> = emptyMap()
override fun inlineNodeStringBuilders(): Map<KClass<out Node>, IInlineNodeStringBuilder<*>> = emptyMap()
}Renders a specific block node as Compose UI.
interface IBlockRenderer<T : Node> {
@Composable
fun Invoke(
node: T,
modifier: Modifier,
)
}Parameters
node: The commonmark AST node to render.modifier: Modifier from the parent layout. Implementations should apply it to preserve layout consistency.
Implementation Tips
- Use
MarkdownChildrento render nested children with standard spacing. - Access the current theme via
currentTheme(). - Access the action handler via
currentActionHandler().
Converts an inline node into styled text spans within an AnnotatedString.
interface IInlineNodeStringBuilder<T : Node> {
fun AnnotatedString.Builder.buildInlineNodeString(
node: T,
inlineContentMap: MarkdownInlineViewMap,
markdownTheme: MarkdownTheme,
actionHandler: ActionHandlerState?,
indentLevel: Int,
isShowNotSupported: Boolean,
renderRegistry: RenderRegistry,
nodeStringBuilderContext: NodeStringBuilderContext,
)
}Parameters
node: The inline AST node.inlineContentMap: Collection associating annotation IDs withMarkdownInlineViewvalues.markdownTheme: Current theme for reading styles.actionHandler: Optional interaction handler for links, etc.renderRegistry: Used for recursively building child node strings.nodeStringBuilderContext: Context providing text measurement, density, clipboard, etc.
Registering inline content
Use the public helper to register the map entry and append its matching embedded or standalone annotation in one operation:
fun AnnotatedString.Builder.appendMarkdownInlineContent(
id: String,
inlineContent: RichTextInlineContent,
inlineContentMap: MarkdownInlineViewMap,
alternateText: String = "\uFFFD",
overwrite: Boolean = false,
): String- The default
overwrite = falsepreserves an existing entry and assigns the new content a deterministic_1,_2, and so on suffix. The helper returns the actual ID. - Pass the node class name as the base ID. The helper's occurrence suffix makes each annotation unique, so adding content, URLs, hashes, or source positions to the base ID is unnecessary. Class-only IDs are shorter, avoid exposing content, and centralize ID generation.
overwrite = truereplaces the entry under the requested ID. Every earlier or later annotation with that ID then resolves to the replacement. Existing and replacement content must both be embedded or both be standalone; cross-type overwrite is rejected. Use overwrite only for stateless, semantically interchangeable occurrences.- When using native
appendInlineContent(...)orappendStandaloneInlineTextContent(...), manage IDs manually. Reassigning an ID in the map replaces the content for every annotation with that ID.
appendMarkdownInlineContent(
id = node::class.simpleName ?: "Node",
inlineContent = buildStatusInlineContent(node),
inlineContentMap = inlineContentMap,
alternateText = "[${node.status}]",
)Helper class:
open class CompositeChildNodeStringBuilder : IInlineNodeStringBuilder<Node> {
open fun getSpanStyle(node: Node, markdownTheme: MarkdownTheme): SpanStyle? = null
open fun getParagraphStyle(node: Node, markdownTheme: MarkdownTheme): ParagraphStyle? = null
}Represents inline composable content that can be embedded within text.
sealed interface MarkdownInlineView {
data class MarkdownRichTextInlineContent(
val inlineContent: RichTextInlineContent,
) : MarkdownInlineView
}RichTextInlineContent has two variants:
EmbeddedRichTextInlineContent-- Small inline elements (icons, badges) that sit within the text flow.StandaloneInlineContent-- Full-width block elements (cards, media) rendered as separate sections.
Adaptive inline content converts measured pixel dimensions to sp through the process-wide
PlaceholderTextUnitConverter proxy:
object PlaceholderTextUnitConverter {
var delegate: (density: Density, px: Int) -> TextUnit
fun convert(density: Density, px: Int): TextUnit
fun reset()
}The default delegate uses Compose's native Density.toSp conversion on every platform. Compose
1.10 is the minimum supported Android baseline, so no compatibility switch is required.
For a custom conversion, replace the delegate directly:
PlaceholderTextUnitConverter.delegate = { density, px ->
with(density) { px.toSp() }
}Call PlaceholderTextUnitConverter.reset() to restore the native default. The
delegate is global to the process, so do not change it during composition.
RenderRegistry is built during MarkdownRenderConfig.Builder.build() and determines how each node type is rendered.
data class RenderRegistry(
private val blockRenderers: Map<KClass<out Node>, IBlockRenderer<*>>,
private val inlineNodeStringBuilders: Map<KClass<out Node>, IInlineNodeStringBuilder<*>>,
val markdownContentRenderer: MarkdownContentRenderer? = null,
val markdownTextRenderer: MarkdownTextRenderer? = null,
) {
fun getBlockRenderer(nodeClass: KClass<out Node>): IBlockRenderer<*>?
fun getInlineNodeStringBuilder(nodeClass: KClass<out Node>): IInlineNodeStringBuilder<*>?
}You typically interact with it indirectly via Builder.addBlockRenderer(...) and Builder.addInlineNodeStringBuilder(...).
Custom renderer interfaces:
fun interface MarkdownContentRenderer {
@Composable
operator fun invoke(node: Node, modifier: Modifier)
}
fun interface MarkdownTextRenderer {
@Composable
operator fun invoke(
parent: Node,
modifier: Modifier,
textAlign: TextAlign,
textStyle: TextStyle?,
)
}File: core/renders/CodeBlockRenderer.kt
This module contains the composable renderer for fenced and indented code blocks:
CodeAnnotator– fun interface for transforming raw code text into a styledAnnotatedString(e.g. syntax highlighting, diff coloring).CodeBlockRenderer–IBlockRenderercombining a header (language label + copy button) and code content (line numbers, optional scroll).
fun interface CodeAnnotator {
fun annotate(code: String, language: String, node: Node): AnnotatedString
}
class CodeBlockRenderer(
private val codeAnnotator: CodeAnnotator? = null,
) : IBlockRenderer<Node>CodeBlockRenderer reads layout and style from MarkdownTheme.codeBlockTheme:
backgroundColor,borderWidth,borderColor,shape,blockModifier,headerModifiershowHeader,showCopyButton,codeTitleTextStyle,codeCopyTextStylecontentTheme(code font, line numbers, padding, height, softWrap, etc.)
Syntax highlighting with BasicSyntaxHighlighter
The library ships a ready-made CodeAnnotator that applies regex-based token coloring for 20+
languages. Pass a BasicSyntaxHighlighter instance to enable it:
val config = MarkdownRenderConfig.Builder()
.addBlockRenderer(
FencedCodeBlock::class,
CodeBlockRenderer(codeAnnotator = BasicSyntaxHighlighter()),
)
.addBlockRenderer(
IndentedCodeBlock::class,
CodeBlockRenderer(codeAnnotator = BasicSyntaxHighlighter()),
)
.build()Customize colors via CodeColors:
val highlighter = BasicSyntaxHighlighter(
colors = CodeColors(
keyword = Color(0xFF569CD6),
string = Color(0xFFCE9178),
comment = Color(0xFF6A9955),
number = Color(0xFFB5CEA8),
annotation = Color(0xFFDCDC00),
type = Color(0xFF4EC9B0),
)
)Supported languages (fenced code fence info string, case-insensitive):
kotlin, java, javascript/js, typescript/ts, python, swift, rust, go, dart,
c, cpp/c++, cs/csharp/c#, ruby, php, sql, bash/sh/shell/zsh,
css, html, xml, yaml/yml, toml, json.
Implement CodeAnnotator directly to integrate any third-party highlighter:
val config = MarkdownRenderConfig.Builder()
.addBlockRenderer(
FencedCodeBlock::class,
CodeBlockRenderer(
codeAnnotator = CodeAnnotator { code, language, _ ->
myHighlighter.highlight(code, language)
},
),
)
.build()Provides GFM table rendering support. Uses commonmark-kotlin-ext-gfm-tables for parsing.
Module: markdown-multiplatform-table
class TableMarkdownPlugin(
private val tableTheme: TableTheme = TableTheme(),
) : IMarkdownRenderPluginTableTheme:
data class TableTheme(
val borderColor: Color = Color.Gray,
val borderThickness: Dp = 1.dp,
val titleBackgroundColor: Color = Color.LightGray,
val tableHeaderBackgroundColor: Color = Color.White,
val tableCellBackgroundColor: Color = Color.White,
val cellTextStyle: TextStyle? = null,
val headerTextStyle: TextStyle? = TextStyle(fontWeight = FontWeight.Bold),
val copyTextStyle: TextStyle = TextStyle(fontSize = 12.sp, color = Color.Black),
val shape: Shape = RoundedCornerShape(8.dp),
val cellPadding: PaddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
)Custom renderers:
fun interface TableWidgetRenderer {
@Composable
operator fun invoke(node: Node, modifier: Modifier)
}
class TableRenderer(
private val tableTheme: TableTheme = TableTheme(),
tableTitleRenderer: TableWidgetRenderer? = null,
tableCellRenderer: TableWidgetRenderer? = null,
) : IBlockRenderer<TableBlock>Example:
// Default
val config = MarkdownRenderConfig.Builder()
.addPlugin(TableMarkdownPlugin())
.build()
// Custom theme
val config = MarkdownRenderConfig.Builder()
.addPlugin(TableMarkdownPlugin(
tableTheme = TableTheme(
borderColor = Color.Blue,
borderThickness = 2.dp,
)
))
.build()Provides Markdown image rendering support.
Module: markdown-multiplatform-image
class ImageMarkdownPlugin(
private val imageTheme: ImageTheme = ImageTheme(),
private val loadingView: ImageWidgetRenderer = LoadingImageWidgetRenderer(),
errorView: ImageWidgetRenderer? = null,
) : IMarkdownRenderPluginImageTheme:
@Immutable
data class ImageTheme(
val alignment: Alignment = Alignment.Center,
val contentScale: ContentScale = ContentScale.Inside,
val shape: Shape = RoundedCornerShape(8.dp),
val modifier: Modifier = Modifier,
val errorPlaceholderColor: Color = Color(0xFFE0E0E0),
)Custom loading/error views:
fun interface ImageWidgetRenderer {
@Composable
operator fun invoke(
url: String,
contentDescription: String?,
node: Node,
modifier: Modifier,
)
}Example:
val config = MarkdownRenderConfig.Builder()
.addPlugin(ImageMarkdownPlugin(
imageTheme = ImageTheme(
contentScale = ContentScale.Crop,
shape = RoundedCornerShape(16.dp),
)
))
.build()Provides HTML inline tag support within Markdown content.
Module: markdown-multiplatform-html
class HtmlMarkdownPlugin(
customTagHandlers: List<HtmlInlineTagHandler> = emptyList(),
) : IMarkdownRenderPluginDefault supported tags: <b>, <strong>, <i>, <em>, <u>, <ins>, <del>, <s>, <strike>, <a>, <span> (with inline CSS style support).
Custom tag handler interface:
interface HtmlInlineTagHandler {
val tagNames: Set<String>
fun onOpenTag(
tagName: String,
rawTag: String,
builder: AnnotatedString.Builder,
context: HtmlInlineTagContext,
)
fun onCloseTag(
tagName: String,
builder: AnnotatedString.Builder,
context: HtmlInlineTagContext,
) {
builder.pop()
}
}HtmlInlineTagContext:
data class HtmlInlineTagContext(
val node: Node,
val inlineContentMap: MarkdownInlineViewMap,
val markdownTheme: MarkdownTheme,
val actionHandler: ActionHandlerState?,
val indentLevel: Int,
val isShowNotSupported: Boolean,
val renderRegistry: RenderRegistry,
val nodeStringBuilderContext: NodeStringBuilderContext,
)Example:
// Default
val config = MarkdownRenderConfig.Builder()
.addPlugin(HtmlMarkdownPlugin())
.build()
// Custom <mark> tag handler
class MarkTagHandler : HtmlInlineTagHandler {
override val tagNames = setOf("mark")
override fun onOpenTag(
tagName: String,
rawTag: String,
builder: AnnotatedString.Builder,
context: HtmlInlineTagContext,
) {
builder.pushStyle(SpanStyle(background = Color.Yellow))
}
}
val config = MarkdownRenderConfig.Builder()
.addPlugin(HtmlMarkdownPlugin(customTagHandlers = listOf(MarkTagHandler())))
.build()Interface for handling user interactions within rendered Markdown content.
interface ActionHandler {
fun handleUrlClick(url: String, node: Node) {}
fun handleCopyClick(node: Node) {}
fun handleImageClick(imageUrl: String, node: Node) {}
fun handleCustomEvent(event: CustomEvent, node: Node) {}
}
interface CustomEventExample:
val handler = object : ActionHandler {
override fun handleUrlClick(url: String, node: Node) {
// Open URL in browser
}
override fun handleCopyClick(node: Node) {
// Copy code block content
}
}
MarkdownView(
text = markdownContent,
actionHandler = handler,
)Provides context for inline node string builders, including text measurement, styles, and system capabilities.
data class NodeStringBuilderContext(
val parser: MarkdownParser,
val layoutContext: TextLayoutContext,
val designContext: TextStyleContext,
val systemContext: SystemContext,
val renderDependencies: Map<String, Any>,
)
data class TextLayoutContext(
val density: Density,
val textMeasurer: TextMeasurer,
val textAlign: TextAlign,
val sizeConstraints: TextSizeConstraints,
)
data class TextStyleContext(
val contentColor: Color,
val textSelectionColors: TextSelectionColors,
val textStyle: TextStyle,
val fontFamilyResolver: FontFamily.Resolver,
val layoutDirection: LayoutDirection,
)
data class SystemContext(
val clipboard: Clipboard,
val uriHandler: UriHandler,
val hapticFeedback: HapticFeedback,
val softwareKeyboardController: SoftwareKeyboardController?,
val focusManager: FocusManager,
val coroutineScope: CoroutineScope,
)Convenience functions for accessing current rendering context within custom renderers:
@Composable @ReadOnlyComposable fun currentTheme(): MarkdownTheme
@Composable @ReadOnlyComposable fun currentParser(): MarkdownParser
@Composable @ReadOnlyComposable fun currentRenderRegistry(): RenderRegistry
@Composable @ReadOnlyComposable fun currentActionHandler(): ActionHandlerState
@Composable @ReadOnlyComposable fun currentRenderDependencies(): Map<String, Any>
@Composable @ReadOnlyComposable fun isShowNotSupported(): BooleanAll top-level rendering components accept a renderDependencies map. Composable renderers read it
with currentRenderDependencies(), and non-Composable node string builders read the same map from
nodeStringBuilderContext.renderDependencies.
actionHandler is provided internally as a stable ActionHandlerState updated with
rememberUpdatedState. Node string builders and interaction listeners keep that State and read its
value only when an event occurs, so changing the external handler does not rebuild annotated text.
renderDependencies and the unsupported-content flag remain direct values because they affect the
rendered result and must invalidate rendering when changed.
Custom nodes used by text-mode block rendering can implement NodeContentHashProvider. Override
contentHash() with only fields that affect rendering; stateless nodes can use the class-name-based
default implementation.
- Use
MarkdownViewwith default configuration.
MarkdownView(text = markdownContent)- Use
LazyMarkdownColumnwhen the whole Markdown string can be parsed up front.
LazyMarkdownColumn(
text = longContent,
markdownRenderConfig = config,
modifier = Modifier.fillMaxSize(),
)- Use
LazyMarkdownViewwhen source I/O, parsing, and AST memory must also be incremental.
LazyMarkdownView(
source = markdownLineSource,
markdownRenderConfig = config,
modifier = Modifier.fillMaxSize(),
)- Enable multiple plugins for tables, images, and HTML support.
val config = MarkdownRenderConfig.Builder()
.addPlugin(TableMarkdownPlugin())
.addPlugin(ImageMarkdownPlugin())
.addPlugin(HtmlMarkdownPlugin())
.build()
MarkdownView(
text = richContent,
markdownRenderConfig = config,
)- Create a shared
MarkdownRenderConfig:- Set
markdownThemeto match your design system. - Add plugins for extended syntax.
- Register custom block renderers or inline builders as needed.
- Use
addExtension()for custom commonmark parser extensions.
- Set