---
title: "Create a Highlight Mark with Markdown Support"
description: "Learn how to create a custom Highlight mark in Tiptap that supports Markdown syntax for enhanced text formatting."
canonical_url: "https://tiptap.dev/docs/editor/markdown/guides/create-a-highlight-mark"
---

# Create a Highlight Mark with Markdown Support

Learn how to create a custom Highlight mark in Tiptap that supports Markdown syntax for enhanced text formatting.

This guide walks through adding Markdown support for a small inline `highlight` mark that uses the `==text==` shorthand (common in some Markdown flavors) to produce a `mark` element in HTML.

We'll follow four clear steps and at each step include a full example so you always have the complete context:

1. Create the basic Tiptap `Mark` extension (without Markdown support).
2. Add a custom Markdown tokenizer to produce tokens from the raw Markdown.
3. Add a parser that converts those tokens into Tiptap JSON (applying the mark).
4. Add a renderer (serializer) that converts the Tiptap content back to Markdown.

Example shorthand we'll support:

```js
This is ==highlighted== text.
```

---

## Step 1: Create the basic `highlight` Mark

Start with a minimal `Mark` definition that describes the mark name, HTML parse/render behavior and any options. Keep Markdown integration out for now so you can focus on schema and HTML input/output first.

```js
import { Mark } from '@tiptap/core'

export const Highlight = Mark.create({
  name: 'highlight',

  addOptions() {
    return {
      HTMLAttributes: {},
    }
  },

  parseHTML() {
    return [{ tag: 'mark' }]
  },

  renderHTML({ HTMLAttributes }) {
    return ['mark', HTMLAttributes, 0]
  },

  addCommands() {
    return {
      toggleHighlight:
        () =>
        ({ commands }) => {
          return commands.toggleMark(this.name)
        },
    }
  },
})
```

**Notes:**

- This mark simply maps to the HTML `<mark>` tag.
- The command `toggleHighlight` allows toggling the mark programmatically.

---

## Step 2: Add a custom Markdown tokenizer

The tokenizer is responsible for recognizing `==text==` in the raw Markdown and returning a token containing the inner text and any nested inline tokens. Keep this step focused on the tokenizer so you can test recognition independently.

```js
import { Mark } from '@tiptap/core'

export const Highlight = Mark.create({
  name: 'highlight',

  addOptions() {
    return {
      HTMLAttributes: {},
    }
  },

  parseHTML() {
    return [{ tag: 'mark' }]
  },

  renderHTML({ HTMLAttributes }) {
    return ['mark', HTMLAttributes, 0]
  },

  // define a custom Markdown tokenizer to recognize ==text==
  markdownTokenizer: {
    name: 'highlight',
    level: 'inline', // inline element
    // fast hint for the lexer to find candidate positions
    start: (src) => src.indexOf('=='),
    tokenize: (src, tokens, lexer) => {
      // Match ==text== at the start of the remaining source
      const match = /^==([^=]+)==/.exec(src)
      if (!match) return undefined

      return {
        type: 'highlight',       // token type (must match name)
        raw: match[0],           // full matched string: ==text==
        text: match[1],          // inner content: text
        // Let the Markdown lexer process nested inline formatting
        tokens: lexer.inlineTokens(match[1]),
      }
    },
  },

  addCommands() {
    return {
      toggleHighlight:
        () =>
        ({ commands }) => {
          return commands.toggleMark(this.name)
        },
    }
  },
})
```

**Implementation notes:**

- `start` is an optimization used by the lexer to quickly locate potential matches.
- The tokenizer returns `tokens: lexer.inlineTokens(match[1])` so nested inline formatting (bold, emphasis, links, etc.) is preserved.

---

## Step 3: Add the parser

The `parseMarkdown` function converts the token produced by the tokenizer into a Tiptap representation. For marks, you'll typically parse the inner tokens into inline nodes and then apply the mark to that content.

