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.
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.
<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;
}
</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. -->
<Editor
image
onImageUpload={uploadImage}
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> | - |
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> | - |