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.
text* to forbid paragraphs.Extends the plain set with Paragraph. The Document schema becomes paragraph+ so Enter creates
new paragraphs, but no formatting marks are available.
paragraph+ — requires at least
one paragraph.The full editing suite. Includes everything from multiline plus marks, block types, lists, and media.
Structure
Marks
autolink and linkOnPaste; click-to-open disabled in edit
mode.Block types
Behavior
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…'
})}Receive the defaults array and append your own extensions.
{@render rich('body', {
extensions: async (defaults) => {
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];
}
})}Return a completely new array to replace every default extension.
{@render rich('body', {
extensions: async () => {
const { default: StarterKit } = await import('@tiptap/starter-kit');
const { default: Highlight } = await import('@tiptap/extension-highlight');
return [StarterKit, Highlight.configure({ multicolor: true })];
}
})}Extend a built-in extension with custom attributes or keyboard shortcuts.
import Heading from '@tiptap/extension-heading';
const CustomHeading = Heading.extend({
addAttributes() {
return {
...this.parent?.(),
id: {
default: null,
parseHTML: (el) => el.getAttribute('id'),
renderHTML: (attrs) => (attrs.id ? { id: attrs.id } : {})
}
};
}
});
// Then use it:
{@render rich('body', {
extensions: (defaults) =>
defaults.map((ext) => (ext.name === 'heading' ? CustomHeading : ext))
})}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.
<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.
{@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];
}
})}Add text highlighting with the Highlight extension. Supports multiple colors.
{@render rich('body', {
extensions: async (defaults) => {
const { Highlight } = await import('@tiptap/extension-highlight');
return [...defaults, Highlight.configure({ multicolor: true })];
}
})}Filter the defaults array to allow only specific formatting. Useful for restricted editing contexts.
<!-- Restrict to bold only — no other formatting -->
{@render rich('body', {
extensions: (defaults) => {
// Filter to keep only structure + bold
return defaults.filter(ext =>
['document', 'paragraph', 'text', 'history', 'bold']
.includes(ext.name)
);
}
})}