Integrate Tiptap with Svelte

A Complete Step-by-Step Guide

I found it difficult to integrate the popular text editor Tiptap into my Svelte project due to the lack of information in the official Tiptap documentation. This tutorial will cover the missing pieces and provide a comprehensive guide on how to get Tiptap working with Svelte.

DAVID YANG

Published Aug 6, 2025 • 4 minutes read

Cover Image

Introduction

When building my Svelte project, I needed a powerful, extensible rich text editor that could support customization and modern UX features. After researching available editors, I chose Tiptap because of its flexibility, active development, and modern ProseMirror-based architecture.

However, integrating Tiptap into a Svelte environment was not as straightforward as I had hoped. While the official documentation is quite comprehensive for React and Vue, it lacks guidance for Svelte users. This tutorial aims to fill in the gaps and help others get Tiptap working smoothly in their Svelte projects.

Why the Existing Tiptap Documentation Falls Short

Here are a few pain points I encountered:

  1. No example of how to pass data in and out of the editor
    Managing bindings between Tiptap's internal state and Svelte's reactivity is not covered in the official guide.

  2. Errors when adding buttons to control formatting
    Trying to add custom buttons caused unexpected issues due to how Tiptap handles commands and editor state.

  3. Lack of guidance on styling the control panel
    There’s little help on how to style your control buttons and panel, or how to reflect active formatting state in the UI.

This tutorial addresses all of these challenges.


Installation and Initialization

Follow the official Tiptap guide to get started with installation:

Tiptap Guide for Svelte


Let the Data Flow

Here’s how to pass data in and retrieve updates from Tiptap in a reactive way.

Example: Two-way Binding

The following is the example code Tiptap.svelte of how we binding the data of the editor:

<script lang="ts">
  import "./Tiptap-styles.scss";
  import StarterKit from "@tiptap/starter-kit";
  import { Editor } from "@tiptap/core";
  import { onMount, onDestroy } from "svelte";
  import { TableKit } from '@tiptap/extension-table'

  /** in/out */
  export let html: string;
  /** out only */
  export let editor: Editor;

  let rootEl: HTMLDivElement;
  let lastHTML: string | null = null;

  onMount(onLoad);

  function onLoad() {
    createEditor();
  }

  function createEditor() {
    editor = new Editor({
      element: rootEl,
      extensions: [
        StarterKit, TableKit
      ],
      content: html,
      onTransaction: () => {
        // force re-render so `editor.isActive` works as expected
        editor = editor;
      },
      onUpdate: ({ editor }) => {
        html = lastHTML = editor.getHTML();
      },
      editable: true
    });
    lastHTML = html;
  }

  export function forceReload() {
    if (editor) {
      editor.destroy();
    }
    createEditor();
  }

  // html changed by caller, not editor
  $: html != lastHTML && forceReload();

  onDestroy(() => {
    if (editor) {
      editor.destroy();
    }
  });
</script>

<div bind:this={rootEl} class="html-editor"></div>

Adding Control Panel

Problem: Errors When Adding Buttons

When I first added control buttons (e.g., bold, italic), I ran into this error:

The editor view is not available. Cannot access view['hasFocus']. The editor may not be mounted yet.

	in <unknown>
	in Tiptap.svelte
	in +page.svelte
	in +layout.svelte
	in root.svelte

Then, I realize the issue is caused by this line of code:

disabled={!editor.can().chain().focus().toggleBold().run()}

The error occurs because we are calling editor.can() methods before the editor is fully initialized and mounted. The solution is to either remove the disabled attribute from the button or add additional check for editor readiness.

Example: Custom Control Panel

Add following code before the html-editor :

