---
title: "A Tiptap Editor That's Invisible Until You Add ProseMirror's Base CSS"
canonical: https://dxdev.com/blog/tiptap-invisible-without-prosemirror-base-css/
datePublished: 2026-02-24
---
# A Tiptap Editor That's Invisible Until You Add ProseMirror's Base CSS

I swapped a plain `<textarea>` for a Tiptap editor and got nothing back. No visible editing area. No placeholder text. No cursor affordance, no border, no min-height, nothing to tell a user there was anything to click. The component mounted fine. React threw no errors. The console was clean. If I clicked exactly where the editor should have been and started typing, the keystrokes landed and the document updated. The thing worked. It was just completely invisible.

That gap, between "technically functional" and "a human can see it exists," is the whole story here. 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 me.

## What you actually get out of the box

When you render `@tiptap/react`, what lands in the DOM is a bare `contenteditable` div. That's it. A `contenteditable` element with no author styles has no outline you'd want, no min-height, no padding, no empty-state hint. Browsers do give a `contenteditable` a focus outline by default, but it's the generic one and it sits tight against zero-height content, so when the document is empty there's effectively nothing to outline. An empty editor collapses to a sliver.

This is not a bug. It's the headless contract. Tiptap's pitch is that it owns the editing behavior (the schema, the commands, the transactions) and hands you the markup so you own the look. The trade is real: 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.

Nobody tells you which trade you signed up for until you've already deleted the `<textarea>` and you're staring at empty space where your 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 have to wire up separately.

Here's the CSS I landed on:

```css
.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). Resetting the outline is the small thing that lets you stop fighting the browser and start styling the container around the editor like any other input.

The second rule is the placeholder, and it's where the real gotcha lives. That `content: attr(data-placeholder)` pseudo-element only works if the `data-placeholder` attribute actually 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, not just CSS

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

```ts
import Placeholder from "@tiptap/extension-placeholder";

const editor = useEditor({
  extensions: [
    StarterKit,
    Placeholder.configure({ placeholder: "Write something..." }),
  ],
});
```

The extension is what adds the `is-editor-empty` class to the first node and stamps the `data-placeholder` attribute that the CSS reads. Without it, your `::before` rule renders `content: attr(data-placeholder)`, the attribute is absent, and you get an empty string. So the placeholder is a two-part contract: the extension produces the hook, the CSS renders it. Miss either half and the empty editor stays blank with no hint, which is exactly the invisibility problem you were trying to solve.

This is the part I'd flag hardest if you're reading this before you build, because it's a silent failure 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 don't get you nice typography, because there's still no styling on the actual content you type, headings, lists, paragraph spacing, none of it. If you're already using Tailwind's typography plugin, the cleanest move is to push the `prose` classes onto the editor's editable element through Tiptap's `editorProps`:

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

That `min-h-[120px]` is doing quiet 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. Tell it to have some. The `prose` classes then handle the typography so your headings and lists look like content instead of unstyled HTML, and `dark:prose-invert` keeps it readable in dark mode.

## The takeaway

Tiptap is headless, which means the application owns the rendering choices. Budget time for an editing surface: a minimum height, a deliberate focus treatment, and, if you want a placeholder, the CSS and extension that support it. The exact styling is a product decision, but it is not automatic. Without it, an editor can be technically functional while remaining hard for a person to discover.

If you take one thing from this: when you pick a headless editor, you're not choosing "less code," you're choosing "I write the presentation layer myself." That's often the right call. Just go in knowing that an empty, unstyled editor is the default state, not a failure state, and the first hour of work is making it visible.

## Related

- [Tiptap v3 Fires onFocus During Init, So Subscribe to the DOM Instead](tiptap-onfocus-fires-on-init-subscribe-to-the-dom): another Tiptap lifecycle surprise that needed a defensive integration choice
- [PayPal Pay-Later Messages Won't Re-Render, So Destroy the Node and Recreate It](paypal-messages-wont-rerender-destroy-the-node): another third-party component that needed an explicit lifecycle workaround
