Svelte
Svelte 5 components and runes for Vizel editor.
Installation
npm install @vizel/svelte
# or
pnpm add @vizel/svelte
# or
yarn add @vizel/svelteRequirements
- Svelte 5 (peer dependency)
@vizel/coreand the required@tiptap/*packages are installed automatically as peer dependencies by npm 7+, pnpm, and yarn. You do not need to list them in your ownpackage.jsonunless you want to pin a specific version.- Any ESM-compatible bundler (verified against Vite 8, SvelteKit 2). Components are shipped as precompiled
.jsmodules, so nooptimizeDepsorssr.noExternalworkaround is required.
Quick Start
Use the Vizel component:
<script lang="ts">
import { Vizel } from '@vizel/svelte';
import '@vizel/core/styles.css';
</script>
<Vizel
placeholder="Type '/' for commands..."
onUpdate={({ editor }) => console.log(editor.getJSON())}
/>Advanced Setup
To customize, use individual components with runes:
<script lang="ts">
import { VizelEditor, VizelBubbleMenu, createVizelEditor } from '@vizel/svelte';
import '@vizel/core/styles.css';
const editor = createVizelEditor({
placeholder: "Type '/' for commands...",
});
</script>
<div class="editor-container">
<VizelEditor editor={editor.current} />
{#if editor.current}
<VizelBubbleMenu editor={editor.current} />
{/if}
</div>Components
Vizel
All-in-one editor component with built-in bubble menu.
<script lang="ts">
import { Vizel } from '@vizel/svelte';
</script>
<Vizel
initialContent={{ type: 'doc', content: [] }}
placeholder="Start writing..."
editable={true}
autofocus="end"
showBubbleMenu={true}
enableEmbed={true}
class="my-editor"
features={{
content: {
image: { onUpload: async (file) => 'url' },
},
}}
onUpdate={({ editor }) => {}}
onCreate={({ editor }) => {}}
onFocus={({ editor }) => {}}
onBlur={({ editor }) => {}}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
initialContent | JSONContent | - | Initial content (JSON) |
initialMarkdown | string | - | Initial content (Markdown) |
bind:markdown | string | - | Two-way Markdown binding |
placeholder | string | - | Placeholder text |
editable | boolean | true | Editable state |
autofocus | boolean | 'start' | 'end' | 'all' | number | - | Auto focus |
features | VizelFeatureOptions | - | Feature options |
class | string | - | CSS class |
showToolbar | boolean | false | Show fixed toolbar above editor |
showBubbleMenu | boolean | true | Show bubble menu |
enableEmbed | boolean | - | Enable embed in links |
extensions | Extensions | - | Additional Tiptap extensions |
transformDiagramsOnImport | boolean | true | Transform diagram code blocks on import |
flavor | VizelMarkdownFlavor | "gfm" | Markdown output flavor ("commonmark", "gfm", "obsidian", "docusaurus"). |
locale | VizelLocale | - | Localized UI strings. Use createVizelLocale() to merge a partial override with the English default. |
toolbar | Snippet<[{ editor }]> | - | Custom toolbar snippet rendered inside <VizelToolbar> when showToolbar is true. |
bubbleMenu | Snippet<[{ editor }]> | - | Custom bubble-menu snippet rendered inside <VizelBubbleMenu> when showBubbleMenu is true. |
onUpdate | (props: { editor }) => void | - | Update callback |
onCreate | (props: { editor }) => void | - | Create callback |
onDestroy | () => void | - | Destroy callback |
onSelectionUpdate | (props: { editor }) => void | - | Selection change callback |
onFocus | (props: { editor }) => void | - | Focus callback |
onBlur | (props: { editor }) => void | - | Blur callback |
onError | (error: VizelError) => void | - | Fired when an editor operation surfaces a VizelError. Narrow with isVizelError(error) to inspect the structured code field. |
Runes
createVizelEditor
This rune creates and manages a Vizel editor instance using Svelte 5 reactivity.
<script lang="ts">
import { createVizelEditor } from '@vizel/svelte';
const editor = createVizelEditor({
initialContent: { type: 'doc', content: [] },
placeholder: 'Start writing...',
features: {
content: {
mathematics: true,
},
},
onUpdate: ({ editor }) => {
console.log(editor.getJSON());
},
});
</script>Options
See Editor for full options.
Return Value
Returns { current: Editor | null }. Access the editor via editor.current.
createVizelState
This rune forces a component re-render on editor state changes.
<script lang="ts">
import { createVizelState } from '@vizel/svelte';
let { editor } = $props();
// Re-renders when editor state changes
const state = createVizelState(() => editor);
</script>
{#if editor}
<div>
<span>{editor.storage.characterCount?.characters() ?? 0} characters</span>
<span>{editor.storage.characterCount?.words() ?? 0} words</span>
</div>
{/if}createVizelEditorState
This rune returns computed editor state that updates reactively. It provides commonly needed properties like character count, word count, and undo/redo availability.
<script lang="ts">
import { createVizelEditor, createVizelEditorState, VizelEditor } from '@vizel/svelte';
const editor = createVizelEditor();
const editorState = createVizelEditorState(() => editor.current);
</script>
<VizelEditor editor={editor.current} />
<div class="status-bar">
<span>{editorState.current.characterCount} characters</span>
<span>{editorState.current.wordCount} words</span>
</div>Return Value
Returns { readonly current: VizelEditorState }:
| Property | Type | Description |
|---|---|---|
isFocused | boolean | Whether the editor is focused |
isEmpty | boolean | Whether the editor is empty |
canUndo | boolean | Whether undo is available |
canRedo | boolean | Whether redo is available |
characterCount | number | Character count |
wordCount | number | Word count |
createVizelAutoSave
This rune automatically saves editor content.
<script lang="ts">
import { createVizelEditor, createVizelAutoSave, VizelEditor, VizelSaveIndicator } from '@vizel/svelte';
const editor = createVizelEditor();
const autoSave = createVizelAutoSave(() => editor.current, {
debounceMs: 2000,
storage: 'localStorage',
key: 'my-editor-content',
onSave: (content) => console.log('Saved'),
onError: (error) => console.error('Save failed', error),
});
</script>
<VizelEditor editor={editor.current} />
<VizelSaveIndicator status={autoSave.status} lastSaved={autoSave.lastSaved} />createVizelMarkdown
This rune provides two-way Markdown synchronization with debouncing.
<script lang="ts">
import { createVizelEditor, createVizelMarkdown, VizelEditor } from '@vizel/svelte';
const editor = createVizelEditor();
const md = createVizelMarkdown(() => editor.current, {
debounceMs: 300, // default: 300ms
});
</script>
<VizelEditor editor={editor.current} />
<textarea value={md.current} oninput={(e) => md.setMarkdown(e.target.value)} />
{#if md.isPending}
<span>Syncing...</span>
{/if}Return Value
| Property | Type | Description |
|---|---|---|
current | string | Current Markdown content (reactive getter) |
setMarkdown | (md: string) => void | Update editor from Markdown |
isPending | boolean | Whether sync is pending (reactive getter) |
flush | () => void | Force-flush pending updates immediately |
getVizelTheme
This rune accesses theme state within VizelThemeProvider context.
<script lang="ts">
import { getVizelTheme, VizelThemeProvider } from '@vizel/svelte';
const theme = getVizelTheme();
function toggleTheme() {
theme.setTheme(theme.current === 'dark' ? 'light' : 'dark');
}
</script>
<VizelThemeProvider defaultTheme="system">
<Editor />
<button onclick={toggleTheme}>
{theme.current === 'dark' ? 'Light Mode' : 'Dark Mode'}
</button>
</VizelThemeProvider>Components
VizelEditor
This component renders the editor content area.
<VizelEditor
editor={editor.current}
class="my-editor"
/>Props
| Prop | Type | Description |
|---|---|---|
editor | Editor | null | Editor instance |
class | string | Custom class name |
VizelBubbleMenu
This component displays a floating bubble menu on text selection.
<VizelBubbleMenu
editor={editor.current}
class="my-bubble-menu"
showDefaultMenu={true}
updateDelay={100}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
editor | Editor | null | - | Editor instance |
class | string | - | Custom class name |
showDefaultMenu | boolean | true | Show default bubble menu |
pluginKey | string | "vizelBubbleMenu" | Plugin key |
updateDelay | number | 100 | Position update delay |
shouldShow | Function | - | Custom visibility logic |
enableEmbed | boolean | - | Enable embed in link editor |
VizelThemeProvider
This component provides theme context.
<VizelThemeProvider
defaultTheme="system"
storageKey="my-theme"
disableTransitionOnChange={false}
>
{@render children()}
</VizelThemeProvider>Props
| Prop | Type | Default | Description |
|---|---|---|---|
defaultTheme | "light" | "dark" | "system" | "system" | Default theme |
storageKey | string | "vizel-theme" | Storage key |
targetSelector | string | - | Theme attribute target |
disableTransitionOnChange | boolean | false | Disable transitions |
VizelSaveIndicator
This component displays the save status.
<VizelSaveIndicator
status={autoSave.status}
lastSaved={autoSave.lastSaved}
class="my-indicator"
/>VizelPortal
Render children outside the editor's DOM hierarchy, into the shared Vizel portal container at the document body level. Used internally for menus, dropdowns, and bubble menus so floating UI stacks correctly above the editor content.
<VizelPortal layer="dropdown">
<div class="my-overlay">Content</div>
</VizelPortal>Props:
| Prop | Type | Default | Description |
|---|---|---|---|
layer | VizelPortalLayer | "dropdown" | Z-index layer ("dropdown", "popover", "modal", "toast"). |
class | string | — | Class applied to the portal wrapper element. |
disabled | boolean | false | When true, renders children in place (no portal). Useful for SSR / debugging. |
VizelIcon
This component renders an icon from the icon context. It uses Iconify icon IDs by default, and can be customized via VizelIconProvider.
<script lang="ts">
import { VizelIcon } from '@vizel/svelte';
</script>
<VizelIcon name="bold" class="my-icon" />Patterns
Working with Markdown
<script lang="ts">
import { Vizel } from '@vizel/svelte';
let markdown = $state('# Hello World\n\nStart editing...');
</script>
<!-- Simple: bind:markdown for two-way binding -->
<Vizel bind:markdown={markdown} />
<!-- Or one-way with initialMarkdown -->
<Vizel initialMarkdown="# Read Only Initial" />Controlled vs uncontrolled
| Mode | Binding | Who owns the state | When to use |
|---|---|---|---|
| Uncontrolled | initialMarkdown only | The editor | Standalone editor; observe edits via onUpdate. |
| Controlled | bind:markdown={...} (Svelte 5 $bindable) | The parent component | Sync with form state, persist to a remote store, drive the editor from another input. |
Passing only markdown={...} (without bind:) is treated as "set-once": the editor renders the initial value but later prop changes are ignored. Use bind:markdown when you intend two-way sync.
Split View (WYSIWYG + Raw Markdown)
<script lang="ts">
import { Vizel } from '@vizel/svelte';
let markdown = $state('# Hello\n\nEdit in either pane!');
</script>
<div class="split-view">
<Vizel bind:markdown={markdown} />
<textarea bind:value={markdown} />
</div>Mirror Editor JSON into $state via onUpdate
initialContent is captured once when the editor mounts — writing to the same $state later does not push the new value into the editor. Use onUpdate to read the editor's JSON back into a $state for display, and call editor.current.commands.setContent(...) imperatively when you need to push from outside:
<script lang="ts">
import type { JSONContent } from '@tiptap/core';
let content = $state<JSONContent>({ type: 'doc', content: [] });
const editor = createVizelEditor({
onUpdate: ({ editor }) => {
content = editor.getJSON();
},
});
function reset(): void {
editor.current?.commands.setContent({ type: 'doc', content: [] });
}
</script>With Form
<script lang="ts">
const editor = createVizelEditor();
function handleSubmit(e: Event) {
e.preventDefault();
if (editor.current) {
const content = editor.current.getJSON();
// Submit content
}
}
</script>
<form onsubmit={handleSubmit}>
<VizelEditor editor={editor.current} />
<button type="submit">Submit</button>
</form>Binding to Variable
<script lang="ts">
let editorRef = $state<Editor | null>(null);
const editor = createVizelEditor({
onCreate: ({ editor }) => {
editorRef = editor;
},
});
function focusEditor() {
editorRef?.commands.focus();
}
</script>
<button onclick={focusEditor}>Focus</button>
<VizelEditor editor={editor.current} />Custom Bubble Menu
<script lang="ts">
import type { Editor } from '@tiptap/core';
let { editor }: { editor: Editor | null } = $props();
</script>
{#if editor}
<div class="bubble-menu">
<button
onclick={() => editor.chain().focus().toggleBold().run()}
class:active={editor.isActive('bold')}
>
Bold
</button>
<button
onclick={() => editor.chain().focus().toggleItalic().run()}
class:active={editor.isActive('italic')}
>
Italic
</button>
<button onclick={() => editor.chain().focus().undo().run()}>
Undo
</button>
<button onclick={() => editor.chain().focus().redo().run()}>
Redo
</button>
</div>
{/if}Context Pattern
<!-- Parent.svelte -->
<script lang="ts">
import { setContext } from 'svelte';
import { createVizelEditor } from '@vizel/svelte';
const editor = createVizelEditor();
setContext('editor', editor);
</script>
<!-- Child.svelte -->
<script lang="ts">
import { getContext } from 'svelte';
const editor = getContext('editor');
</script>Derived State
<script lang="ts">
const editor = createVizelEditor();
createVizelState(() => editor.current);
// Derived values that update with editor state
const characterCount = $derived(
editor.current?.storage.characterCount?.characters() ?? 0
);
const wordCount = $derived(
editor.current?.storage.characterCount?.words() ?? 0
);
const isEmpty = $derived(
editor.current?.isEmpty ?? true
);
</script>
<div class="stats">
<span>{characterCount} characters</span>
<span>{wordCount} words</span>
<span>{isEmpty ? 'Empty' : 'Has content'}</span>
</div>SSR/SvelteKit Considerations
The editor runs on the client side only. createVizelEditor is safe to call unconditionally at script top — its internal $effect is a no-op on the server, and editor.current is null until the editor finishes initializing in the browser:
<script lang="ts">
import { createVizelEditor, VizelEditor } from '@vizel/svelte';
const editor = createVizelEditor();
</script>
{#if editor.current}
<VizelEditor editor={editor.current} />
{:else}
<div>Loading editor...</div>
{/if}If you want to defer loading the editor bundle until the client needs it, use a dynamic import:
<script lang="ts">
import { onMount } from 'svelte';
let Editor = $state<typeof import('./Editor.svelte').default | null>(null);
onMount(async () => {
Editor = (await import('./Editor.svelte')).default;
});
</script>
{#if Editor}
<Editor />
{:else}
<div>Loading...</div>
{/if}The <svelte:component> wrapper used in earlier Svelte versions is deprecated in Svelte 5 — render the component variable directly.
Svelte 5 Runes vs Svelte 4
Vizel uses Svelte 5 runes. Key differences:
| Svelte 4 | Svelte 5 (Vizel) |
|---|---|
let editor | const editor = createVizelEditor() |
$: count = ... | const count = $derived(...) |
export let prop | let { prop } = $props() |
| Stores | Runes ($state, $derived) |
Next Steps
- Editor - Editor options, features, and auto-save
- Features - Enable and configure features
- Theming - Customize appearance
- API Reference