Customize ordered list numbering for DOCX export

Available in Start planBeta

@tiptap-pro/extension-export-docx accepts an optional registry of numbering format definitions — multilevel marker style, marker text, alignment, indent, and font — selected per-list via an attribute on the outermost <ol>. Lists with a matching id export with the corresponding definition; lists without one export as plain 1. 2. 3..

The feature is opt-in. Consumers who don't supply numberingFormats see no behavior change.

What's provided

ExportPackagePurpose
OrderedListNumbering@tiptap-pro/extension-convert-kitTiptap extension that adds the numberingFormat attribute to orderedList and exposes the setOrderedListNumberingFormat(id) and toggleOrderedListWithFormat(format?) commands, and tracks the active format at the selection via editor.storage.orderedListNumbering. Registered by ConvertKit (opt in via orderedListNumbering: true, or an options object with defaultFormat / formats). Enforces outermost-only numbering on paste, JSON, collaborative sync, and programmatic edits.
generateNumberingFormatCss(formats, options?)@tiptap-pro/extension-convert-kitPure, dependency-free function that returns CSS text for the editor preview — same registry, same visual result.
NumberingFormatDefinition, NumberingLevelDefinition, NumberingMarkerFont@tiptap-pro/extension-convert-kitThe data shape your registry follows. Structurally compatible with ExportDocx's numberingFormats config.
ExportDocx.configure({ numberingFormats })@tiptap-pro/extension-export-docxPass the registry to the exporter so the .docx carries the matching definitions.
LevelFormat, IRunOptions, PositiveUniversalMeasure@tiptap-pro/extension-export-docxRe-exported from docx so you don't add a second dependency.

A picker UI for end users isn't part of these packages — that's an application concern and depends on your component library.

Quick start

import { Editor } from '@tiptap/core'
import { ConvertKit, type NumberingFormatDefinition } from '@tiptap-pro/extension-convert-kit'
import { ExportDocx, LevelFormat } from '@tiptap-pro/extension-export-docx'

// 1. Define your registry — one source of truth, used on both sides below.
const MY_FORMATS: NumberingFormatDefinition[] = [
  {
    id: 'decimal-paren',
    levels: [
      { baseStyle: LevelFormat.DECIMAL, textTemplate: '%1)' },
      { baseStyle: LevelFormat.LOWER_LETTER, textTemplate: '%2)' },
      { baseStyle: LevelFormat.LOWER_ROMAN, textTemplate: '%3)' },
    ],
  },
  {
    id: 'outline',
    levels: [
      { baseStyle: LevelFormat.DECIMAL, textTemplate: '%1.' },
      { baseStyle: LevelFormat.DECIMAL, textTemplate: '%1.%2.' },
      { baseStyle: LevelFormat.DECIMAL, textTemplate: '%1.%2.%3.' },
    ],
  },
]

// 2. Opt in via ConvertKit. Passing `formats` generates the editor-preview CSS
//    and injects it for you; register ExportDocx with the same registry so the
//    .docx carries the matching definitions.
const editor = new Editor({
  extensions: [
    ConvertKit.configure({
      // Off by default so consumers without custom numbering keep a clean
      // orderedList schema. Pass an options object (or `true`) to opt in.
      orderedListNumbering: { formats: MY_FORMATS },
    }),
    ExportDocx.configure({
      numberingFormats: MY_FORMATS,
      onCompleteExport: (blob) => {
        /* download blob */
      },
    }),
  ],
})

// 3. Start a numbered list in the chosen format at the current selection
//    (or, when already in a list, change its format with setOrderedListNumberingFormat).
editor.chain().focus().toggleOrderedListWithFormat('outline').run()

Prefer to manage the stylesheet yourself? Omit formats and inject the CSS from generateNumberingFormatCss instead — the two paths produce the same markers.

Enabling ordered list numbering

OrderedListNumbering ships with @tiptap-pro/extension-convert-kit but is off by default, to keep the orderedList schema clean for consumers that don't use custom numbering. Opt in via ConvertKit:

ConvertKit.configure({ orderedListNumbering: true })

Pass an options object instead of true to configure two things:

