Skip to content

Vue

Vue 3 components and composables for Vizel editor.

Installation

bash
npm install @vizel/vue
# or
pnpm add @vizel/vue
# or
yarn add @vizel/vue

Requirements

  • Vue 3.4+ (peer dependency)
  • @vizel/core and 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 own package.json unless you want to pin a specific version.
  • Any ESM-compatible bundler (verified against Vite 8, Nuxt 3, esbuild).

Quick Start

Use the Vizel component:

vue
<script setup lang="ts">
import { Vizel } from '@vizel/vue';
import '@vizel/core/styles.css';
</script>

<template>
  <Vizel
    placeholder="Type '/' for commands..."
    @update="({ editor }) => console.log(editor.getJSON())"
  />
</template>

Advanced Setup

To customize, use individual components with composables:

vue
<script setup lang="ts">
import { VizelEditor, VizelBubbleMenu, useVizelEditor } from '@vizel/vue';
import '@vizel/core/styles.css';

const editor = useVizelEditor({
  placeholder: "Type '/' for commands...",
});
</script>

<template>
  <div class="editor-container">
    <VizelEditor :editor="editor" />
    <VizelBubbleMenu v-if="editor" :editor="editor" />
  </div>
</template>

Components

Vizel

All-in-one editor component with built-in bubble menu.

vue
<script setup lang="ts">
import { Vizel } from '@vizel/vue';
</script>

<template>
  <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' },
      },
    }"
    @update="({ editor }) => {}"
    @create="({ editor }) => {}"
    @focus="({ editor }) => {}"
    @blur="({ editor }) => {}"
  />
</template>

Props

PropTypeDefaultDescription
initialContentJSONContent-Initial content (JSON)
initialMarkdownstring-Initial content (Markdown)
v-model:markdownstring-Two-way Markdown binding
placeholderstring-Placeholder text
editablebooleantrueEditable state
autofocusboolean | 'start' | 'end' | 'all' | number-Auto focus
featuresVizelFeatureOptions-Feature options
classstring-CSS class
showToolbarbooleanfalseShow fixed toolbar above editor
showBubbleMenubooleantrueShow bubble menu
enableEmbedboolean-Enable embed in links
extensionsExtensions-Additional Tiptap extensions
transformDiagramsOnImportbooleantrueTransform diagram code blocks on import
flavorVizelMarkdownFlavor"gfm"Markdown output flavor ("commonmark", "gfm", "obsidian", "docusaurus").
localeVizelLocale-Localized UI strings. Use createVizelLocale() to merge a partial override with the English default.

Slots

SlotSlot propsDescription
toolbar{ editor }Custom toolbar content. Renders inside <VizelToolbar> when showToolbar is true.
bubble-menu{ editor }Custom bubble-menu content. Renders inside <VizelBubbleMenu> when showBubbleMenu is true.
default{ editor }Additional content rendered inside the editor root.

Events

EventPayloadDescription
update{ editor: Editor }Fires when content changes
update:markdownstringFires when Markdown content changes (paired with v-model:markdown)
create{ editor: Editor }Fires when the editor initializes
destroy-Fires when the editor destroys
selectionUpdate{ editor: Editor }Fires when the selection changes
focus{ editor: Editor }Fires when the editor gains focus
blur{ editor: Editor }Fires when the editor loses focus
errorVizelErrorFires when an editor operation surfaces a VizelError. Narrow with isVizelError(error) to inspect the structured code field.

Composables

useVizelEditor

This composable creates and manages a Vizel editor instance.

vue
<script setup lang="ts">
import { useVizelEditor } from '@vizel/vue';

