Skip to content

RichTextEditor Component

A modern rich text editor component built on TipTap, providing complete text formatting functionality and an extensible plugin system. Perfectly integrated with the Vue 3 ecosystem, supporting two-way data binding and custom configuration.

View Live Demo

✍️ Rich Formatting

Complete text formatting options including bold, italic, underline, links, lists, and headings

🧩 Extensible Plugin System

Modular architecture based on TipTap, supports custom plugins and command extensions

🎨 Theme & Style Customization

Complete theme system with dark mode support and custom style configuration

🛡️ TypeScript Support

Complete type definitions ensuring type safety and excellent development experience

📊 Character Count & Limits

Built-in character statistics with character limit support and real-time notifications

♿ Accessibility Support

WCAG compliant with complete keyboard navigation and screen reader support

The simplest rich text editor integration:

Basic Editor Example
<template>
<div class="space-y-4">
<!-- Basic Rich Text Editor -->
<div>
<label class="block text-sm font-medium mb-2">
Article Content
</label>
<RichTextEditor
v-model="articleContent"
placeholder="Start writing your article..."
:min-height="'200px'"
:character-limit="5000"
:show-character-count="true"
@change="handleContentChange"
/>
</div>
<!-- Preview Area -->
<div v-if="articleContent" class="mt-6">
<h3 class="text-lg font-semibold mb-2">Preview</h3>
<div
class="prose prose-gray dark:prose-invert max-w-none p-4 border border-gray-200 dark:border-gray-700 rounded-lg"
v-html="articleContent"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { RichTextEditor } from '@/components/RichTextEditor'
// Article content
const articleContent = ref(`
<h2>Welcome to Rich Text Editor</h2>
<p>This is a powerful rich text editor supporting multiple formatting options:</p>
<ul>
<li><strong>Bold text</strong></li>
<li><em>Italic text</em></li>
<li><u>Underlined text</u></li>
<li><a href="https://example.com">Hyperlinks</a></li>
</ul>
<p>You can easily create professional content editing experiences.</p>
`)
// Content change handler
const handleContentChange = (content: string) => {
console.log('Content updated:', content)
// Auto-save functionality can be implemented here
// autoSave(content)
}
</script>

Using complete configuration options and custom features:

Advanced Editor Configuration
<template>
<div class="space-y-6">
<!-- Advanced Configuration Editor -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg">
<div class="p-4 bg-gray-50 dark:bg-gray-800 border-b">
<h3 class="font-semibold">Email Editor</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Rich text email editing functionality
</p>
</div>
<div class="p-4">
<RichTextEditor
v-model="emailContent"
:editable="!isSending"
:show-toolbar="true"
:toolbar-position="'top'"
:auto-focus="true"
:character-limit="10000"
:show-character-count="true"
:min-height="'300px'"
:max-height="'500px'"
placeholder="Write your email content..."
class="email-editor"
@focus="handleEditorFocus"
@blur="handleEditorBlur"
@character-count="handleCharacterCount"
/>
<div class="flex items-center justify-between mt-4">
<div class="text-sm text-gray-500">
<span v-if="characterCount > 0">
{{ characterCount }} characters
<span v-if="characterCount > 8000" class="text-orange-500">
(recommended to keep under 8000 characters)
</span>
</span>
</div>
<div class="flex gap-2">
<Button
@click="saveDraft"
variant="outline"
size="sm"
:disabled="isSending"
>
Save Draft
</Button>
<Button
@click="sendEmail"
:loading="isSending"
size="sm"
>
{{ isSending ? 'Sending...' : 'Send Email' }}
</Button>
</div>
</div>
</div>
</div>
<!-- Editor Status Display -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
<div class="text-sm font-medium text-blue-900 dark:text-blue-100">
Edit Status
</div>
<div class="text-blue-700 dark:text-blue-300">
{{ editorFocused ? 'Editing' : 'Inactive' }}
</div>
</div>
<div class="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg">
<div class="text-sm font-medium text-green-900 dark:text-green-100">
Character Count
</div>
<div class="text-green-700 dark:text-green-300">
{{ characterCount }} / 10,000
</div>
</div>
<div class="bg-purple-50 dark:bg-purple-900/20 p-3 rounded-lg">
<div class="text-sm font-medium text-purple-900 dark:text-purple-100">
Auto Save
</div>
<div class="text-purple-700 dark:text-purple-300">
{{ lastSaved ? `${lastSaved} saved` : 'Not saved' }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Button } from '@/components/ui/button'
import { RichTextEditor } from '@/components/RichTextEditor'
import { toast } from 'vue-sonner'
// Email content
const emailContent = ref('')
const characterCount = ref(0)
const editorFocused = ref(false)
const isSending = ref(false)
const lastSaved = ref('')
// Editor event handlers
const handleEditorFocus = () => {
editorFocused.value = true
}
const handleEditorBlur = () => {
editorFocused.value = false
// Auto-save draft on blur
saveDraft()
}
const handleCharacterCount = (count: number) => {
characterCount.value = count
}
// Save draft
const saveDraft = async () => {
if (!emailContent.value.trim()) return
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500))
lastSaved.value = new Date().toLocaleTimeString()
toast.success('Draft saved')
} catch (error) {
toast.error('Save failed, please try again')
}
}
// Send email
const sendEmail = async () => {
if (!emailContent.value.trim()) {
toast.error('Please enter email content')
return
}
isSending.value = true
try {
// Simulate sending email
await new Promise(resolve => setTimeout(resolve, 2000))
toast.success('Email sent successfully!')
emailContent.value = ''
characterCount.value = 0
lastSaved.value = ''
} catch (error) {
toast.error('Send failed, please try again')
} finally {
isSending.value = false
}
}
</script>
<style>
.email-editor {
--editor-max-width: none;
--editor-font-size: 14px;
}
</style>
PropertyTypeDefaultDescription
modelValuestringEditor content (HTML format)
placeholderstring’Start typing…’Placeholder text
editablebooleantrueWhether the editor is editable
showToolbarbooleantrueWhether to show toolbar
toolbarPosition'top' | 'bottom' | 'floating'’top’Toolbar position
minHeightstring-Minimum height (e.g., ‘200px’)
maxHeightstring-Maximum height (e.g., ‘500px’)
characterLimitnumber-Character limit
showCharacterCountbooleanfalseWhether to show character count
autoFocusbooleanfalseWhether to auto-focus
classstring-Custom CSS class name
EventParametersDescription
update:modelValuevalue: stringEmitted when content updates
changevalue: stringEmitted when content changes
focus-Emitted when editor gains focus
blur-Emitted when editor loses focus
character-countcount: numberEmitted when character count changes

Access component methods through template ref:

Component Methods Usage
<template>
<div>
<RichTextEditor
ref="editorRef"
v-model="content"
placeholder="Enter content..."
/>
<div class="mt-4 flex gap-2">
<Button @click="focusEditor">Focus Editor</Button>
<Button @click="clearContent">Clear Content</Button>
<Button @click="insertTemplate">Insert Template</Button>
<Button @click="getStats">Get Statistics</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { RichTextEditorInstance } from '@/components/RichTextEditor/types'
const editorRef = ref<RichTextEditorInstance>()
const content = ref('')
// Focus editor
const focusEditor = () => {
editorRef.value?.focus()
}
// Clear content
const clearContent = () => {
editorRef.value?.clear()
}
// Insert template content
const insertTemplate = () => {
const template = `
<h2>Meeting Notes</h2>
<p><strong>Date:</strong> {{ date }}</p>
<p><strong>Attendees:</strong> {{ attendee list }}</p>
<h3>Meeting Content</h3>
<ul>
<li>Topic 1:</li>
<li>Topic 2:</li>
</ul>
`
editorRef.value?.setContent(template)
}
// Get editor statistics
const getStats = () => {
if (editorRef.value) {
const htmlContent = editorRef.value.getHTML()
const textContent = editorRef.value.getText()
const charCount = editorRef.value.getCharacterCount()
console.log({
html: htmlContent,
text: textContent,
characters: charCount,
words: textContent.split(/s+/).length
})
}
}
</script>

Perfect for CMS platforms requiring rich content editing capabilities with complete formatting control.

Ideal for email clients, messaging systems, and communication tools requiring formatted text input.

Excellent for documentation platforms, note-taking applications, and knowledge management systems.