ConvertKit.configure({
  orderedListNumbering: {
    // The format id new ordered lists start with. Defaults to `null` (plain `1. 2. 3.`).
    defaultFormat: 'outline',
    // The same definitions you pass to ExportDocx. When provided, the
    // editor-preview CSS is generated from them and injected automatically,
    // so on-screen markers match the export with no extra wiring.
    formats: MY_FORMATS,
  },
})
OptionDefaultDescription
defaultFormatnullThe numbering format id applied to a newly created ordered list (the numberingFormat attribute default). Only the outermost list is formatted; nested lists stay clear.
formatsnullNumbering format definitions used to generate and inject the editor-preview CSS — the same array passed to ExportDocx.configure({ numberingFormats }). Editors configured with the same formats share one preview stylesheet, while editors with different formats get separate ones, so each always shows the correct markers; a shared stylesheet is removed only once the last editor using it is destroyed.

Passing formats here is the equivalent of calling generateNumberingFormatCss and injecting the result yourself — choose whichever fits your setup.

Applying a format

The setOrderedListNumberingFormat(id) command sets the numberingFormat attribute on the outermost ordered list ancestor of the current selection. Pass null to clear it (the list then exports as plain 1. 2. 3.).

editor.chain().focus().setOrderedListNumberingFormat('decimal-paren').run()
editor.chain().focus().setOrderedListNumberingFormat(null).run()

The command returns false when the selection isn't inside an ordered list.

Starting a new list in a format

setOrderedListNumberingFormat only targets a list the selection is already inside. To start a numbered list in a chosen format from a non-list selection, use toggleOrderedListWithFormat(format?) — it creates the ordered list and applies the format in one call, which is the natural action for a "pick a format to start a numbered list" toolbar button:

// From a paragraph: create an ordered list and set its format.
editor.chain().focus().toggleOrderedListWithFormat('outline').run()

// Omit the format to start a list in the configured `defaultFormat`.
editor.chain().focus().toggleOrderedListWithFormat().run()

When the selection is already inside an ordered list, the command toggles the list off, mirroring toggleOrderedList. Chaining toggleOrderedList().setOrderedListNumberingFormat(format) reaches the same end state; the single command is simply more convenient and also handles the toggle-off case.

To reflect the active format in a toolbar, read it from the extension's storage — it stays in sync as the selection and document change:

const activeFormatId = editor.storage.orderedListNumbering.activeNumberingFormat
// a format id, or `null` when the selection is not inside an ordered list

NumberingFormatDefinition

interface NumberingFormatDefinition {
  id: string
  levels: NumberingLevelDefinition[]
}
FieldTypeDescription
idstringUnique within your numberingFormats[]. Serialized into the orderedList's numberingFormat attribute.
levelsNumberingLevelDefinition[]One entry per nesting depth. Must be non-empty. When a list nests deeper than the array, depth N reuses levels[N % levels.length].

NumberingLevelDefinition

interface NumberingLevelDefinition {
  baseStyle: NumberingBaseStyle
  textTemplate: string
  startAt?: number
  alignment?: 'left' | 'center' | 'right'
  numberIndent?: number | string
  textIndent?: number | string
  markerFont?: NumberingMarkerFont
}

ConvertKit's types use plain string literals so the package does not pull in docx. The fields are structurally compatible with ExportDocx's stricter (docx-typed) version, so passing LevelFormat.DECIMAL from @tiptap-pro/extension-export-docx works the same as passing the string 'decimal'.

FieldDefaultDescription
baseStyle(required)The counter glyph, equivalent to the matching docx LevelFormat value. The Latin/numeric styles ('decimal', 'decimalZero', 'upperLetter', 'lowerLetter', 'upperRoman', 'lowerRoman', 'none') and many locale styles (e.g. 'hebrew1', 'thaiNumbers', 'hindiNumbers', 'japaneseCounting', 'koreanDigital', 'chineseCounting', 'ideographDigital') map to a matching CSS counter style so the preview renders their native glyph. Styles with no faithful CSS equivalent (e.g. 'chicago', 'ordinal', 'cardinalText') — and any unrecognized string — fall back to decimal in the editor preview only; the exported .docx always carries the exact LevelFormat you set.
textTemplate(required)Marker text using Word's <w:lvlText> grammar — see below.
startAt1Initial counter value.
alignment'left'Marker text alignment within the number area.
numberIndentWord's per-level defaultDistance from the page margin to the marker. Twips number or a docx-style measure string ('0.63cm', '0.25in', '18pt').
textIndentWord's per-level defaultDistance from the page margin to the body text. Should be greater than numberIndent.
markerFontMarker-only run formatting (see NumberingMarkerFont below). Survives DOCX export and is reflected by generateNumberingFormatCss in the editor preview.

NumberingMarkerFont