{#if editor}
  <div class="control-group">
    <!-- Text formatting -->
    <div class="button-group">
      <button
        type="button"
        on:click={() => editor.chain().focus().toggleBold().run()}
        class={editor.isActive("bold") ? "is-active" : ""}
      >
        Bold
      </button>
      <button
        type="button"
        on:click={() => editor.chain().focus().toggleItalic().run()}
        class={editor.isActive("italic") ? "is-active" : ""}
      >
        Italic
      </button>
      <button
        type="button"
        on:click={() => editor.chain().focus().toggleStrike().run()}
        class={editor.isActive("strike") ? "is-active" : ""}
      >
        Strike
      </button>
      <button
        on:click={() => editor.chain().focus().toggleCode().run()}
        class={editor.isActive("code") ? "is-active" : ""}
      >
        Code
      </button>
      <button type="button" on:click={() => editor.chain().focus().unsetAllMarks().run()}>Clear marks</button>
      <button type="button" on:click={() => editor.chain().focus().clearNodes().run()}>Clear nodes</button>

      <button
        type="button"
        on:click={() => editor.chain().focus().setParagraph().run()}
        class={editor.isActive("paragraph") ? "is-active" : ""}
      >
        P
      </button>
      <button
        type="button"
        on:click={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
        class={editor.isActive("heading", { level: 1 }) ? "is-active" : ""}
      >
        H1
      </button>
      <button
        type="button"
        on:click={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
        class={editor.isActive("heading", { level: 2 }) ? "is-active" : ""}
      >
        H2
      </button>
      <button
        on:click={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
        class={editor.isActive("heading", { level: 3 }) ? "is-active" : ""}
      >
        H3
      </button>
      <button
        type="button"
        on:click={() => editor.chain().focus().toggleHeading({ level: 4 }).run()}
        class={editor.isActive("heading", { level: 4 }) ? "is-active" : ""}
      >
        H4
      </button>
      <button
        type="button"
        on:click={() => editor.chain().focus().toggleHeading({ level: 5 }).run()}
        class={editor.isActive("heading", { level: 5 }) ? "is-active" : ""}
      >
        H5
      </button>
      <button
        type="button"
        on:click={() => editor.chain().focus().toggleHeading({ level: 6 }).run()}
        class={editor.isActive("heading", { level: 6 }) ? "is-active" : ""}
      >
        H6
      </button>

      <button
        type="button"
        on:click={() => editor.chain().focus().toggleBulletList().run()}
        class={editor.isActive("bulletList") ? "is-active" : ""}
      >
        • List
      </button>
      <button
        type="button"
        on:click={() => editor.chain().focus().toggleOrderedList().run()}
        class={editor.isActive("orderedList") ? "is-active" : ""}
      >
        1. List
      </button>
      <button
        type="button"
        on:click={() => editor.chain().focus().toggleCodeBlock().run()}
        class={editor.isActive("codeBlock") ? "is-active" : ""}
      >
        Code block
      </button>
      <button
        type="button"
        on:click={() => editor.chain().focus().toggleBlockquote().run()}
        class={editor.isActive("blockquote") ? "is-active" : ""}
      >
        Quote
      </button>
      <button type="button" on:click={() => editor.chain().focus().setHorizontalRule().run()}>
        HR
      </button>
      <button type="button" on:click={() => editor.chain().focus().setHardBreak().run()}>Break</button>

      <button
        type="button"
        on:click={() => editor.chain().focus().undo().run()}
      >
        Undo
      </button>
      <button
        type="button"
        on:click={() => editor.chain().focus().redo().run()}
      >
        Redo
      </button>
    </div>

    <div class="separator"></div>

    <!-- Table controls -->
    <div class="button-group">
      <button type="button" on:click={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}>
        Insert table
      </button>
      <button type="button" on:click={() => editor.chain().focus().addColumnBefore().run()}>+ Col before</button>
      <button type="button" on:click={() => editor.chain().focus().addColumnAfter().run()}>+ Col after</button>
      <button type="button" on:click={() => editor.chain().focus().deleteColumn().run()}>- Col</button>
      <button type="button" on:click={() => editor.chain().focus().addRowBefore().run()}>+ Row before</button>
      <button type="button" on:click={() => editor.chain().focus().addRowAfter().run()}>+ Row after</button>
      <button type="button" on:click={() => editor.chain().focus().deleteRow().run()}>- Row</button>
      <button type="button" on:click={() => editor.chain().focus().deleteTable().run()}>Delete table</button>
      <button type="button" on:click={() => editor.chain().focus().mergeCells().run()}>Merge</button>
      <button type="button" on:click={() => editor.chain().focus().splitCell().run()}>Split</button>
      <button type="button" on:click={() => editor.chain().focus().toggleHeaderColumn().run()}>Header col</button>
      <button type="button" on:click={() => editor.chain().focus().toggleHeaderRow().run()}>Header row</button>
      <button type="button" on:click={() => editor.chain().focus().toggleHeaderCell().run()}>Header cell</button>
      <button type="button" on:click={() => editor.chain().focus().mergeOrSplit().run()}>Merge/Split</button>          
    </div>
  </div>
{/if}

Customize Editor Content Style

You can style the editor content using a class or CSS selector. Tiptap does not impose any built-in styles, so you'll need to define your own.

Example CSS
<style>
  .html-editor {
    height: 100%;
    display: flex;
    flex-direction: column;
    padding: 0.5rem;
  }
  .html-editor :global(.ProseMirror) {
    height: 100%;
    flex: 1;
  }
  .html-editor :global(.ProseMirror:focus-visible) {
    outline: none;
  }

  .control-group {
    position: sticky;
    top: 0;
    z-index: 10;
    background: #f5f5f5;
    border-bottom: 1px solid #ddd;
    padding: 0.5rem;
    margin-bottom: 0;
    flex-shrink: 0;
  }

  .button-group {
    display: flex;
    flex-wrap: wrap;
    gap: 0.25rem;
    margin-bottom: 0.25rem;
  }

  .button-group button {
    padding: 0.25rem 0.5rem;
    font-size: 0.75rem;
    font-weight: 500;
    line-height: 1.2;
    color: #333;
    background-color: #fff;
    border: 1px solid #ccc;
    border-radius: 0.25rem;
    cursor: pointer;
    transition: background-color 0.15s ease;
    min-width: auto;
    white-space: nowrap;
  }

  .button-group button:hover {
    background-color: #f0f0f0;
  }

  .button-group button:focus {
    outline: 2px solid #007acc;
    outline-offset: 1px;
  }

  .button-group button.is-active {
    background-color: #333;
    color: white;
    border-color: #333;
  }

  .button-group button.is-active:hover {
    background-color: #555;
    border-color: #555;
  }

  .separator {
    width: 100%;
    height: 0.25rem;
  }
</style>

Add Additional Extensions

By default, the Tiptap text editor doesn’t include many features such as tables, and images. To add these important features, you need to install the corresponding extensions.

Conclusion

Tiptap is a fantastic editor once it’s up and running, but integrating it with Svelte requires some workarounds and clear understanding of how Tiptap manages state and commands.

This tutorial showed:

  • How to initialize Tiptap in Svelte

  • How to pass data in and out

  • How to build a functional control panel

  • How to style the editor and controls

Hopefully, this fills in the gaps left by the official documentation and saves you the time I spent figuring it out.