Customization

Extensions

Swap, extend, or replace the default TipTap extension set for any text editor.

Default Extensions

Each editor variant ships with a curated set of TipTap extensions. The text snippet uses the plain set, multiline adds paragraph support, and rich includes the full formatting suite.

The minimal set for single-line input. The Document schema is narrowed to text* so Enter is ignored and content stays on one line.

Document Top-level node. Schema set to text* to forbid paragraphs.
Text The inline text node. Required by every editor.
History Undo / redo support via Ctrl+Z.
Placeholder Shows ghost text when the editor is empty.

Custom TipTap Extensions

Every text snippet accepts a TextEditorOptions object as its second argument. Use it to swap, extend, or replace the default extension set.

Override placeholder text without touching extensions.

{@render text('title', {
  placeholder: 'Enter a title…'
})}

TextEditorOptions

extensions?: (defaults: Extensions) => Extensions | Promise<Extensions>

placeholder?: string

oncreate?: (editor: Editor) => void

ondestroy?: (editor: Editor | null) => void

editorProps?: EditorProps

Keeping the Bundle Small

editable-kit lazy-loads all TipTap code — nothing is fetched until a user toggles editing on. Custom extensions should follow the same pattern. If you import them statically at the top of your file, they get bundled into the page's initial JavaScript and load on every visit, even for users who never edit.

The extensions callback can be async, so you can use dynamic import() to keep custom extensions in the same lazy chunk as the editor. They'll only be fetched when the editor actually mounts.

Static import
<script lang="ts">
  // ✗ Static import — pulled into the page's initial bundle
  import CodeBlock from '@tiptap/extension-code-block';
  import TaskList from '@tiptap/extension-task-list';
  import TaskItem from '@tiptap/extension-task-item';
</script>

{@render rich('body', {
  extensions: (defaults) => [...defaults, CodeBlock, TaskList, TaskItem]
})}

Extensions are bundled into the page and loaded immediately, even for read-only visitors.

Dynamic import
{@render rich('body', {
  extensions: async (defaults) => {
    // ✓ Dynamic import — loaded only when the editor mounts
    const [{ default: CodeBlock }, { default: TaskList }, { default: TaskItem }] =
      await Promise.all([
        import('@tiptap/extension-code-block'),
        import('@tiptap/extension-task-list'),
        import('@tiptap/extension-task-item')
      ]);
    return [...defaults, CodeBlock, TaskList, TaskItem];
  }
})}

Extensions are code-split into a separate chunk and only fetched when the editor mounts.

Why this matters. TipTap extensions depend on ProseMirror, which is a sizeable dependency tree. A single static import can pull in tens of kilobytes that your read-only visitors will download but never use. Dynamic imports keep the editing code behind the same lazy boundary that editable-kit already maintains for its own defaults.

More Examples

Common extension recipes. All examples use dynamic imports to keep the editing code lazy-loaded.

Add fenced code blocks with the official CodeBlock extension.

{@render rich('body', {
  extensions: async (defaults) => {
    const { CodeBlock } = await import('@tiptap/extension-code-block');
    return [...defaults, CodeBlock];
  }
})}