Editor Theme Customization
/* Custom editor theme */
.custom-editor {
--editor-bg: theme('colors.gray.50');
--editor-text: theme('colors.gray.900');
--editor-border: theme('colors.gray.200');
--editor-focus-ring: theme('colors.blue.500');
}
.dark .custom-editor {
--editor-bg: theme('colors.gray.800');
--editor-text: theme('colors.gray.100');
--editor-border: theme('colors.gray.600');
--editor-focus-ring: theme('colors.blue.400');
}
/* Toolbar customization */
.custom-editor .rich-text-editor__toolbar {
background: var(--editor-bg);
border: 1px solid var(--editor-border);
border-radius: 8px;
padding: 8px;
margin-bottom: 8px;
}
/* Content area customization */
.custom-editor .rich-text-editor__content {
background: var(--editor-bg);
color: var(--editor-text);
border: 1px solid var(--editor-border);
border-radius: 8px;
padding: 16px;
font-family: 'Inter', sans-serif;
line-height: 1.6;
}
.custom-editor .rich-text-editor__content:focus-within {
outline: none;
box-shadow: 0 0 0 2px var(--editor-focus-ring);
}
/* Character count styling */
.custom-editor .rich-text-editor__character-count {
text-align: right;
font-size: 12px;
color: theme('colors.gray.500');
margin-top: 4px;
}
.custom-editor .rich-text-editor__character-count--warning {
color: theme('colors.orange.500');
}
.custom-editor .rich-text-editor__character-count--error {
color: theme('colors.red.500');
}
/* Content styling */
.custom-editor .ProseMirror h1 {
font-size: 1.5em;
font-weight: 700;
margin: 1em 0 0.5em;
}
.custom-editor .ProseMirror h2 {
font-size: 1.25em;
font-weight: 600;
margin: 0.8em 0 0.4em;
}
.custom-editor .ProseMirror p {
margin: 0.5em 0;
}
.custom-editor .ProseMirror ul,
.custom-editor .ProseMirror ol {
margin: 0.5em 0;
padding-left: 1.2em;
}
.custom-editor .ProseMirror blockquote {
border-left: 3px solid theme('colors.gray.300');
padding-left: 1em;
margin: 1em 0;
color: theme('colors.gray.600');
}
.custom-editor .ProseMirror code {
background: theme('colors.gray.100');
padding: 2px 4px;
border-radius: 3px;
font-size: 0.9em;
}
.dark .custom-editor .ProseMirror code {
background: theme('colors.gray.700');
}
/* Link styling */
.custom-editor .ProseMirror a {
color: theme('colors.blue.600');
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
.custom-editor .ProseMirror a:hover {
color: theme('colors.blue.700');
}
/* Placeholder styling */
.custom-editor .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: theme('colors.gray.400');
pointer-events: none;
height: 0;
}
Content Processing Best Practices
// ✅ Recommended: Content validation and cleaning
const validateAndCleanContent = (content: string) => {
// 1. Basic validation
if (!content || typeof content !== 'string') {
return ''
}
// 2. HTML security handling (XSS prevention)
const cleanContent = DOMPurify.sanitize(content, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote'],
ALLOWED_ATTR: ['href', 'target', 'rel']
})
// 3. Remove empty tags
return cleanContent.replace(/<p></p>/g, '').trim()
}
// ✅ Recommended: Content length calculation
const getContentLength = (htmlContent: string) => {
// Create temporary DOM element to get plain text
const temp = document.createElement('div')
temp.innerHTML = htmlContent
return temp.textContent?.length || 0
}
// ❌ Avoid: Using unvalidated content directly
const badExample = (userContent: any) => {
// Direct use may cause security issues
return userContent
}
Performance Optimization Practices
// ✅ Recommended: Debounced saving and performance optimization
import { debounce } from 'lodash-es'
const debouncedSave = debounce((content: string) => {
// Only save when content actually changes
if (content !== lastSavedContent.value) {
saveContent(content)
lastSavedContent.value = content
}
}, 1000)
// ✅ Recommended: Large content lazy loading
const loadEditor = async () => {
if (contentLength > 50000) {
// Load large content in chunks
const chunks = splitContentIntoChunks(content.value)
for (const chunk of chunks) {
await nextTick()
appendChunk(chunk)
}
} else {
// Load small content directly
editor.value?.setContent(content.value)
}
}
// ❌ Avoid: Frequent content updates
const badExample = () => {
// Saving on every input affects performance
watch(content, (newContent) => {
saveContent(newContent) // Not recommended
})
}
  • Provide clear status feedback for saving and loading states
  • Implement proper error handling with user-friendly messages
  • Add keyboard shortcuts for common operations
  • Include loading indicators for better perceived performance
  • Implement auto-save functionality with visual confirmation