Editor
A rich-text WYSIWYG editor built on Tiptap v3 + ProseMirror. Configurable toolbar, optional bubble menu, image upload, resizable tables, @-mentions, /-commands, YouTube embeds, drag handle for block reordering, HTML / JSON / Markdown output, character counter, sticky toolbar - and a full imperative api via bind:api.
Installation
Editor's Tiptap stack is grouped by feature so you can see which package backs which Editor option. All groups are required at install time - Editor loads every extension at module mount, so a missing peer fails at build/dev. The grouping is for understanding; a single combined command (further down) is fine.
1. Core
Required every time you import sv5ui/editor.
# Core - required every time you import `sv5ui/editor`
npm install \
sv5ui \
@tiptap/core \
@tiptap/pm \
@tiptap/starter-kit2. Built-in UX extensions
Powers placeholder, showCount / maxLength, bubbleMenu, and the text-alignment toolbar buttons (always loaded).
# Always-on Editor features: placeholder, char/word count, bubble menu,
# text alignment toolbar buttons, and smart-typography substitutions.
npm install \
@tiptap/extension-placeholder \
@tiptap/extension-character-count \
@tiptap/extension-bubble-menu \
@tiptap/extension-text-align \
@tiptap/extension-typography3. Image upload
Required when you use the image prop (paste / drop / onImageUpload).
# Image upload (`image` prop, paste/drop, onImageUpload callback)
npm install @tiptap/extension-image4. Tables
Required when you use the tables prop. Tiptap splits the feature across 4 packages - install all four.
# Tables (`tables` prop + the 8×8 dimension picker)
npm install \
@tiptap/extension-table \
@tiptap/extension-table-row \
@tiptap/extension-table-cell \
@tiptap/extension-table-header5. YouTube embeds
Required when you use the youtube prop.
# YouTube embeds (`youtube` prop)
npm install @tiptap/extension-youtube6. Mentions & slash commands
Required for onMention and the slash palette. Both share @tiptap/suggestion.
# @-mentions (`onMention`) and slash commands (`slash` prop).
# Both features share @tiptap/suggestion.
npm install @tiptap/extension-mention @tiptap/suggestion7. Drag handle
Required when you use the dragHandle prop.
# Block reordering via drag handle (`dragHandle` prop)
npm install @tiptap/extension-drag-handle8. Markdown output
Required when you set output="markdown" or enable markdownAllowHtml.
# Markdown output (`output="markdown"`, `markdownAllowHtml`)
npm install tiptap-markdownOr - all in one
Same packages, single command. Copy-paste convenience.
# All 18 packages in one shot (copy-paste convenience)
npm install \
sv5ui \
@tiptap/core \
@tiptap/pm \
@tiptap/starter-kit \
@tiptap/extension-placeholder \
@tiptap/extension-character-count \
@tiptap/extension-bubble-menu \
@tiptap/extension-text-align \
@tiptap/extension-typography \
@tiptap/extension-image \
@tiptap/extension-table \
@tiptap/extension-table-row \
@tiptap/extension-table-cell \
@tiptap/extension-table-header \
@tiptap/extension-youtube \
@tiptap/extension-mention \
@tiptap/suggestion \
@tiptap/extension-drag-handle \
tiptap-markdownBasic Usage
Import from sv5ui/editor and bind value. The default toolbar is shown automatically.
<script lang="ts">
// Note the sub-export: 'sv5ui/editor', not 'sv5ui'.
import { Editor } from 'sv5ui/editor';
let value = $state('<p>Start writing…</p>');
</script>
<Editor bind:value placeholder="Write something amazing…" />Output Formats
Switch the serialization format with output. The bound value type follows: string for HTML and Markdown, structured EditorJSON for JSON.
Set output once on mount. Changing it at runtime is now ignored to avoid content corruption - call getValue(format) for a one-off conversion, or remount (e.g. with {#key output}) to switch formats.
HTML
JSON
Markdown
<script lang="ts">
import { Editor } from 'sv5ui/editor';
import type { EditorJSON } from 'sv5ui/editor';
// HTML (default) - value is a string
let htmlValue = $state('<p>Hello <strong>world</strong></p>');
// JSON - value is a Tiptap document
let jsonValue = $state<string | EditorJSON>({ type: 'doc', content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Structured!' }] }
]});
// Markdown - string of Markdown (uses tiptap-markdown)
let mdValue = $state('# Heading\n\nA **Markdown** paragraph.');
</script>
<Editor bind:value={htmlValue} output="html" />
<Editor bind:value={jsonValue} output="json" />
<Editor bind:value={mdValue} output="markdown" />Toolbar
The toolbar prop accepts true (default), false (hide), or an explicit array of action ids and '|' separators.
Default
Custom subset
Sticky toolbar
<!-- Default toolbar - every common action -->
<Editor toolbar />
<!-- Custom subset - order matters, '|' inserts a vertical separator -->
<Editor toolbar={['bold', 'italic', 'underline', '|', 'h1', 'h2', '|', 'link']} />
<!-- Hide the toolbar (pair with bubbleMenu for floating controls) -->
<Editor toolbar={false} bubbleMenu />
<!-- Stick the toolbar to the top of the page on scroll -->
<Editor stickyToolbar />Bubble Menu
Hide the toolbar and surface a floating mini-menu on text selection.
<!-- Floating mini-toolbar that appears on text selection. -->
<Editor toolbar={false} bubbleMenu />Placeholder, Counter & Max Length
Combine placeholder, showCount, and maxLength for textarea-style inputs.
<!-- Combine placeholder with a character counter + hard cap. -->
<Editor
placeholder="Tell us about your weekend…"
showCount
maxLength={280}
/>Image Upload
Enable image and provide an async onImageUpload(file). Pasted, dropped, and picker-selected files all flow through the same handler. To show the picker button on the toolbar, include 'image' in the toolbar array.
When the upload rejects, pass onImageUploadError(error) to surface the failure to the user. If omitted, the error is only logged to the console.
<script lang="ts">
import { Editor } from 'sv5ui/editor';
async function uploadImage(file: File): Promise<string> {
// Replace with your real upload (S3, Cloudinary, …).
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/upload', { method: 'POST', body: fd });
const { url } = await res.json();
return url;
}
function onUploadError(error: unknown) {
// Surface the failure to the user (toast, inline message, …).
// When omitted, the error is just logged to the console.
console.error('Image upload failed', error);
}
</script>
<!-- IMPORTANT: enabling `image` activates the extension (paste/drop work,
slash commands work) but does NOT auto-add the button to the toolbar.
Include 'image' in the toolbar array to show the picker button.
The resolved URL is validated before insertion (relative / http(s) /
raster data:image/* allowed; javascript:, data:text/*, SVG data URIs
rejected). HTML output is NOT sanitized - sanitize it yourself. -->
<Editor
image
onImageUpload={uploadImage}
onImageUploadError={onUploadError}
toolbar={['bold', 'italic', '|', 'h2', 'h3', '|', 'bulletList', 'orderedList', '|', 'link', 'image', '|', 'undo', 'redo']}
/>Tables
Enable tables for the extension, then include 'table' in the toolbar array to show the button that opens an 8×8 dimension picker.
<!-- Enable the extension AND include 'table' in the toolbar array. -->
<Editor
tables
toolbar={['bold', 'italic', '|', 'h2', 'h3', '|', 'bulletList', 'orderedList', '|', 'link', 'table', '|', 'undo', 'redo']}
/>YouTube Embeds
Enable youtube for the extension, then include 'youtube' in the toolbar array to show the URL-prompt button.
<!-- Enable the extension AND include 'youtube' in the toolbar array. -->
<Editor
youtube
toolbar={['bold', 'italic', '|', 'h2', 'h3', '|', 'bulletList', 'orderedList', '|', 'link', 'youtube', '|', 'undo', 'redo']}
/>@-Mentions
Provide onMention(query). Typing @ opens a floating popup; results show avatar + label, the selected id is what gets stored.
<script lang="ts">
import { Editor } from 'sv5ui/editor';
import type { MentionItem } from 'sv5ui/editor';
const users: MentionItem[] = [
{ id: 'alice', label: 'Alice Martin', avatar: 'https://i.pravatar.cc/40?u=alice' },
{ id: 'bob', label: 'Bob Wilson', avatar: 'https://i.pravatar.cc/40?u=bob' },
{ id: 'carol', label: 'Carol Lee', avatar: 'https://i.pravatar.cc/40?u=carol' }
];
async function lookup(query: string): Promise<MentionItem[]> {
const q = query.toLowerCase();
return users.filter((u) =>
u.label.toLowerCase().includes(q) || u.id.toLowerCase().includes(q)
);
}
</script>
<Editor onMention={lookup} placeholder="Type @ to mention someone…" />Slash Commands
Type / on a new line to open a fuzzy-searchable command palette. Use buildDefaultSlashCommands(opts) to extend the built-ins.
<script lang="ts">
import { Editor, buildDefaultSlashCommands } from 'sv5ui/editor';
import type { SlashCommand } from 'sv5ui/editor';
// Extend the defaults with one custom command.
const commands: SlashCommand[] = [
...buildDefaultSlashCommands({ image: true, tables: true, youtube: true }),
{
id: 'todo',
label: 'TODO note',
description: 'Insert a yellow TODO callout',
icon: 'lucide:list-todo',
keywords: ['todo', 'task'],
run: ({ editor }) => {
editor.chain().focus()
.insertContent('<p><mark>TODO: </mark></p>')
.run();
}
}
];
</script>
<Editor slash slashCommands={commands} image tables youtube />Drag Handle
Set dragHandle to reveal a handle on the left of any block on hover. Drag to reorder paragraphs, headings, lists, blockquotes, etc.
<!-- Hover any block to reveal a drag handle on the left. Reorder
paragraphs, headings, lists, tables, etc. -->
<Editor dragHandle />Inside FormField
Wrap Editor in <FormField> - error state propagates and the FormField label targets the editor's contenteditable.
Body is required
<script lang="ts">
import { Editor } from 'sv5ui/editor';
import { FormField } from 'sv5ui';
let value = $state('');
const error = $derived(value.replace(/<[^>]*>/g, '').trim().length === 0
? 'Body is required'
: '');
</script>
<FormField label="Body" required help="Markdown / rich text" {error}>
<Editor bind:value placeholder="Write a description…" />
</FormField>Imperative API
Bind api to focus the editor, run any toolbar action by id, read reactive state, or insert content programmatically.
Chars: 0 · Words: 0 · Active: none
<script lang="ts">
import { Editor } from 'sv5ui/editor';
import type { EditorApi } from 'sv5ui/editor';
import { Button } from 'sv5ui';
let value = $state('<p>Edit me…</p>');
let api = $state<EditorApi>();
</script>
<Editor bind:value bind:api />
<div class="flex flex-wrap gap-2">
<Button label="Bold" onclick={() => api?.run('bold')} />
<Button label="H1" onclick={() => api?.run('h1')} />
<Button label="Clear" color="error" variant="outline" onclick={() => api?.clear()} />
<Button label="Focus end" onclick={() => api?.focus('end')} />
<Button label="Insert text" onclick={() => api?.insert('Inserted! ')} />
</div>
<!-- Reactive editor state -->
<p>
Chars: {api?.state.charCount ?? 0} ·
Words: {api?.state.wordCount ?? 0} ·
{api?.state.active.bold ? 'bold ' : ''}
{api?.state.active.italic ? 'italic ' : ''}
</p>Custom Toolbar
The toolbarSlot snippet replaces the entire toolbar - it receives reactive state and the same api as bind:api.
<script lang="ts">
import { Editor } from 'sv5ui/editor';
import { Button } from 'sv5ui';
</script>
<!-- Fully custom toolbar - the snippet receives reactive `state` and `api`. -->
<Editor>
{#snippet toolbarSlot({ state, api })}
<div class="bg-surface-container/30 flex items-center gap-1 border-b border-on-surface/15 p-1">
<Button
size="xs" variant={state.active.bold ? 'solid' : 'ghost'}
icon="lucide:bold" onclick={() => api.run('bold')}
aria-pressed={state.active.bold} aria-label="Bold"
/>
<Button
size="xs" variant={state.active.italic ? 'solid' : 'ghost'}
icon="lucide:italic" onclick={() => api.run('italic')}
aria-pressed={state.active.italic} aria-label="Italic"
/>
<span class="text-on-surface/40 ml-auto px-2 text-xs">
{state.wordCount} words
</span>
</div>
{/snippet}
</Editor>EditorApi
Exposed via `bind:api` for imperative control.
| Member | Type |
|---|---|
editor | TiptapEditor | null |
state | EditorReactiveState |
focus(position?) | (position?: 'start' | 'end' | number) => void |
run(action) | (action: ToolbarAction) => void |
getValue(format?) | (format?: EditorOutput) => string | EditorJSON |
setValue(value) | (value: string | EditorJSON) => void |
clear() | () => void |
insert(content) | (content: string | EditorJSON) => void |
Toolbar Actions
All built-in action ids accepted by the `toolbar` prop (or `api.run(action)`).
| Action(s) | Description |
|---|---|
bold / italic / underline / strike / code | Inline formatting marks |
h1 / h2 / h3 | Heading levels (config-driven via `headingLevels`) |
paragraph | Normalize block to paragraph |
bulletList / orderedList | List blocks |
blockquote / codeBlock / horizontalRule | Other block types |
link / unlink | Manage hyperlinks |
alignLeft / alignCenter / alignRight / alignJustify | Text alignment |
image | Insert image. NOT in the default toolbar - include `'image'` in your `toolbar` array AND set the `image` prop on Editor |
table | Insert a table. NOT in the default toolbar - include `'table'` in your `toolbar` array AND set the `tables` prop on Editor |
youtube | Insert a YouTube embed. NOT in the default toolbar - include `'youtube'` in your `toolbar` array AND set the `youtube` prop on Editor |
undo / redo | History controls |
clearFormatting | Strip marks from the current selection |
UI Slots
Use the `ui` prop to override classes on internal elements.
| Slot | Description |
|---|---|
root | Outermost wrapper around toolbar + content + counter |
toolbar | Toolbar bar |
toolbarButton | Per-action button |
toolbarSeparator | Vertical divider between groups |
content | Editable content surface (the `prose` area) |
bubbleMenu | Floating bubble menu container |
footer | Bottom strip (counter or custom footer) |
count | Character/word count text |
Snippets
| Snippet | Description |
|---|---|
toolbarSlot | Replace the entire toolbar. Receives `{ state, api }` |
bubbleMenuSlot | Replace bubble menu content. Receives `{ state, api }` |
header | Custom content between the toolbar and the content area |
footer | Custom content below the content area (overrides the count footer) |
Props
| Prop | Type | Default |
|---|---|---|
value | string | EditorJSON | '' |
output | 'html' | 'json' | 'markdown' | 'html' |
placeholder | string | - |
onValueChange | (value) => void | - |
onFocus / onBlur | () => void | - |
readonly | boolean | false |
disabled | boolean | false |
autofocus | boolean | 'start' | 'end' | number | false |
maxLength | number | - |
showCount | boolean | false |
toolbar | boolean | (ToolbarAction | '|')[] | true |
stickyToolbar | boolean | false |
bubbleMenu | boolean | false |
headingLevels | (1|2|3|4|5|6)[] | [1, 2, 3] |
autolink | boolean | true |
linkOpenInNewTab | boolean | true |
image | boolean | false |
onImageUpload | (file: File) => Promise<string> | - |
onImageUploadError | (error: unknown) => void | - |
tables | boolean | false |
youtube | boolean | false |
dragHandle | boolean | false |
slash | boolean | false |
slashCommands | SlashCommand[] | - |
slashTrigger | string | '/' |
onMention | (query) => Promise<MentionItem[]> | - |
mentionTrigger | string | '@' |
markdownAllowHtml | boolean | false |
extensions | AnyExtension[] | - |
extensionsOverride | AnyExtension[] | - |
size | 'sm' | 'md' | 'lg' | 'md' |
color | ColorType | 'primary' |
api | EditorApi | - |
id | string | - |
name | string | - |
ref | HTMLElement | null | null |
class | string | - |
ui | Record<Slot, Class> | - |