Skip to main content
The Placeholder extension adds placeholder text to empty nodes in your editor. It’s useful for providing hints about what content should go in different parts of the document. The placeholder can be static text or a dynamic function that returns different text based on the node context.

Installation

The Placeholder extension is included in the @tiptap/extensions package.
npm install @tiptap/extensions

Basic Usage

import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extensions/placeholder'

const editor = new Editor({
  extensions: [
    StarterKit,
    Placeholder.configure({
      placeholder: 'Write something …',
    }),
  ],
})

Styling the Placeholder

The extension adds CSS classes that you can style:
/* Style the placeholder text */
.is-empty::before {
  content: attr(data-placeholder);
  float: left;
  color: #adb5bd;
  pointer-events: none;
  height: 0;
}

/* Style when the entire editor is empty */
.is-editor-empty::before {
  content: attr(data-placeholder);
  float: left;
  color: #adb5bd;
  pointer-events: none;
  height: 0;
}

Configuration Options

placeholder
string | function
The placeholder content. Can be a static string or a function that returns different text based on the node context.Default: 'Write something …'As a string:
Placeholder.configure({
  placeholder: 'Start typing here…',
})
As a function:
Placeholder.configure({
  placeholder: ({ editor, node, pos, hasAnchor }) => {
    if (node.type.name === 'heading') {
      return 'What's the title?'
    }
    return 'Can you add some further context?'
  },
})
Function parameters:
  • editor: The editor instance
  • node: The ProseMirror node
  • pos: Position in the document
  • hasAnchor: Whether the cursor is in this node
emptyEditorClass
string
The CSS class added to empty nodes when the entire editor is empty.Default: 'is-editor-empty'
Placeholder.configure({
  emptyEditorClass: 'my-editor-empty-class',
})
emptyNodeClass
string
The CSS class added to individual empty nodes.Default: 'is-empty'
Placeholder.configure({
  emptyNodeClass: 'my-empty-node-class',
})
dataAttribute
string
The data attribute name used for the placeholder text. Will be prepended with data- and converted to kebab-case.Default: 'placeholder'
Placeholder.configure({
  dataAttribute: 'custom-placeholder',
})
// Results in: data-custom-placeholder="..."
showOnlyWhenEditable
boolean
Whether the placeholder should only be shown when the editor is editable.Default: true
Placeholder.configure({
  showOnlyWhenEditable: false, // Show even in read-only mode
})
showOnlyCurrent
boolean
Whether the placeholder should only be shown for the node containing the cursor.Default: true
Placeholder.configure({
  showOnlyCurrent: false, // Show for all empty nodes
})
includeChildren
boolean
Whether to show placeholders for all descendant nodes or just direct children.Default: false
Placeholder.configure({
  includeChildren: true, // Show for nested empty nodes
})

Advanced Examples

Different Placeholders for Different Nodes

import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extensions/placeholder'

const editor = new Editor({
  extensions: [
    StarterKit,
    Placeholder.configure({
      placeholder: ({ node }) => {
        if (node.type.name === 'heading') {
          return 'Enter a heading…'
        }
        if (node.type.name === 'paragraph') {
          return 'Write your content here…'
        }
        if (node.type.name === 'codeBlock') {
          return '// Write your code here'
        }
        return 'Start typing…'
      },
    }),
  ],
})

Context-Aware Placeholders

Placeholder.configure({
  placeholder: ({ node, pos, editor }) => {
    // Get the parent node
    const parent = editor.state.doc.resolve(pos).parent
    
    if (parent.type.name === 'listItem') {
      return 'List item…'
    }
    if (parent.type.name === 'blockquote') {
      return 'Quote…'
    }
    return 'Type something…'
  },
})

Show All Empty Nodes

Placeholder.configure({
  placeholder: 'Empty block',
  showOnlyCurrent: false, // Show placeholder in all empty nodes
  includeChildren: true,  // Include nested empty nodes
})

Custom Styling

/* Different styles based on node depth */
.is-empty::before {
  content: attr(data-placeholder);
  float: left;
  color: #ced4da;
  pointer-events: none;
  height: 0;
}

.is-editor-empty::before {
  content: attr(data-placeholder);
  float: left;
  color: #6c757d;
  pointer-events: none;
  height: 0;
  font-size: 1.2em;
}

/* Style headings differently */
h1.is-empty::before {
  font-size: 2em;
  font-weight: bold;
  color: #adb5bd;
}

h2.is-empty::before {
  font-size: 1.5em;
  font-weight: bold;
  color: #adb5bd;
}

/* Style code blocks differently */
pre.is-empty::before {
  font-family: monospace;
  color: #495057;
}

Read-Only Editor with Placeholders

const editor = new Editor({
  editable: false,
  extensions: [
    StarterKit,
    Placeholder.configure({
      placeholder: 'No content yet',
      showOnlyWhenEditable: false, // Show in read-only mode
    }),
  ],
})

Dynamic Placeholder Based on Editor State

Placeholder.configure({
  placeholder: ({ editor, node }) => {
    const wordCount = editor.storage.characterCount?.words() || 0
    
    if (wordCount === 0) {
      return 'Start writing your first draft…'
    }
    if (wordCount < 100) {
      return 'Keep going…'
    }
    return 'Add more details…'
  },
})

Position-Based Placeholders

Placeholder.configure({
  placeholder: ({ pos, editor }) => {
    const doc = editor.state.doc
    const nodesBefore = doc.childCount
    
    if (pos < 10) {
      return 'This is the introduction…'
    }
    if (pos > doc.content.size - 10) {
      return 'Add a conclusion…'
    }
    return 'Continue writing…'
  },
})

Source Code

View the source code on GitHub:

Build docs developers (and LLMs) love