Vue contenteditable caret jumps on reactive state update
OS: Any Any · Device: Desktop or Laptop Any · Browser: Safari Latest · Keyboard: US
Open case →Scenario
When using contenteditable with JavaScript frameworks like Vue, Angular, or Svelte, state synchronization between the DOM and framework state can cause caret position issues, event mismatches, and performance problems. Each framework has unique challenges when integrating with contenteditable.
When using contenteditable with JavaScript frameworks like Vue, Angular, or Svelte, state synchronization between the DOM and framework state can cause caret position issues, event mismatches, and performance problems.
v-model is designed for form inputs, not contenteditablechange events don’t fire reliably, requiring input eventsControlValueAccessor by defaultngModel doesn’t work directly with contenteditableinput events<template>
<div
ref="editable"
contenteditable="true"
@input="onInput"
@blur="onBlur"
></div>
</template>
<script>
export default {
props: {
value: String
},
emits: ['update:value'],
data() {
return {
caretOffset: 0
}
},
methods: {
onInput(e) {
const el = this.$refs.editable
const sel = window.getSelection()
if (sel.rangeCount > 0) {
const range = sel.getRangeAt(0)
const pre = range.cloneRange()
pre.selectNodeContents(el)
pre.setEnd(range.startContainer, range.startOffset)
this.caretOffset = pre.toString().length
}
this.$emit('update:value', el.innerText)
},
restoreCaret() {
// Restore caret position logic
const el = this.$refs.editable
// Implementation similar to React example
}
},
watch: {
value(newVal) {
const el = this.$refs.editable
if (el.innerText !== newVal) {
el.innerText = newVal
this.$nextTick(this.restoreCaret)
}
}
}
}
</script>
import { debounce } from 'lodash'
export default {
methods: {
onInput: debounce(function(e) {
this.$emit('update:value', e.currentTarget.innerText)
}, 300)
}
}
import { Component, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-contenteditable',
template: '<div contenteditable="true" (input)="onInput($event)"></div>',
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ContentEditableComponent),
multi: true
}]
})
export class ContentEditableComponent implements ControlValueAccessor {
private caretPosition: number = 0;
onInput(event: Event) {
const element = event.target as HTMLElement;
this.saveCaretPosition(element);
this.onChange(element.innerText);
}
writeValue(value: string): void {
const element = document.querySelector('[contenteditable]') as HTMLElement;
if (element && element.innerText !== value) {
element.innerText = value;
this.restoreCaretPosition(element);
}
}
registerOnChange(fn: (value: string) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
private onChange = (value: string) => {};
private onTouched = () => {};
private saveCaretPosition(element: HTMLElement): void {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const preRange = range.cloneRange();
preRange.selectNodeContents(element);
preRange.setEnd(range.startContainer, range.startOffset);
this.caretPosition = preRange.toString().length;
}
}
private restoreCaretPosition(element: HTMLElement): void {
// Restore logic
}
}
<script>
let content = '';
let editableElement;
let caretPosition = 0;
function saveCaretPosition() {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const preRange = range.cloneRange();
preRange.selectNodeContents(editableElement);
preRange.setEnd(range.startContainer, range.startOffset);
caretPosition = preRange.toString().length;
}
}
function handleInput(event) {
saveCaretPosition();
content = event.currentTarget.innerText;
}
$: if (content !== editableElement?.innerText) {
editableElement.innerText = content;
// Restore caret
}
</script>
<div
bind:this={editableElement}
contenteditable="true"
on:input={handleInput}
>{content}</div>
Visual view of how this scenario connects to its concrete cases and environments. Nodes can be dragged and clicked.
Each row is a concrete case for this scenario, with a dedicated document and playground.
| Case | OS | Device | Browser | Keyboard | Status |
|---|---|---|---|---|---|
| ce-0560-framework-state-sync-vue | Any Any | Desktop or Laptop Any | Safari Latest | US | draft |
Open a case to see the detailed description and its dedicated playground.
OS: Any Any · Device: Desktop or Laptop Any · Browser: Safari Latest · Keyboard: US
Open case →Other scenarios that share similar tags or category.
When using contentEditable elements in React, the caret (cursor) position jumps to the beginning of the element whenever the component re-renders. This occurs because React's reconciliation process replaces DOM nodes, causing the browser to lose track of the caret position. This issue is more prevalent in Safari and Firefox.
When browser dark mode is enabled, contenteditable elements may experience invisible or poorly visible caret, inline style injection conflicts, background color issues, and form control styling problems. These issues are caused by missing color-scheme declarations and conflicts between browser-injected styles and custom CSS.
When the browser is zoomed (or content is scaled via CSS transforms), caret position and text selection in contenteditable elements can become inaccurate. Clicking at a certain position places the caret elsewhere, and selection highlights may not match the visual selection.
When editing content inside an element with `position:relative`, the text caret (cursor) is completely invisible. Text can be typed and appears in the editor, but there's no visual feedback of where the insertion point is located.
On Chrome Mobile for Android, typing certain punctuation characters (commas, colons, semicolons, quotes, etc.) in the middle of a word causes the cursor to jump to the end of the word instead of staying at the insertion point.
Have questions, suggestions, or want to share your experience? Join the discussion below.