const editor = useVizelEditor({
  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 ShallowRef<Editor | null>. The editor starts as null during SSR and before initialization.

useVizelState

This composable forces a component re-render on editor state changes.

vue
<script setup lang="ts">
import { useVizelState } from '@vizel/vue';

const props = defineProps<{ editor: Editor | null }>();

// Re-renders when editor state changes
useVizelState(() => props.editor);
</script>

<template>
  <div v-if="editor">
    <span>{{ editor.storage.characterCount?.characters() ?? 0 }} characters</span>
    <span>{{ editor.storage.characterCount?.words() ?? 0 }} words</span>
  </div>
</template>

useVizelEditorState

This composable returns computed editor state that updates reactively. It provides commonly needed properties like character count, word count, and undo/redo availability.

vue
<script setup lang="ts">
import { useVizelEditor, useVizelEditorState, VizelEditor } from '@vizel/vue';

const editor = useVizelEditor();
const editorState = useVizelEditorState(() => editor.value);
</script>

<template>
  <VizelEditor :editor="editor" />
  <div class="status-bar">
    <span>{{ editorState.characterCount }} characters</span>
    <span>{{ editorState.wordCount }} words</span>
  </div>
</template>

Return Value

Returns ComputedRef<VizelEditorState>:

PropertyTypeDescription
isFocusedbooleanWhether the editor is focused
isEmptybooleanWhether the editor is empty
canUndobooleanWhether undo is available
canRedobooleanWhether redo is available
characterCountnumberCharacter count
wordCountnumberWord count

useVizelAutoSave

This composable automatically saves editor content.

vue
<script setup lang="ts">
import { useVizelEditor, useVizelAutoSave, VizelEditor, VizelSaveIndicator } from '@vizel/vue';

const editor = useVizelEditor();

const { status, lastSaved, save, restore } = useVizelAutoSave(() => editor.value, {
  debounceMs: 2000,
  storage: 'localStorage',
  key: 'my-editor-content',
  onSave: (content) => console.log('Saved'),
  onError: (error) => console.error('Save failed', error),
});
</script>

<template>
  <div>
    <VizelEditor :editor="editor" />
    <VizelSaveIndicator :status="status" :lastSaved="lastSaved" />
  </div>
</template>

useVizelMarkdown

This composable provides two-way Markdown synchronization with debouncing.

vue
<script setup lang="ts">
import { useVizelEditor, useVizelMarkdown, VizelEditor } from '@vizel/vue';

const editor = useVizelEditor();
const { markdown, setMarkdown, isPending } = useVizelMarkdown(() => editor.value, {
  debounceMs: 300, // default: 300ms
});
</script>

<template>
  <VizelEditor :editor="editor" />
  <textarea :value="markdown" @input="setMarkdown($event.target.value)" />
  <span v-if="isPending">Syncing...</span>
</template>

Return Value

PropertyTypeDescription
markdownReadonly<ShallowRef<string>>Current Markdown content. Templates auto-unwrap the ref; in <script setup> read markdown.value.
setMarkdown(md: string) => voidUpdate editor from Markdown
isPendingComputedRef<boolean>Whether sync is pending
flush() => voidForce-flush pending updates immediately

useVizelTheme

This composable accesses theme state within VizelThemeProvider.

vue
<!-- App.vue - VizelThemeProvider wraps the app -->
<script setup lang="ts">
import { VizelThemeProvider } from '@vizel/vue';
import ThemeToggle from './ThemeToggle.vue';
</script>

<template>
  <VizelThemeProvider defaultTheme="system">
    <Editor />
    <ThemeToggle />
  </VizelThemeProvider>
</template>
vue
<!-- ThemeToggle.vue - useVizelTheme() must be called inside the provider -->
<script setup lang="ts">
import { useVizelTheme } from '@vizel/vue';

const { theme, setTheme } = useVizelTheme();

function toggleTheme() {
  setTheme(theme.value === 'dark' ? 'light' : 'dark');
}
</script>

<template>
  <button @click="toggleTheme">
    {{ theme === 'dark' ? 'Light Mode' : 'Dark Mode' }}
  </button>
</template>

Components

VizelEditor

This component renders the editor content area.

vue
<VizelEditor 
  :editor="editor" 
  class="my-editor"
/>

Props

PropTypeDescription
editorEditor | nullEditor instance
classstringCustom class name

VizelBubbleMenu

This component displays a floating bubble menu on text selection.

vue
<VizelBubbleMenu 
  :editor="editor"
  class="my-bubble-menu"
  :showDefaultMenu="true"
  :updateDelay="100"
/>

Props

PropTypeDefaultDescription
editorEditor | null-Editor instance
classstring-Custom class name
showDefaultMenubooleantrueShow default bubble menu
pluginKeystring"vizelBubbleMenu"Plugin key
updateDelaynumber100Position update delay
shouldShowFunction-Custom visibility logic
enableEmbedboolean-Enable embed in link editor

VizelThemeProvider

This component provides theme context.

vue
<VizelThemeProvider 
  defaultTheme="system"
  storageKey="my-theme"
  :disableTransitionOnChange="false"
>
  <slot />
</VizelThemeProvider>

Props

PropTypeDefaultDescription
defaultTheme"light" | "dark" | "system""system"Default theme
storageKeystring"vizel-theme"Storage key
targetSelectorstring-Theme attribute target
disableTransitionOnChangebooleanfalseDisable transitions

VizelSaveIndicator

This component displays the save status.

vue
<VizelSaveIndicator 
  :status="status" 
  :lastSaved="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.

vue
<VizelPortal layer="dropdown">
  <div class="my-overlay">Content</div>
</VizelPortal>

Props:

PropTypeDefaultDescription
layerVizelPortalLayer"dropdown"Z-index layer ("dropdown", "popover", "modal", "toast").
classstringClass applied to the portal wrapper element.
disabledbooleanfalseWhen 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.

vue
<script setup lang="ts">
import { VizelIcon } from '@vizel/vue';
</script>

<template>
  <VizelIcon name="bold" class="my-icon" />
</template>

Patterns

Working with Markdown

vue
<script setup lang="ts">
import { ref } from 'vue';
import { Vizel } from '@vizel/vue';

const markdown = ref('# Hello World\n\nStart editing...');
</script>

<template>
  <!-- Simple: v-model:markdown for two-way binding -->
  <Vizel v-model:markdown="markdown" />
  
  <!-- Or one-way with initialMarkdown -->
  <Vizel initialMarkdown="# Read Only Initial" />
</template>

Controlled vs uncontrolled

ModePropsWho owns the stateWhen to use
Uncontrolled:initial-markdown="..." onlyThe editorStandalone editor; observe edits via @update.
Controlledv-model:markdown="..." (or :markdown + @update:markdown)The parent componentSync with form state, persist to a remote store, drive the editor from another input.

Passing only :markdown without listening for update:markdown is treated as "set-once": the editor renders the initial value but later prop changes are ignored. Always use v-model:markdown (or both halves manually) when you intend two-way sync.

Split View (WYSIWYG + Raw Markdown)

vue
<script setup lang="ts">
import { ref } from 'vue';
import { Vizel } from '@vizel/vue';

const markdown = ref('# Hello\n\nEdit in either pane!');
</script>

<template>
  <div class="split-view">
    <Vizel v-model:markdown="markdown" />
    <textarea v-model="markdown" />
  </div>
</template>

Controlled Content with v-model (JSON)

vue
<script setup lang="ts">
import { ref, watch } from 'vue';
import type { JSONContent } from '@tiptap/core';

const content = ref<JSONContent>({ type: 'doc', content: [] });

const editor = useVizelEditor({
  initialContent: content.value,
  onUpdate: ({ editor }) => {
    content.value = editor.getJSON();
  },
});
</script>

With Form

vue
<script setup lang="ts">
const editor = useVizelEditor();

function handleSubmit() {
  if (editor.value) {
    const content = editor.value.getJSON();
    // Submit content
  }
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <VizelEditor :editor="editor" />
    <button type="submit">Submit</button>
  </form>
</template>

Template Ref Access

vue
<script setup lang="ts">
import { ref, watchEffect } from 'vue';

const editorRef = ref<Editor | null>(null);

const editor = useVizelEditor({
  onCreate: ({ editor }) => {
    editorRef.value = editor;
  },
});

function focusEditor() {
  editorRef.value?.commands.focus();
}
</script>

<template>
  <div>
    <button @click="focusEditor">Focus</button>
    <VizelEditor :editor="editor" />
  </div>
</template>

Custom Bubble Menu

vue
<script setup lang="ts">
import type { Editor } from '@tiptap/core';

const props = defineProps<{ editor: Editor | null }>();
</script>

<template>
  <div v-if="editor" class="bubble-menu">
    <button
      @click="editor.chain().focus().toggleBold().run()"
      :class="{ active: editor.isActive('bold') }"
    >
      Bold
    </button>
    <button
      @click="editor.chain().focus().toggleItalic().run()"
      :class="{ active: editor.isActive('italic') }"
    >
      Italic
    </button>
    <button @click="editor.chain().focus().undo().run()">
      Undo
    </button>
    <button @click="editor.chain().focus().redo().run()">
      Redo
    </button>
  </div>
</template>

Provide/Inject Pattern

vue
<!-- Parent.vue -->
<script setup lang="ts">
import { provide } from 'vue';
import { useVizelEditor } from '@vizel/vue';

const editor = useVizelEditor();
provide('editor', editor);
</script>

<!-- Child.vue -->
<script setup lang="ts">
import { inject, type ShallowRef } from 'vue';
import type { Editor } from '@tiptap/core';

const editor = inject<ShallowRef<Editor | null>>('editor');
</script>

SSR/Nuxt Considerations

The editor runs on the client side only. Use <ClientOnly> in Nuxt:

vue
<template>
  <ClientOnly>
    <Editor />
    <template #fallback>
      <div>Loading editor...</div>
    </template>
  </ClientOnly>
</template>

Or use dynamic import:

vue
<script setup lang="ts">
import { defineAsyncComponent } from 'vue';

const Editor = defineAsyncComponent(() => import('./Editor.vue'));
</script>

Next Steps

Released under the MIT License.