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.

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.

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.

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.

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>-
onImageUploadError(error: unknown) => void-
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>-