Getting the prose class, toolbar states, and the editor-versus-render gap right
TipTap builds on ProseMirror and deliberately ships without any visual styling of its own, only the editable structure and the commands for formatting. The entire look, from the running text inside the editor to the toolbar buttons to the final rendering of saved content, comes exclusively from custom CSS. The @tailwindcss/typography prose class provides a solid starting point for that, but it needs targeted adjustments so the editor view and the render view genuinely look consistent.
Table of Contents
- 1. TipTap and ProseMirror: why the library ships without its own styles
- 2. Adapting the prose class for the editable area
- 3. Difference between the editor view and the final render view
- 4. Styling toolbar buttons with active and inactive states
- 5. Placeholder text styling with the Placeholder extension
- 6. Custom prose overrides for editor-specific elements
- 7. Practical example: a complete editor component with toolbar and status bar
- 8. Dark mode: prose-invert and the editor cursor color
- 9. Common pitfalls: preflight, ProseMirror base classes, and the focus ring
- 10. Summary
- 11. FAQ
1. TipTap and ProseMirror: why the library ships without its own styles
TipTap is a toolkit built around the ProseMirror editor core and provides a declarative, extensible API for formatting commands, extensions, and schema definitions, without prescribing a single visual design. That deliberate choice makes it possible to use the same editor across completely different design systems, from a minimalist comment field to a full-fledged document editor, without TipTap itself ever needing adjustment for that.
At its core, a TipTap editor's editable area is a contenteditable element managed by ProseMirror, but it remains visually completely unstyled until custom CSS is applied. Without that customization, headings, lists, and quotes look exactly like unformatted running text inside the editor, even though the underlying document structure is already correct, which makes the need for a deliberate styling strategy obvious from the start.
2. Adapting the prose class for the editable area
The prose class from the @tailwindcss/typography plugin provides predefined styles for typographic elements like headings, paragraphs, lists, and quotes, originally intended for static, Markdown-rendered content. Applied to the TipTap editor, usually through the editorProps.attributes.class configuration, the editable area immediately gets consistent base typography, without having to style every single HTML element manually.
The editor context still needs additional adjustments compared to pure static prose content, say a visible cursor with sufficient contrast, a clearly recognizable focus marker for the entire editable area, and enough padding so text does not sit flush against the edge of the editor container. Those adjustments are usually added through extra utility classes alongside prose, rather than by modifying the typography plugin configuration itself.
const editor = useEditor({
extensions: [StarterKit, Placeholder.configure({
placeholder: "Start writing ...",
})],
editorProps: {
attributes: {
class:
"prose prose-slate max-w-none focus:outline-none " +
"min-h-[240px] px-4 py-3 rounded-lg border border-slate-200",
},
},
});
3. Difference between the editor view and the final render view
The editable area and the later, read-only rendering of the same content share the same underlying HTML structure, but they need different interaction styles. Inside the editor, cursor visibility, focus rings, and possibly placeholder text matter, while the render view should drop these interaction cues entirely, since no editing happens there and such cues would only confuse readers.
In practice, it works well to use the same prose base class in both contexts, but add editor-specific rules only in the editor context through an extra modifier class like is-editor. That way, the underlying typography, heading sizes, line spacing, list formatting, is guaranteed to stay identical between both views, while only interaction-related details like the focus ring and cursor color vary between editor and render view.
4. Styling toolbar buttons with active and inactive states
TipTap's editor.isActive('bold') method returns a boolean state that is true exactly when the current cursor position or text selection already carries that formatting. That state is the foundation for visual toolbar feedback: an active bold button should clearly differ from an inactive one, usually through a darker background or a colored border, so users can always tell which formatting is already active at the current cursor position.
It matters that the formatting command itself runs independently of the visual state, say via editor.chain().focus().toggleBold().run(), while the button's class assignment reacts purely declaratively to isActive. That separation ensures the visual state always stays in sync with the actual editor state, even when formatting changes through a keyboard shortcut instead of a toolbar click.
function ToolbarButton({ editor, format, label }) {
const isActive = editor.isActive(format);
return (
<button
type="button"
onClick={() => editor.chain().focus().toggleBold().run()}
className={`rounded px-2.5 py-1.5 text-sm font-medium transition-colors ${
isActive
? "bg-slate-800 text-white"
: "text-slate-600 hover:bg-slate-100"
}`}
aria-pressed={isActive}
>
{label}
</button>
);
}
5. Placeholder text styling with the Placeholder extension
TipTap's official Placeholder extension inserts placeholder text through a CSS pseudo-element, specifically ::before on the first empty paragraph, whenever the editor holds no content. Unlike a classic input element with a native placeholder attribute, this behavior has to be explicitly recreated through CSS for a contenteditable-based editor, because the browser provides no built-in placeholder mechanism for arbitrarily structured, editable content.
For a consistent appearance, the placeholder text should use the same font size and line spacing as the actual running text, but clearly identify itself as a placeholder through a lighter text color, similar to native form fields. A common styling mistake is too strong a contrast gap between placeholder and real text, which produces an irritating, visible color jump as soon as the user starts typing.
/* Placeholder styling for TipTap's Placeholder extension */
.tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: theme(colors.slate.400);
float: left;
height: 0;
pointer-events: none;
}
6. Custom prose overrides for editor-specific elements
The default prose styles cover classic text elements well, but TipTap editors often contain additional, editor-specific elements like embedded mentions, code blocks with syntax highlighting, or interactive task lists with checkboxes, for which the default prose class ships no sensible defaults. Those elements need their own, extra CSS rules, targeted through the prose-* modifier utilities, say prose-code:bg-slate-100 for inline code or prose-a:text-sky-600 for links.
With task lists using checkboxes, it particularly matters that the browser's native checkbox rendering does not collide with the prose list styles, which in practice usually means taking the list item itself out of the normal list-style flow and placing the checkbox instead through its own flex layout next to the text content. This kind of special-casing for individual element types is the main reason plain default prose configuration rarely stays fully sufficient for more complex TipTap editors.
7. Practical example: a complete editor component with toolbar and status bar
A complete editor component combines the customized prose class for the content area, a toolbar with clearly recognizable active states for formatting commands, and usually a slim status bar showing word count or save status. The toolbar should be visually clearly separated from the actual editing area, typically through a subtle divider line or a slightly different background tone, so users can immediately distinguish controls from content.
For responsive display, it is worth making the toolbar horizontally scrollable on narrow screens instead of letting buttons wrap, because wrapped toolbar rows eat into valuable vertical space needed for the actual editing area. A horizontally scrollable toolbar with a subtly styled scrollbar, the kind that also suits other UI areas, keeps the editor component compact and functional even on small screens.
8. Dark mode: prose-invert and the editor cursor color
The typography plugin ships prose-invert as a ready-made dark mode variant that automatically adjusts text colors, headings, and list markers for dark backgrounds once activated together with a dark: variant. That adjustment covers only the plain prose content, though, and not automatically the editor-specific extra styles, which is why cursor color, focus ring, and placeholder color need to be checked separately in dark mode and adjusted where necessary.
A particularly easy point to overlook is the text selection color, which the browser renders by default through ::selection and which usually offers sufficient contrast in light mode, but quickly becomes hard to read in dark mode. An explicit ::selection rule with an adjusted background color for the dark mode context ensures selected text stays legible even against a dark editor background.
9. Common pitfalls: preflight, ProseMirror base classes, and the focus ring
Tailwind's preflight reset removes browser default styles for headings, lists, and quotes by default, which combined with ProseMirror's own base classes can lead to duplicated or conflicting rules whenever both systems try to style the same elements. In practice it is best to rely consistently on the prose class as the single source of typographic base rules and not additionally override ProseMirror's own classes manually, to avoid specificity conflicts.
Another common mistake is a missing or too-subtle focus ring on the entire editable area, leaving users, particularly keyboard users, unsure whether the editor actually holds input focus. Instead of setting focus:outline-none without a replacement, an alternative, clearly visible focus marker should always be added, say a colored border via focus-within:ring-2 on the enclosing editor container.
| Area | Responsible class/API | Typical adjustment | Important note |
|---|---|---|---|
| Running text in editor | prose (typography plugin) | prose-slate, max-w-none, padding | Base for both editor and render view |
| Active toolbar button | editor.isActive('format') | bg-slate-800, text-white when active | Set aria-pressed for accessibility |
| Placeholder | Placeholder extension + ::before | color: theme(colors.slate.400) | Same font size as real text |
| Task lists/code blocks | prose-code:, prose-a: modifiers | Custom color and layout rules | Default prose does not cover these elements |
| Focus state | focus-within: on editor container | ring-2, ring-sky-500 | Never focus:outline-none without a replacement |
Mironsoft
Tailwind CSS architecture, design systems, and performance
Tailwind frontends that stay maintainable despite thousands of utility classes?
We review existing Tailwind projects for bloated class lists, inconsistent design tokens, and unused CSS remnants, then build a design system that scales cleanly instead of getting messier with every component.
Design System Review
Checking tokens, spacing scale, and component consistency for maintainability.
Performance Optimization
Systematically reducing CSS bundle size, purge configuration, and load times.
Component Architecture
Building reusable, well-structured components instead of sprawling class lists.
10. Summary
Rich Text Editor Styling with TipTap: The Essentials at a Glance
Core idea
TipTap ships no styles of its own, the prose class from @tailwindcss/typography provides the typographic base.
Editor vs. render
Both views share the same prose base, editor-specific rules get added through an extra modifier class.
Toolbar state
editor.isActive('format') returns the boolean state for visual feedback, independent of the actual formatting command.
Most common pitfalls
Preflight/ProseMirror conflicts, missing focus ring after focus:outline-none, too strong placeholder contrast.