```js
import { Mark } from '@tiptap/core'

export const Highlight = Mark.create({
  name: 'highlight',

  addOptions() {
    return {
      HTMLAttributes: {},
    }
  },

  parseHTML() {
    return [{ tag: 'mark' }]
  },

  renderHTML({ HTMLAttributes }) {
    return ['mark', HTMLAttributes, 0]
  },

  // define a custom Markdown tokenizer to recognize ==text==
  markdownTokenizer: {
    name: 'highlight',
    level: 'inline', // inline element
    // fast hint for the lexer to find candidate positions
    start: (src) => src.indexOf('=='),
    tokenize: (src, tokens, lexer) => {
      // Match ==text== at the start of the remaining source
      const match = /^==([^=]+)==/.exec(src)
      if (!match) return undefined

      return {
        type: 'highlight',       // token type (must match name)
        raw: match[0],           // full matched string: ==text==
        text: match[1],          // inner content: text
        // Let the Markdown lexer process nested inline formatting
        tokens: lexer.inlineTokens(match[1]),
      }
    },
  },

  // Parse Markdown token to Tiptap JSON
  parseMarkdown: (token, helpers) => {
    // Parse nested inline tokens into Tiptap inline content
    const content = helpers.parseInline(token.tokens || [])
    // Apply the 'highlight' mark to the parsed content
    return helpers.applyMark('highlight', content)
  },

  addCommands() {
    return {
      toggleHighlight:
        () =>
        ({ commands }) => {
          return commands.toggleMark(this.name)
        },
    }
  },
})
```

**Notes:**

- `helpers.parseInline` converts nested inline tokens into the tiptap-compatible `content` array.
- `helpers.applyMark('highlight', content)` applies the mark to the parsed inline content and returns the structure the Markdown parser expects.

---

## Step 4: Add the renderer

To support serializing editor state back to Markdown, implement the `renderMarkdown` function. It receives a Tiptap node (or mark node structure) and should return the Markdown string. Use `helpers.renderChildren` to serialize nested content.

```js
import { Mark } from '@tiptap/core'

export const Highlight = Mark.create({
  name: 'highlight',

  addOptions() {
    return {
      HTMLAttributes: {},
    }
  },

  parseHTML() {
    return [{ tag: 'mark' }]
  },

  renderHTML({ HTMLAttributes }) {
    return ['mark', HTMLAttributes, 0]
  },

  // define a custom Markdown tokenizer to recognize ==text==
  markdownTokenizer: {
    name: 'highlight',
    level: 'inline', // inline element
    // fast hint for the lexer to find candidate positions
    start: (src) => src.indexOf('=='),
    tokenize: (src, tokens, lexer) => {
      // Match ==text== at the start of the remaining source
      const match = /^==([^=]+)==/.exec(src)
      if (!match) return undefined

      return {
        type: 'highlight',       // token type (must match name)
        raw: match[0],           // full matched string: ==text==
        text: match[1],          // inner content: text
        // Let the Markdown lexer process nested inline formatting
        tokens: lexer.inlineTokens(match[1]),
      }
    },
  },

  // Parse Markdown token to Tiptap JSON
  parseMarkdown: (token, helpers) => {
    // Parse nested inline tokens into Tiptap inline content
    const content = helpers.parseInline(token.tokens || [])
    // Apply the 'highlight' mark to the parsed content
    return helpers.applyMark('highlight', content)
  },

  // Render Tiptap node back to Markdown
  renderMarkdown: (node, helpers) => {
    const content = helpers.renderChildren(node.content || [])
    // Wrap serialized children in == delimiters
    return `==${content}==`
  },

  addCommands() {
    return {
      toggleHighlight:
        () =>
        ({ commands }) => {
          return commands.toggleMark(this.name)
        },
    }
  },
})
```

**Notes:**

- `helpers.renderChildren` serializes the node/mark children back to Markdown respecting nested formatting.
- Ensure the `renderMarkdown` output matches the tokenizer's expectations (spacing/newlines). For inline marks like this the simple `==content==` form is appropriate.

---

## Usage

Add the extension to your editor and set content from Markdown:

```js
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Markdown from 'some-markdown-integration' // replace with your Markdown integration
import { Highlight } from './highlight'

const editor = new Editor({
  extensions: [StarterKit, Markdown, Highlight],
})

// Set Markdown content (the Markdown integration must support 'contentType' or similar option)
editor.commands.setContent('This is ==highlighted== text', { contentType: 'markdown' })

// Toggle highlight programmatically
editor.commands.toggleHighlight()
```

---

## Testing and edge cases

- Nested inline formatting: The tokenizer used `lexer.inlineTokens(match[1])` so nested inline tokens (bold, emphasis, links) should be parsed and rendered correctly.
- Greedy matches: The tokenizer uses a simple `^==([^=]+)==` regex — this will not match content containing `==` inside. If you need to support nested `==` or multiline highlights, extend the regex accordingly.
- Whitespace handling: Some Markdown flavors permit spaces inside delimiters (e.g., `== text ==`). If you want to support that, update the tokenizer and the `renderMarkdown` output to preserve the chosen formatting.
- Conflicts with other tokenizers: The `start` optimization helps the lexer pick candidate positions, but ensure your regex doesn't conflict with other inline tokenizers in your Markdown toolchain.
