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.