Replace CKEditor with TipTap in Drupal — a headless WYSIWYG with native dark mode
· Drupal · 10 min
CKEditor's iframe model makes dark mode and custom theming a maintenance burden. Here's how to register TipTap as a proper Drupal text format — with full dark mode support essentially for free.
CKEditor ships a capable editor, but it has one design choice that causes disproportionate pain in Drupal projects that care about UI polish: the content area renders inside an iframe.
That's fine until you want your editor UI to match a dark admin theme, or until you need CSS variables from the host document to reach the editor surface. The iframe boundary is a hard wall. Theming becomes a copy-paste exercise across two separate stylesheets, and dark mode requires either JavaScript toggling or a separate CKEditor-specific CSS build.
TipTap doesn't have this problem. It renders directly into the host document's DOM — which means a single prefers-color-scheme media query is all it takes to get dark mode that works.
This is a write-up of the R&D project I built to replace CKEditor inside Drupal. You can see the final result in the Drupal + TipTap case study.
How Drupal's editor system works
Drupal's editor integration is built around the text format system. A text format has two concerns:
1. A filter pipeline — HTML filters that sanitize and transform content on save and display 2. An optional editor plugin — anything implementing EditorPluginInterface, responsible for providing the JS/CSS assets and the editor config form
CKEditor 5 is the default editor plugin. Replacing it means implementing your own plugin class that tells Drupal which assets to load and how to hand off to the editor.
The Drupal module
The custom module is deliberately thin. Its job is to declare the editor plugin and attach the Vite-built ES module.
The plugin class lives in src/Plugin/Editor/TipTap.php:
namespace Drupal\mymodule\Plugin\Editor;
use Drupal\Core\Form\FormStateInterface;
use Drupal\editor\Attribute\Editor;
use Drupal\editor\Plugin\EditorBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;
#[Editor(
id: 'tiptap',
label: new TranslatableMarkup('TipTap'),
supports_content_filtering: TRUE,
supports_inline_images: FALSE,
is_xss_safe: FALSE,
)]
class TipTap extends EditorBase {
public function getDefaultSettings(): array {
return [
'toolbar' => ['bold', 'italic', 'heading', 'bulletList', 'orderedList', 'link', 'codeBlock'],
];
}
public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
return $form;
}
public function validateConfigurationForm(array &$form, FormStateInterface $form_state): void {}
public function submitConfigurationForm(array &$form, FormStateInterface $form_state): void {}
public function attachments(mixed $editor): array {
return ['library' => ['mymodule/tiptap_editor']];
}
}
The library declaration in mymodule.libraries.yml:
tiptap_editor:
version: VERSION
js:
dist/editor.js: { preprocess: false, minified: true }
css:
theme:
dist/editor.css: {}
dependencies:
- core/drupal
The TypeScript editor
The Vite entry point scans the page for textareas that Drupal has marked for this editor and mounts TipTap on each one:
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
document.querySelectorAll<HTMLTextAreaElement>('[data-editor="tiptap"]').forEach((textarea) => {
const wrapper = document.createElement('div');
wrapper.className = 'tiptap-editor';
textarea.insertAdjacentElement('afterend', wrapper);
textarea.style.display = 'none';
new Editor({
element: wrapper,
extensions: [
StarterKit,
Link.configure({ openOnClick: false }),
],
content: textarea.value,
onUpdate({ editor }) {
// Write clean HTML back to the textarea on every change so the
// normal Drupal form submission flow needs no modification.
textarea.value = editor.getHTML();
},
});
});
Each toolbar button is a TipTap extension with its own command, keyboard shortcut, and render logic. The onUpdate callback writes HTML back to the hidden textarea, so Drupal's form submission and filter pipeline work exactly as they would with CKEditor.
Dark mode with CSS variables
Because TipTap renders into the host document — not an iframe — dark mode requires nothing beyond CSS custom properties:
.tiptap-editor .ProseMirror {
--editor-bg: hsl(0 0% 100%);
--editor-text: hsl(0 0% 10%);
--editor-border: hsl(0 0% 85%);
--editor-placeholder: hsl(0 0% 60%);
background: var(--editor-bg);
color: var(--editor-text);
border: 1px solid var(--editor-border);
border-radius: 4px;
min-height: 200px;
padding: 1rem;
outline: none;
line-height: 1.6;
}
@media (prefers-color-scheme: dark) {
.tiptap-editor .ProseMirror {
--editor-bg: hsl(220 13% 10%);
--editor-text: hsl(220 13% 92%);
--editor-border: hsl(220 13% 22%);
--editor-placeholder: hsl(220 13% 50%);
}
}
No JavaScript. No class toggling. The editor matches the system preference the instant the OS switches.
The same principle extends to the toolbar, focus rings, and code block surface — everything is a CSS variable that the media query overrides. Compare this to CKEditor, where you'd need to reach inside the iframe with contenteditable stylesheets and duplicate every token.
Serialisation and security
TipTap's getHTML() produces clean semantic HTML — no <span style="..."> artefacts from paste events, no editor-specific attributes. Pair it with Drupal's standard filter_html filter on the text format and you get the same XSS protection you'd have with CKEditor.
The ProseMirror schema constrains what nodes and marks are possible, so the output is structurally predictable even before the filter runs. Heading levels, list nesting, link targets — all capped by the schema you define.
Build setup
The editor is an entirely separate Vite project inside the module directory:
mymodule/
src/Plugin/Editor/TipTap.php
js/
src/
editor.ts
toolbar.ts
package.json
vite.config.ts
dist/
editor.js ← Drupal library references this
editor.css
mymodule.libraries.yml
mymodule.module
The vite.config.ts builds to dist/ as a plain IIFE (no ES module imports, so Drupal's asset aggregation can still wrap it):
import { defineConfig } from 'vite';
export default defineConfig({
build: {
lib: {
entry: 'src/editor.ts',
name: 'TipTapEditor',
formats: ['iife'],
fileName: () => 'editor.js',
},
outDir: '../dist',
emptyOutDir: true,
},
});
What you gain
The CKEditor 5 bundle for Drupal weighs around 500 KB. The TipTap build with StarterKit and Link comes in under 80 KB — a 6× reduction. First-keystroke latency drops noticeably on slower admin machines.
More importantly, the theming story is completely solved. One CSS file, one @media block, done. No Drupal-specific CKEditor CSS overrides, no iframe postMessage workarounds, no maintenance tax every time CKEditor releases a new major version.
The full working result — including the dark mode demo — is in the Drupal + TipTap case study.