Forms

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.

Terminal
# Core — required every time you import `sv5ui/editor`
npm install \
  sv5ui \
  @tiptap/core \
  @tiptap/pm \
  @tiptap/starter-kit

2. Built-in UX extensions

Powers placeholder, showCount / maxLength, bubbleMenu, and the text-alignment toolbar buttons (always loaded).

Terminal
# 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-typography

3. Image upload

Required when you use the image prop (paste / drop / onImageUpload).

Terminal
# Image upload (`image` prop, paste/drop, onImageUpload callback)
npm install @tiptap/extension-image

4. Tables

Required when you use the tables prop. Tiptap splits the feature across 4 packages — install all four.

Terminal
# Tables (`tables` prop + the 8×8 dimension picker)
npm install \
  @tiptap/extension-table \
  @tiptap/extension-table-row \
  @tiptap/extension-table-cell \
  @tiptap/extension-table-header

5. YouTube embeds

Required when you use the youtube prop.

Terminal
# YouTube embeds (`youtube` prop)
npm install @tiptap/extension-youtube

6. Mentions & slash commands

Required for onMention and the slash palette. Both share @tiptap/suggestion.

Terminal
# @-mentions (`onMention`) and slash commands (`slash` prop).
# Both features share @tiptap/suggestion.
npm install @tiptap/extension-mention @tiptap/suggestion

7. Drag handle

Required when you use the dragHandle prop.

Terminal
# Block reordering via drag handle (`dragHandle` prop)
npm install @tiptap/extension-drag-handle

8. Markdown output

Required when you set output="markdown" or enable markdownAllowHtml.

Terminal
# Markdown output (`output="markdown"`, `markdownAllowHtml`)
npm install tiptap-markdown

Or — all in one

Same packages, single command. Copy-paste convenience.

Terminal
# 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-markdown

Basic 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.

0 / 280  chars 0 words
<!-- 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.

0 words
<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.

MemberType
editorTiptapEditor | null
stateEditorReactiveState
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 / codeInline formatting marks
h1 / h2 / h3Heading levels (config-driven via `headingLevels`)
paragraphNormalize block to paragraph
bulletList / orderedListList blocks
blockquote / codeBlock / horizontalRuleOther block types
link / unlinkManage hyperlinks
alignLeft / alignCenter / alignRight / alignJustifyText alignment
imageInsert image. NOT in the default toolbar — include `'image'` in your `toolbar` array AND set the `image` prop on Editor
tableInsert a table. NOT in the default toolbar — include `'table'` in your `toolbar` array AND set the `tables` prop on Editor
youtubeInsert a YouTube embed. NOT in the default toolbar — include `'youtube'` in your `toolbar` array AND set the `youtube` prop on Editor
undo / redoHistory controls
clearFormattingStrip marks from the current selection

UI Slots

Use the `ui` prop to override classes on internal elements.

SlotDescription
rootOutermost wrapper around toolbar + content + counter
toolbarToolbar bar
toolbarButtonPer-action button
toolbarSeparatorVertical divider between groups
contentEditable content surface (the `prose` area)
bubbleMenuFloating bubble menu container
footerBottom strip (counter or custom footer)
countCharacter/word count text

Snippets

SnippetDescription
toolbarSlotReplace the entire toolbar. Receives `{ state, api }`
bubbleMenuSlotReplace bubble menu content. Receives `{ state, api }`
headerCustom content between the toolbar and the content area
footerCustom content below the content area (overrides the count footer)

Props

PropTypeDefault
valuestring | EditorJSON''
output'html' | 'json' | 'markdown''html'
placeholderstring-
onValueChange(value) => void-
onFocus / onBlur() => void-
readonlybooleanfalse
disabledbooleanfalse
autofocusboolean | 'start' | 'end' | numberfalse
maxLengthnumber-
showCountbooleanfalse
toolbarboolean | (ToolbarAction | '|')[]true
stickyToolbarbooleanfalse
bubbleMenubooleanfalse
headingLevels(1|2|3|4|5|6)[][1, 2, 3]
autolinkbooleantrue
linkOpenInNewTabbooleantrue
imagebooleanfalse
onImageUpload(file: File) => Promise<string>-
tablesbooleanfalse
youtubebooleanfalse
dragHandlebooleanfalse
slashbooleanfalse
slashCommandsSlashCommand[]-
slashTriggerstring'/'
onMention(query) => Promise<MentionItem[]>-
mentionTriggerstring'@'
markdownAllowHtmlbooleanfalse
extensionsAnyExtension[]-
extensionsOverrideAnyExtension[]-
size'sm' | 'md' | 'lg''md'
colorColorType'primary'
apiEditorApi-
idstring-
namestring-
refHTMLElement | nullnull
classstring-
uiRecord<Slot, Class>-