Integrating contenteditable with Svelte
How to properly integrate contenteditable elements with Svelte, handle reactive state, and prevent caret position issues
When to Use This Tip
Use this pattern when you need to:
- Integrate contenteditable with Svelte
- Handle reactive state binding
- Prevent caret position jumps on reactive updates
- Work with Svelte’s reactivity system
- Implement two-way binding with contenteditable
Problem
Svelte’s reactivity can cause issues with contenteditable:
- Caret position jumps on reactive updates
- Reactive statements trigger DOM updates that reset cursor
- State synchronization between DOM and Svelte state
- Binding content directly can cause issues
Solution
1. Basic Svelte Component with contenteditable
Simple integration with manual state management:
<script>
let content = '';
let editableElement;
function handleInput(event) {
content = event.currentTarget.innerHTML;
}
function handleBlur(event) {
content = event.currentTarget.innerHTML;
}
</script>
<div
bind:this={editableElement}
contenteditable="true"
on:input={handleInput}
on:blur={handleBlur}
innerHTML={content}
></div>
2. Caret Position Preservation
Save and restore caret position to prevent jumps:
<script>
let content = '';
let editableElement;
let savedSelection = null;
let isUpdating = false;
function saveCaretPosition() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
// Calculate character offsets
const startRange = range.cloneRange();
startRange.selectNodeContents(editableElement);
startRange.setEnd(range.startContainer, range.startOffset);
const startOffset = startRange.toString().length;
const endRange = range.cloneRange();
endRange.selectNodeContents(editableElement);
endRange.setEnd(range.endContainer, range.endOffset);
const endOffset = endRange.toString().length;
savedSelection = {
start: startOffset,
end: endOffset,
collapsed: range.collapsed,
};
}
function restoreCaretPosition() {
if (!savedSelection || !editableElement) return;
const selection = window.getSelection();
const range = document.createRange();
// Find start position
let currentOffset = 0;
const walker = document.createTreeWalker(
editableElement,
NodeFilter.SHOW_TEXT,
null
);
let startNode = null;
let startOffset = 0;
let node;
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= savedSelection.start) {
startNode = node;
startOffset = savedSelection.start - currentOffset;
break;
}
currentOffset += nodeLength;
}
if (!startNode) return;
range.setStart(startNode, startOffset);
if (savedSelection.collapsed) {
range.collapse(true);
} else {
// Find end position
currentOffset = 0;
const walker = document.createTreeWalker(
editableElement,
NodeFilter.SHOW_TEXT,
null
);
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= savedSelection.end) {
const endNode = node;
const endOffset = savedSelection.end - currentOffset;
range.setEnd(endNode, endOffset);
break;
}
currentOffset += nodeLength;
}
}
selection.removeAllRanges();
selection.addRange(range);
}
function handleInput(event) {
if (isUpdating) return;
saveCaretPosition();
content = event.currentTarget.innerHTML;
}
function handleKeyUp() {
saveCaretPosition();
}
function handleMouseUp() {
saveCaretPosition();
}
// Reactive statement to update DOM when content changes
$: if (editableElement && !isUpdating && editableElement.innerHTML !== content) {
isUpdating = true;
saveCaretPosition();
editableElement.innerHTML = content;
// Restore caret after DOM update
setTimeout(() => {
restoreCaretPosition();
isUpdating = false;
}, 0);
}
</script>
<div
bind:this={editableElement}
contenteditable="true"
on:input={handleInput}
on:keyup={handleKeyUp}
on:mouseup={handleMouseUp}
></div>
3. Two-Way Binding with Store
Use Svelte store for state management:
<script>
import { writable } from 'svelte/store';
let editableElement;
let savedSelection = null;
let isUpdating = false;
// Create store for content
export let contentStore = writable('');
let content = '';
// Subscribe to store
contentStore.subscribe(value => {
content = value;
});
function saveCaretPosition() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const startRange = range.cloneRange();
startRange.selectNodeContents(editableElement);
startRange.setEnd(range.startContainer, range.startOffset);
const startOffset = startRange.toString().length;
const endRange = range.cloneRange();
endRange.selectNodeContents(editableElement);
endRange.setEnd(range.endContainer, range.endOffset);
const endOffset = endRange.toString().length;
savedSelection = {
start: startOffset,
end: endOffset,
collapsed: range.collapsed,
};
}
function restoreCaretPosition() {
if (!savedSelection || !editableElement) return;
const selection = window.getSelection();
const range = document.createRange();
let currentOffset = 0;
const walker = document.createTreeWalker(
editableElement,
NodeFilter.SHOW_TEXT,
null
);
let startNode = null;
let startOffset = 0;
let node;
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= savedSelection.start) {
startNode = node;
startOffset = savedSelection.start - currentOffset;
break;
}
currentOffset += nodeLength;
}
if (!startNode) return;
range.setStart(startNode, startOffset);
if (savedSelection.collapsed) {
range.collapse(true);
} else {
currentOffset = 0;
const walker = document.createTreeWalker(
editableElement,
NodeFilter.SHOW_TEXT,
null
);
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= savedSelection.end) {
const endNode = node;
const endOffset = savedSelection.end - currentOffset;
range.setEnd(endNode, endOffset);
break;
}
currentOffset += nodeLength;
}
}
selection.removeAllRanges();
selection.addRange(range);
}
function handleInput(event) {
if (isUpdating) return;
saveCaretPosition();
const newContent = event.currentTarget.innerHTML;
contentStore.set(newContent);
}
// Update DOM when store changes
$: if (editableElement && !isUpdating && editableElement.innerHTML !== content) {
isUpdating = true;
saveCaretPosition();
editableElement.innerHTML = content;
requestAnimationFrame(() => {
restoreCaretPosition();
isUpdating = false;
});
}
</script>
<div
bind:this={editableElement}
contenteditable="true"
on:input={handleInput}
on:keyup={saveCaretPosition}
on:mouseup={saveCaretPosition}
></div>
4. Component with Props and Events
Reusable component with proper event handling:
<script>
export let value = '';
export let disabled = false;
let editableElement;
let savedSelection = null;
let isUpdating = false;
function saveCaretPosition() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const startRange = range.cloneRange();
startRange.selectNodeContents(editableElement);
startRange.setEnd(range.startContainer, range.startOffset);
const startOffset = startRange.toString().length;
const endRange = range.cloneRange();
endRange.selectNodeContents(editableElement);
endRange.setEnd(range.endContainer, range.endOffset);
const endOffset = endRange.toString().length;
savedSelection = {
start: startOffset,
end: endOffset,
collapsed: range.collapsed,
};
}
function restoreCaretPosition() {
if (!savedSelection || !editableElement) return;
const selection = window.getSelection();
const range = document.createRange();
let currentOffset = 0;
const walker = document.createTreeWalker(
editableElement,
NodeFilter.SHOW_TEXT,
null
);
let startNode = null;
let startOffset = 0;
let node;
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= savedSelection.start) {
startNode = node;
startOffset = savedSelection.start - currentOffset;
break;
}
currentOffset += nodeLength;
}
if (!startNode) return;
range.setStart(startNode, startOffset);
if (savedSelection.collapsed) {
range.collapse(true);
} else {
currentOffset = 0;
const walker = document.createTreeWalker(
editableElement,
NodeFilter.SHOW_TEXT,
null
);
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= savedSelection.end) {
const endNode = node;
const endOffset = savedSelection.end - currentOffset;
range.setEnd(endNode, endOffset);
break;
}
currentOffset += nodeLength;
}
}
selection.removeAllRanges();
selection.addRange(range);
}
function handleInput(event) {
if (isUpdating || disabled) return;
saveCaretPosition();
const newValue = event.currentTarget.innerHTML;
if (newValue !== value) {
value = newValue;
// Dispatch custom event for two-way binding
const inputEvent = new CustomEvent('input', {
detail: newValue,
bubbles: true,
});
editableElement.dispatchEvent(inputEvent);
}
}
function handleBlur() {
// Dispatch blur event
const blurEvent = new CustomEvent('blur', {
bubbles: true,
});
editableElement.dispatchEvent(blurEvent);
}
// Update DOM when value prop changes
$: if (editableElement && !isUpdating && editableElement.innerHTML !== value) {
isUpdating = true;
saveCaretPosition();
editableElement.innerHTML = value;
requestAnimationFrame(() => {
restoreCaretPosition();
isUpdating = false;
});
}
</script>
<div
bind:this={editableElement}
contenteditable={!disabled}
class:disabled
on:input={handleInput}
on:blur={handleBlur}
on:keyup={saveCaretPosition}
on:mouseup={saveCaretPosition}
role="textbox"
aria-multiline="true"
></div>
<style>
.disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
<!-- Usage -->
<!-- <ContentEditable bind:value={content} disabled={false} /> -->
5. Advanced Component with Actions
Use Svelte actions for better encapsulation:
<script>
export let value = '';
export let disabled = false;
let editableElement;
let savedSelection = null;
let isUpdating = false;
function contenteditableAction(node) {
editableElement = node;
function saveCaretPosition() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const startRange = range.cloneRange();
startRange.selectNodeContents(node);
startRange.setEnd(range.startContainer, range.startOffset);
const startOffset = startRange.toString().length;
const endRange = range.cloneRange();
endRange.selectNodeContents(node);
endRange.setEnd(range.endContainer, range.endOffset);
const endOffset = endRange.toString().length;
savedSelection = {
start: startOffset,
end: endOffset,
collapsed: range.collapsed,
};
}
function restoreCaretPosition() {
if (!savedSelection) return;
const selection = window.getSelection();
const range = document.createRange();
let currentOffset = 0;
const walker = document.createTreeWalker(
node,
NodeFilter.SHOW_TEXT,
null
);
let startNode = null;
let startOffset = 0;
let node;
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= savedSelection.start) {
startNode = node;
startOffset = savedSelection.start - currentOffset;
break;
}
currentOffset += nodeLength;
}
if (!startNode) return;
range.setStart(startNode, startOffset);
if (savedSelection.collapsed) {
range.collapse(true);
} else {
currentOffset = 0;
const walker = document.createTreeWalker(
node,
NodeFilter.SHOW_TEXT,
null
);
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= savedSelection.end) {
const endNode = node;
const endOffset = savedSelection.end - currentOffset;
range.setEnd(endNode, endOffset);
break;
}
currentOffset += nodeLength;
}
}
selection.removeAllRanges();
selection.addRange(range);
}
function handleInput(event) {
if (isUpdating || disabled) return;
saveCaretPosition();
const newValue = event.currentTarget.innerHTML;
if (newValue !== value) {
value = newValue;
node.dispatchEvent(new CustomEvent('input', {
detail: newValue,
bubbles: true,
}));
}
}
node.addEventListener('input', handleInput);
node.addEventListener('keyup', saveCaretPosition);
node.addEventListener('mouseup', saveCaretPosition);
// Update DOM when value changes
const unsubscribe = () => {
if (node && !isUpdating && node.innerHTML !== value) {
isUpdating = true;
saveCaretPosition();
node.innerHTML = value;
requestAnimationFrame(() => {
restoreCaretPosition();
isUpdating = false;
});
}
};
// Watch for value changes
$: if (node) {
unsubscribe();
}
return {
destroy() {
node.removeEventListener('input', handleInput);
node.removeEventListener('keyup', saveCaretPosition);
node.removeEventListener('mouseup', saveCaretPosition);
},
};
}
</script>
<div
use:contenteditableAction
contenteditable={!disabled}
class:disabled
role="textbox"
aria-multiline="true"
></div>
<style>
.disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
6. Complete Svelte Integration with Debouncing
Full solution with debouncing and proper state management:
<script>
import { debounce } from './utils';
export let value = '';
export let disabled = false;
export let debounceMs = 100;
let editableElement;
let savedSelection = null;
let isUpdating = false;
let localValue = value;
// Debounced update function
const debouncedUpdate = debounce((newValue) => {
if (newValue !== value) {
value = newValue;
editableElement?.dispatchEvent(new CustomEvent('input', {
detail: newValue,
bubbles: true,
}));
}
}, debounceMs);
function saveCaretPosition() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const startRange = range.cloneRange();
startRange.selectNodeContents(editableElement);
startRange.setEnd(range.startContainer, range.startOffset);
const startOffset = startRange.toString().length;
const endRange = range.cloneRange();
endRange.selectNodeContents(editableElement);
endRange.setEnd(range.endContainer, range.endOffset);
const endOffset = endRange.toString().length;
savedSelection = {
start: startOffset,
end: endOffset,
collapsed: range.collapsed,
};
}
function restoreCaretPosition() {
if (!savedSelection || !editableElement) return;
const selection = window.getSelection();
const range = document.createRange();
let currentOffset = 0;
const walker = document.createTreeWalker(
editableElement,
NodeFilter.SHOW_TEXT,
null
);
let startNode = null;
let startOffset = 0;
let node;
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= savedSelection.start) {
startNode = node;
startOffset = savedSelection.start - currentOffset;
break;
}
currentOffset += nodeLength;
}
if (!startNode) return;
range.setStart(startNode, startOffset);
if (savedSelection.collapsed) {
range.collapse(true);
} else {
currentOffset = 0;
const walker = document.createTreeWalker(
editableElement,
NodeFilter.SHOW_TEXT,
null
);
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= savedSelection.end) {
const endNode = node;
const endOffset = savedSelection.end - currentOffset;
range.setEnd(endNode, endOffset);
break;
}
currentOffset += nodeLength;
}
}
selection.removeAllRanges();
selection.addRange(range);
}
function handleInput(event) {
if (isUpdating || disabled) return;
saveCaretPosition();
localValue = event.currentTarget.innerHTML;
debouncedUpdate(localValue);
}
function handleBlur() {
// Final update on blur
if (editableElement && editableElement.innerHTML !== value) {
value = editableElement.innerHTML;
editableElement.dispatchEvent(new CustomEvent('input', {
detail: value,
bubbles: true,
}));
}
editableElement?.dispatchEvent(new CustomEvent('blur', {
bubbles: true,
}));
}
// Update DOM when value prop changes
$: if (editableElement && !isUpdating && editableElement.innerHTML !== value) {
isUpdating = true;
saveCaretPosition();
editableElement.innerHTML = value;
localValue = value;
requestAnimationFrame(() => {
restoreCaretPosition();
isUpdating = false;
});
}
</script>
<div
bind:this={editableElement}
contenteditable={!disabled}
class:disabled
on:input={handleInput}
on:blur={handleBlur}
on:keyup={saveCaretPosition}
on:mouseup={saveCaretPosition}
role="textbox"
aria-multiline="true"
aria-disabled={disabled}
></div>
<style>
.disabled {
opacity: 0.6;
cursor: not-allowed;
user-select: none;
}
[contenteditable="true"] {
outline: none;
}
[contenteditable="true"]:focus {
outline: 2px solid var(--accent-primary, #0066cc);
outline-offset: 2px;
}
</style>
<!-- utils.js -->
<!--
export function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
-->
Notes
- Use reactive statements (
$:) carefully to avoid infinite loops - Always save caret position before DOM updates
- Use
requestAnimationFrameorsetTimeoutto restore caret after DOM changes - Debounce input events to reduce reactive updates
- Use
bind:thisto get element reference - Avoid binding
innerHTMLdirectly - use reactive statements instead - Test with Svelte’s reactivity in different scenarios
- Consider using stores for complex state management
Browser Compatibility
- Chrome/Edge: Works well with Svelte
- Firefox: Good support, but test caret restoration
- Safari: Works, but be careful with reactive updates