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). One handler covers every route that produces a file: the toolbar picker, a paste and a drop. 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. Either way the document is left as it was.

<script lang="ts">
  import { Editor } from 'sv5ui/editor';

  async function uploadImage(file: File): Promise<string> {
    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>

<!-- One handler covers all three routes that produce a file: the toolbar
     picker, a paste and a drop. Paste several images at once and they are
     uploaded one after another and inserted in order, rather than racing
     each other. A dropped image lands where it was dropped, not where the
     caret happened to be. -->
<Editor
  image
  onImageUpload={uploadImage}
  onImageUploadError={(error) => toast.add({ title: 'Upload failed', color: 'error' })}
  toolbar={['bold', 'italic', '|', 'link', 'image', '|', 'undo', 'redo']}
/>

<!-- Without onImageUpload nothing is intercepted: a pasted image falls
     through to the editor's own handling, and the toolbar button opens the
     URL dialog instead of a file picker. -->

Paste and Drop

Since v2.7.0 all of these routes go through onImageUpload.

ActionWhat happens
Paste one imageUploaded, then inserted at the caret
Paste several at onceUploaded one after another and inserted in that order, rather than racing
Drop an imageInserted at the drop position, which is not necessarily where the caret was
Paste plain textHanded back to the editor and handled as usual
Paste with no onImageUploadNothing is intercepted, because there is nowhere to send the file
Upload rejectsReported through onImageUploadError, and the document is left untouched
Handler returns an unsafe URLBlocked with a console warning, and the image is not inserted

Crop Before Upload

imageCrop opens an ImageCropper in a modal before onImageUpload runs, so what leaves the browser is the framed image rather than whatever came off the camera. Pass true for the defaults, or an object to shape both the dialog and the export.

Pick an image from the toolbar, or paste or drop one into the editor. This demo has no server: the handler turns the cropped file into a data URL, so what you see inserted is exactly what a real handler would have received.

<script lang="ts">
  import { Editor } from 'sv5ui/editor';
</script>

<!-- imageCrop opens an ImageCropper in a modal before onImageUpload runs,
     so what leaves the browser is the cropped file rather than the original.
     true uses the defaults. -->
<Editor image imageCrop onImageUpload={uploadImage} />

<!-- An object configures both the dialog and the export. maxWidth is the
     one to reach for first: it caps what the server receives without asking
     the writer to think about pixels. -->
<Editor
  image
  imageCrop={{
    aspect: 16 / 9,
    mode: 'fixed',
    title: 'Crop before upload',
    description: 'Drag to reposition, scroll to zoom.',
    confirmLabel: 'Insert image',
    output: { maxWidth: 1200, quality: 0.85 }
  }}
  onImageUpload={uploadImage}
/>

<!-- Applies to the toolbar picker, a paste and a drop alike. Inserting an
     image by URL never goes through the cropper, since reading pixels back
     out of a remote image needs CORS headers the host may not send.

     The dialog opens with the crop frame on the whole image, so pressing
     Insert without dragging keeps the original rather than quietly cropping
     it. That is ImageCropper's initialFrame="full", set by the dialog. -->

imageCrop Options

Pass true to accept every default.

FieldTypeDefault
aspectnumber | 'free''free'
shape'rect' | 'circle''rect'
mode'fixed' | 'box''box'
titlestring'Crop image'
descriptionstring'Drag to reposition, and drag a handle to resize.'
confirmLabelstring'Insert'
output.type'image/png' | 'image/jpeg' | 'image/webp'source format
output.qualitynumber-
output.maxWidthnumber-
output.maxHeightnumber-

Crop Behavior

SituationResult
Toolbar, paste or dropAll three open the cropper before the upload handler is called
Cancel in the dialogNothing is uploaded and the document does not change
Insert without dragging anythingThe whole image is kept. The dialog opens with the frame on the entire image (initialFrame="full"), so it never crops silently
Insert an image by URLNever goes through the cropper
imageCrop not setThe upload flow is exactly what it was before
Several images pasted at onceThe cropper opens for each in turn, one upload at a time
Esc or an outside clickIgnored: only Cancel closes the dialog, so a slip cannot lose the image

URL Dialogs

The three dialogs that ask for a URL, insert a link, insert an image by URL and embed a YouTube video, are proper forms: a labelled input, a required marker, an icon and help text. The URL field takes focus as the dialog opens, and no error appears until the field is actually left, so an empty dialog never opens complaining.

The link dialog also has an optional Display text field, prefilled with whatever is selected in the document. Try the link button below with a selection and without one.

<!-- The three URL dialogs are real forms: a labelled input, a required
     marker, an icon and help text. The link dialog has an optional Display
     text field, prefilled with whatever is selected in the document.

     Insert link          Link URL   Include the protocol, for example https://
     Edit link            Link URL   same, with an Update button
     Insert image (URL)   Image URL
     Embed YouTube        Video URL

     The URL input takes focus when the dialog opens, and no error is shown
     until the field is actually left, so an empty dialog never greets the
     writer with a complaint. -->

<Editor
  image
  youtube
  toolbar={['bold', 'italic', '|', 'link', 'image', 'youtube', '|', 'undo', 'redo']}
/>

What the Link Dialog Inserts

The selection and the Display text field together decide the result.

SituationResult
Text selected, Display text emptyThe link is applied to the selection as it stands
Text selected, Display text filledThe selection is replaced by the typed text, carrying the link
Nothing selected, Display text emptyThe URL is inserted as its own label. Previously the document was left unchanged
Nothing selected, Display text filledThe typed text is inserted, carrying the link
Caret inside an existing linkThe dialog switches to Edit link, and the change covers the whole link

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