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").
classstring—Class 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.