Tiptap placeholder extension that shows hint text in empty nodes.
Tiptap's built-in Placeholder rescans the whole document (doc.descendants) on every transaction. This one decorates only the empty node at the cursor plus its empty ancestor wrappers — O(depth), not O(document) — and drops in for the built-in, with a few deliberate differences.
bun add @docs.plus/extension-placeholderRequires @tiptap/core ^3.22.3 and @tiptap/pm ^3.22.3 (Tiptap 3.x).
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Placeholder } from '@docs.plus/extension-placeholder'
const editor = new Editor({
extensions: [
StarterKit,
Placeholder.configure({
placeholder: 'Write something …'
})
]
})The hint stays invisible until you add CSS — see Styling.
| Option | Type | Default | Description |
|---|---|---|---|
placeholder |
string | (props) => string |
'Write something …' |
Hint text, or a function returning it per node. The function receives { editor, node, pos, hasAnchor, parentName, doc }. |
emptyNodeClass |
string |
'is-empty' |
Class on the empty node at the cursor (and on empty ancestor wrappers). |
emptyEditorClass |
string |
'is-editor-empty' |
Class added when the whole document is empty. |
showOnlyWhenEditable |
boolean |
true |
Hide the placeholder while the editor is read-only. |
showOnlyWhenEditable is evaluated at render time, so editor.setEditable() updates the placeholder immediately.
The package ships no CSS — the resolved hint text lands in the node's data-placeholder attribute. Add a rule that renders it:
.ProseMirror [data-placeholder]::before {
content: attr(data-placeholder);
color: #9ca3af;
float: left;
height: 0;
pointer-events: none;
}Target [data-placeholder] rather than .is-empty: empty ancestor wrappers (list items, blockquotes) carry the empty class without the attribute.
The ::before hint is invisible to screen readers — label the editable element yourself, with aria-placeholder or aria-label.
- The
showOnlyCurrent,includeChildren, anddataAttributeoptions are ignored — the attribute is alwaysdata-placeholder, and the behavior is inherently current-node-only. - A
placeholderfunction returning''suppresses the empty classes entirely. - Empty ancestor wrappers also receive the empty class.
hasAnchoris alwaystrue.
| Built-in option | This package |
|---|---|
showOnlyCurrent |
Always current-node-only (no option) |
includeChildren |
Empty ancestors always get the empty class |
dataAttribute |
Always data-placeholder |
| Full-document scan | O(depth) at cursor only |
Drop the built-in extension and add this one with the same placeholder string or function.
Exports: Placeholder (default), PlaceholderOptions, PlaceholderRenderProps (function-form placeholder callback).
Sibling packages: extensions/README.md.
Bug reports and PRs welcome. Setup, test commands, and the playground harness live in CONTRIBUTING.md.
MIT — see LICENSE.