Skip to content

Headings

The Rich Text Editor models headings as a first-class paragraph field, not as a visual style fingerprint. Every paragraph carries a HeadingStyle (levels Normal and H1..H6), so heading identity survives theme changes, font customization, and round-trips through HTML and Markdown.

Basic Usage

Applying a heading

// Make the current paragraph an H2
richTextState.setHeadingStyle(HeadingStyle.H2)

// Remove heading level (back to a normal paragraph)
richTextState.setHeadingStyle(HeadingStyle.Normal)

setHeadingStyle applies to every paragraph that intersects the current selection. Wrap the call in recordHistory automatically so undo/redo restores heading changes alongside other formatting.

Reading the current level

val current = richTextState.currentHeadingStyle

val isH1 = current == HeadingStyle.H1
val level = current.level  // 0 for Normal, 1..6 for H1..H6

Use this from toolbar code to highlight the active heading button:

HeadingButton(
    label = "H2",
    isActive = state.currentHeadingStyle == HeadingStyle.H2,
    onClick = {
        val next = if (state.currentHeadingStyle == HeadingStyle.H2)
            HeadingStyle.Normal
        else
            HeadingStyle.H2
        state.setHeadingStyle(next)
    },
)

Heading Levels

HeadingStyle is an enum with seven values:

Style Level HTML Markdown Default size Weight
Normal 0 (none) (none) inherited inherited
H1 1 <h1> # 2.0em Bold
H2 2 <h2> ## 1.5em Bold
H3 3 <h3> ### 1.17em Bold
H4 4 <h4> #### 1.12em Bold
H5 5 <h5> ##### 0.83em Bold
H6 6 <h6> ###### 0.75em Bold

The defaults are framework-agnostic (em-based) so the core library doesn't depend on Material 2 or 3. Your app's TextStyle on the editor composable is still respected as the base from which em sizes derive.

Customizing the heading typography

Set RichTextConfig.headingTextStyles to give a level your own typography, for example the heading styles of your theme or a brand font:

richTextState.config.headingTextStyles = mapOf(
    HeadingStyle.H1 to TextStyle(
        fontSize = 28.sp,
        fontWeight = FontWeight.SemiBold,
        fontFamily = BrandFontFamily,
        lineHeight = 36.sp,
    ),
    HeadingStyle.H2 to MaterialTheme.typography.headlineSmall,
)
  • Each TextStyle is applied on top of the default of its level, so what it leaves unset keeps coming from the table above. TextStyle(color = Color.Red) colors a level and keeps its size and weight.
  • The paragraph part of the style (line height, alignment, and so on) applies to the heading paragraph.
  • Levels without an entry keep the default. An entry for HeadingStyle.Normal is ignored.
  • Changing the map re-styles the headings already in the editor, and in a read-only RichText.

This is an appearance setting of the editor, like linkColor. The document stores the heading level only, so the output of toHtml() and toMarkdown() does not change: a restyled H1 is still written as a plain <h1> or #, and a loaded <h1> renders with your style. Set the map on every RichTextState that displays the content.

Formatting added to the heading itself still wins over the configured style and is still exported: a color or font size on part of a heading, or an alignment on the heading paragraph.

Note

A font size or weight equal to the level's default (bold, or 2em on an H1) counts as the heading's own typography, not as added formatting. It is not exported, and it is replaced by the configured style.

Note: This API is marked @ExperimentalRichTextApi and may change in a future release.

Converting between levels

// From an integer (e.g. a toolbar picker)
val style = HeadingStyle.fromLevel(3)  // H3

// From an HTML tag name
val fromHtml = HeadingStyle.fromHtmlTag("h2")  // H2
val unknown  = HeadingStyle.fromHtmlTag("div") // Normal

// Access the serialization constants directly
HeadingStyle.H3.markdownPrefix   // "### "
HeadingStyle.H3.htmlTag          // "h3"

Typing shortcut

With headingTypingShortcutsEnabled, typing # to ###### at the start of a paragraph sets heading level 1 to 6 and removes the marker, as in Notion and Google Docs. The shortcut is off by default:

richTextState.config.headingTypingShortcutsEnabled = true

The marker only counts at the very start of a paragraph, followed by a space. A marker typed in a list item stays as text, and so does one when headings are left out of config.features (see Editor features). One undo restores the typed marker.

Pressing Enter in a heading

Splitting a heading follows the same rules as Google Docs, Word and Notion:

  • Inside the heading text: both halves stay headings of the same level.
  • At the end of the heading: the new paragraph is a normal one, so what you type next is normal sized.
  • At the start of the heading: an empty normal paragraph is inserted above, and the heading keeps its level and its content.

Serialization

HTML

Paragraphs with a heading level serialize to their corresponding tag:

state.setMarkdown("# Title\n\nBody paragraph.")
val html = state.toHtml()
// <h1>Title</h1><p>Body paragraph.</p>

On import, recognized tags (h1..h6) become the matching HeadingStyle; other block tags stay as Normal paragraphs.

Markdown

ATX-style headings (#..###### followed by a space) import and export losslessly:

state.setHtml("<h2>Section</h2><p>Body.</p>")
val markdown = state.toMarkdown()
// "## Section\n\nBody."

Setext-style headings (Title\n=====) are not supported on export but may be parsed on import depending on the Markdown dialect.

Why a first-class field?

Earlier revisions of the library modeled headings as a bundle of SpanStyle/ParagraphStyle (e.g. "this paragraph looks bold at 2em, so it must be an H1"). That approach breaks the moment a user applies partial formatting - italicizing one word in an H1 shouldn't demote it.

Storing the level directly on RichParagraph.headingStyle means:

  • Toolbar highlighting stays accurate regardless of partial inline styling.
  • Round-trips are lossless - setHtml β†’ toHtml and setMarkdown β†’ toMarkdown preserve the heading, even if the user tweaks font size mid-line.
  • Theme changes update visuals without losing structure - swap the app typography and every H2 re-renders at the new size without re-tagging.