The console was clean, React threw no errors, and the Tiptap editor we had just swapped in for a plain <textarea> was nowhere on the page. There was no visible editing area, no placeholder text, no border, no min-height, and nothing to tell a user there was anything to click. The component had mounted fine. If we clicked exactly where the editor should have been and started typing, the keystrokes landed and the document updated. It worked, and it was completely invisible.

That gap between “technically functional” and “a human can see it exists” is the whole story. Tiptap is headless. It ships zero base styling on purpose, and ProseMirror underneath it needs CSS you write yourself. If you come from a <textarea> or a drop-in WYSIWYG like CKEditor or Quill, where you get a styled box the moment you instantiate it, this will catch you. It caught us.

What lands in the DOM

When you render @tiptap/react, what you get is a bare contenteditable div. With no author styles it has no min-height, no padding and no empty-state hint. Browsers do give a contenteditable a default focus outline, but it is the generic one and it sits tight against zero-height content, so when the document is empty there is effectively nothing to outline. An empty editor collapses to a sliver.

This is the headless contract, and it is not a bug. Tiptap owns the editing behavior (the schema, the commands, the transactions) and hands you the markup so you own the look. You get total control over presentation, and in exchange you get a blank element until you style it. Drop-in editors make the opposite trade: they look fine immediately and fight you the moment you want them to look like your app.

We found out which trade we had signed up for after we had already deleted the <textarea> and were staring at empty space where the editor should be.

The minimal fix

Two pieces of CSS get you from invisible to usable. The first handles the outline. The second handles the empty-state placeholder, and it has a dependency you wire up separately.

Here is the CSS we landed on:

.ProseMirror { outline: none; }
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: oklch(0.5 0.015 240 / 50%);
pointer-events: none;
height: 0;
font-style: italic;
}

The first rule kills the default browser focus outline so you can supply your own treatment on the wrapper instead (a border, a ring, a background shift on focus, whatever fits your design). With the outline reset, you can style the container around the editor like any other input.

The second rule is the placeholder, and it is where the gotcha lives. The content: attr(data-placeholder) pseudo-element only works if the data-placeholder attribute exists on the empty paragraph, and Tiptap does not put it there on its own. You need the placeholder extension.

The placeholder needs the extension as well as the CSS

Install @tiptap/extension-placeholder and register it in the editor’s extensions list with the text you want:

import Placeholder from "@tiptap/extension-placeholder";
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({ placeholder: "Write something..." }),
],
});

The extension adds the is-editor-empty class to the first node and stamps the data-placeholder attribute that the CSS reads. Without it, the ::before rule renders content: attr(data-placeholder), the attribute is absent, and you get an empty string. The placeholder is a two-part contract: the extension produces the hook and the CSS renders it.

This is the part I would flag hardest if you are reading before you build, because it fails with no signal on both ends. Add the CSS without the extension and nothing shows. Add the extension without the CSS and nothing shows. Each half is useless alone, and neither throws an error to tell you the other is missing.

Typography, if you want it to look like prose

The reset and the placeholder get you a usable editor. They do not get you nice typography, because there is still no styling on the content you type: headings, lists and paragraph spacing, none of it. If you already use Tailwind’s typography plugin, the cleanest move is to push the prose classes onto the editor’s editable element through Tiptap’s editorProps:

const editor = useEditor({
extensions: [/* ... */],
editorProps: {
attributes: {
class: "prose prose-sm dark:prose-invert min-h-[120px] focus:outline-none",
},
},
});

That min-h-[120px] does useful work. It gives the editor a real click target even when empty, which is half of why it felt invisible before. An empty headless editor has no height, so tell it to have some. The prose classes then handle the typography so headings and lists look like content instead of unstyled HTML, and dark:prose-invert keeps it readable in dark mode.

What to budget for

Tiptap is headless, so your application owns every rendering choice. Budget time for the editing surface: a minimum height, a deliberate focus treatment, and, if you want a placeholder, both the CSS and the extension that support it. The exact styling is a product decision, but none of it happens automatically. Without it, an editor can work perfectly and still be hard for a person to find.