interface NumberingMarkerFont {
  font?: string | { name: string }
  size?: number | string
  bold?: boolean
  italics?: boolean
  color?: string
  underline?: unknown
}
FieldDescription
fontFont family name, or a docx-style { name } object.
sizedocx half-points as a number (so 28 = 14pt), or a docx measure string such as '14pt'.
boldSet true to render the marker bold.
italicsSet true to render the marker italic.
colorHex color, with or without leading #.
underlineAny truthy value applies text-decoration: underline in the preview.

Mirrors the subset of docx IRunOptions that generateNumberingFormatCss understands. Additional fields you pass through to ExportDocx are ignored by the preview but still survive into the .docx.

The textTemplate grammar

  • %1 through %9 reference the counter at the 1-indexed nesting depth. So %1 is always "the counter at the outermost level", regardless of which level the textTemplate belongs to.
  • All other characters render literally.
  • Stray % followed by a non-digit (e.g. "50%") is preserved.

To make each level display its own counter, use ascending %N per level. To make a level include parent counters (legal-style outlines), chain them: '%1.%2.' at level 1 renders '1.1.'.

Template at level NRendered example
'%1.' at level 01.
'%2.' at level 11. (the level-1 counter)
'%1.%2.' at level 11.1.
'%1.%2.%3.' at level 21.1.1.
'Article %1' at level 0Article 1
'§ %1 —' at level 0§ 1 —
'(%3)' at level 2(1)

Schema convention — outermost only

The numberingFormat attribute belongs on the outermost <ol> only. One definition declares all nesting levels for the entire multilevel list. OrderedListNumbering enforces this on parse — pasted HTML with data-numbering-format on a nested <ol> has the attribute stripped.

All nesting levels under one outermost <ol> share a single counter scope, and sub-levels restart when the parent advances, exactly as Word does.

Cycling rule

levels.length defines the cycle period. When a list nests deeper than levels.length - 1, depth N reuses levels[N % levels.length] for both DOCX export and the editor preview CSS. Supply nine levels to cover Word's full nesting depth without cycling; supply fewer when the deeper levels can naturally repeat the shallower entries.

Defaults match Word's standard

When numberIndent / textIndent are omitted, the exporter and the CSS helper both emit Word's standard multilevel-list defaults:

DepthtextIndentnumberIndentHanging
0720 (0.50″)360 (0.25″)360
11140 (0.79″)780 (0.54″)360
21440 (1.00″)1080 (0.75″)360
31740 (1.21″)1380 (0.96″)360
42040 (1.42″)1680 (1.17″)360
52340 (1.63″)1980 (1.38″)360
62640 (1.83″)2280 (1.58″)360
72940 (2.04″)2580 (1.79″)360
83240 (2.25″)2880 (2.00″)360

generateNumberingFormatCss(formats, options?)

Returns the CSS text — you choose how to inject it (a <style> tag, a CSS-in-JS layer, a stylesheet served by your build pipeline, etc.). The function is pure and dependency-free, safe to call at boot or whenever your registry changes.

generateNumberingFormatCss(formats, {
  scope: '.tiptap.ProseMirror', // default
  maxDepth: 9, // default
})
OptionDefaultDescription
scope'.tiptap.ProseMirror'CSS selector prefix scoping every emitted rule. Pass an empty string for unscoped output (handy if you inject inside Shadow DOM or a CSS layer of your own).
maxDepth9Maximum nesting depth to emit rules for. Lower values produce smaller stylesheets.

The generated CSS positions each list marker and its body text to match the absolute positions Word renders, including the stair-step indents at every nesting depth. If you need to override anything for theming (dark mode, RTL, font scaling), wrap the output in your own selector or scope and rely on the cascade.

Resolution rules

  • A matched id renders every level of that multilevel list — including all nested <ol>s — using the corresponding definition.
  • An unmatched id (typo, missing attribute, empty numberingFormats) renders as plain 1. 2. 3. numbering.
  • Sibling multilevel lists referencing the same format restart independently — each starts at its own startAt.

Known limitations

Word features the exporter intentionally does not model:

FeatureWorkaround / scope
Forcing parent-counter refs to render as arabic regardless of base style (Word's "legal numbering" override)Define a separate format with all-DECIMAL levels for the same visual output.
Per-level counter-restart customizationWord's default behavior (sub-levels restart when the parent advances) is always used.
Marker-text suffix (tab / space / nothing between marker and body)Always tab (Word's default).
Continuing numbering across separate listsEach list starts at its startAt independently.
Per-item marker overridesUse a separate list to start at a different counter value.
DOCX → editor importHandled separately by @tiptap-pro/extension-import-docx.

See also