I spent way too many hours fighting with cursor positioning in contenteditable elements. The browser's default behavior is unpredictable, and most tutorials show toy examples that break with real content.
What you'll build: Bulletproof cursor positioning that works with text, HTML, and edge cases
Time needed: 15 minutes to implement, hours of debugging saved
Difficulty: Intermediate (need basic DOM knowledge)
Here's the exact approach I use in production apps. No more cursors jumping to random positions or disappearing entirely.
Why I Built This
I was building a rich text editor where users could insert dynamic content (like mentions or tags) and the cursor would jump to weird places. Sometimes it disappeared completely.
My setup:
- Building a collaborative writing tool
- Users needed to insert formatted content programmatically
- Had to work across Chrome, Firefox, and Safari
- Mobile support was essential
What didn't work:
element.focus()alone - cursor appears but at wrong positionsetSelectionRange()- doesn't work on contenteditable divs- Most Stack Overflow solutions - only handle simple text cases
The Complete Solution
The problem: ContentEditable elements use the Selection API, not the simpler input methods
My solution: Use Range objects to precisely control cursor placement
Time this saves: Hours of debugging cursor behavior across browsers
Step 1: Create the Cursor Positioning Function
This handles the core cursor positioning logic:
// Main function to set cursor position in contenteditable elements
function setCursorPosition(element, position, fromEnd = false) {
// Ensure element is focusable
if (!element.isContentEditable) {
console.warn('Element must be contenteditable');
return false;
}
// Get text content for position calculation
const textContent = element.textContent || element.innerText || '';
const maxPosition = textContent.length;
// Calculate actual position
let targetPosition;
if (fromEnd) {
targetPosition = Math.max(0, maxPosition - position);
} else {
targetPosition = Math.min(position, maxPosition);
}
try {
// Create range and selection
const range = document.createRange();
const selection = window.getSelection();
// Find the text node and position
const result = findTextNodeAndOffset(element, targetPosition);
if (result.node) {
range.setStart(result.node, result.offset);
range.setEnd(result.node, result.offset);
// Clear existing selection and apply new range
selection.removeAllRanges();
selection.addRange(range);
// Focus the element
element.focus();
return true;
}
return false;
} catch (error) {
console.error('Error setting cursor position:', error);
return false;
}
}
What this does: Creates a precise cursor position using the browser's Range API instead of fighting with element.focus()
Expected output: Cursor appears exactly where you want it, even in complex HTML
Personal tip: "Always check isContentEditable first - I wasted an hour debugging before realizing the element wasn't editable"
Step 2: Handle Complex HTML Content
The tricky part is finding the right text node when your contenteditable contains HTML:
// Helper function to traverse DOM and find text node at specific position
function findTextNodeAndOffset(element, targetPosition) {
let currentPosition = 0;
function traverse(node) {
// If it's a text node, check if target position falls within it
if (node.nodeType === Node.TEXT_NODE) {
const textLength = node.textContent.length;
if (currentPosition + textLength >= targetPosition) {
// Found the target text node
const offset = targetPosition - currentPosition;
return { node: node, offset: offset };
}
currentPosition += textLength;
} else {
// Recursively check child nodes
for (let child of node.childNodes) {
const result = traverse(child);
if (result.node) {
return result;
}
}
}
return { node: null, offset: 0 };
}
return traverse(element);
}
What this does: Walks through all text nodes to find exactly where your position falls, even inside HTML tags
Expected output: Works perfectly with content like <p>Hello <strong>world</strong>!</p>
Personal tip: "Text position 7 in 'Hello world!' falls inside the <strong> tag - this function handles that automatically"
Step 3: Add Practical Helper Methods
These cover the most common use cases I needed:
// Set cursor at the very end of content
function setCursorAtEnd(element) {
return setCursorPosition(element, element.textContent.length);
}
// Set cursor at the beginning
function setCursorAtStart(element) {
return setCursorPosition(element, 0);
}
// Set cursor X characters from the end
function setCursorFromEnd(element, offsetFromEnd) {
return setCursorPosition(element, offsetFromEnd, true);
}
// Get current cursor position (useful for debugging)
function getCurrentCursorPosition(element) {
const selection = window.getSelection();
if (selection.rangeCount === 0) {
return -1;
}
const range = selection.getRangeAt(0);
const preSelectionRange = range.cloneRange();
preSelectionRange.selectNodeContents(element);
preSelectionRange.setEnd(range.startContainer, range.startOffset);
return preSelectionRange.toString().length;
}
What this does: Covers 90% of real-world cursor positioning needs with simple function calls
Expected output: Cursor goes exactly where the function name suggests
Personal tip: "I use getCurrentCursorPosition() constantly during development to verify my positioning logic"
Step 4: Handle Edge Cases and Mobile
Real-world usage requires handling these scenarios:
// Enhanced version with error handling and mobile support
function setCursorPositionRobust(element, position, fromEnd = false) {
// Validate inputs
if (!element || !element.isContentEditable) {
console.warn('Invalid contenteditable element');
return false;
}
// Handle empty content
if (!element.textContent && !element.innerHTML) {
element.focus();
return true;
}
const textContent = element.textContent || '';
const maxPosition = textContent.length;
// Clamp position to valid range
let targetPosition;
if (fromEnd) {
targetPosition = Math.max(0, maxPosition - Math.abs(position));
} else {
targetPosition = Math.max(0, Math.min(position, maxPosition));
}
try {
const range = document.createRange();
const selection = window.getSelection();
// Find text node and offset
const result = findTextNodeAndOffset(element, targetPosition);
if (result.node) {
range.setStart(result.node, result.offset);
range.collapse(true); // Collapse to cursor position
selection.removeAllRanges();
selection.addRange(range);
// Ensure element is focused (important for mobile)
element.focus();
// Force scroll cursor into view on mobile
if (isMobileDevice()) {
setTimeout(() => {
if (selection.rangeCount > 0) {
const rect = selection.getRangeAt(0).getBoundingClientRect();
if (rect.bottom > window.innerHeight) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}, 100);
}
return true;
}
// Fallback: just focus the element
element.focus();
return false;
} catch (error) {
console.error('Cursor positioning failed:', error);
element.focus(); // Fallback
return false;
}
}
// Simple mobile detection
function isMobileDevice() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
What this does: Handles empty content, invalid positions, and mobile viewport issues
Expected output: Works reliably even when users do unexpected things
Personal tip: "The mobile scroll fix saved my butt - virtual keyboards hide content and users lose track of their cursor"
Complete Working Example
Here's a full HTML page you can test immediately:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cursor Position Demo</title>
<style>
.editor {
border: 2px solid #ddd;
padding: 15px;
min-height: 100px;
margin: 10px 0;
border-radius: 5px;
font-family: Arial, sans-serif;
line-height: 1.5;
}
.editor:focus {
border-color: #007cba;
outline: none;
}
.controls {
margin: 10px 0;
}
button {
margin: 5px;
padding: 8px 15px;
background: #007cba;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
}
button:hover {
background: #005a87;
}
</style>
</head>
<body>
<h1>ContentEditable Cursor Position Demo</h1>
<div class="editor" contenteditable="true" id="editor">
Hello <strong>world</strong>! This is a <em>test</em> with some <u>formatted</u> content.
</div>
<div class="controls">
<button onclick="setCursorAtStart(document.getElementById('editor'))">
Start
</button>
<button onclick="setCursorAtEnd(document.getElementById('editor'))">
End
</button>
<button onclick="setCursorPosition(document.getElementById('editor'), 6)">
Position 6
</button>
<button onclick="setCursorFromEnd(document.getElementById('editor'), 10)">
10 from End
</button>
<button onclick="showCurrentPosition()">
Show Position
</button>
</div>
<div id="position-display"></div>
<script>
// Include all the functions from above steps here
[Your cursor positioning functions]
// Demo function to show current position
function showCurrentPosition() {
const editor = document.getElementById('editor');
const position = getCurrentCursorPosition(editor);
document.getElementById('position-display').textContent =
`Current cursor position: ${position}`;
}
</script>
</body>
</html>
What this does: Provides an interactive demo to test all cursor positioning scenarios
Expected output: Buttons that precisely move cursor to expected positions
Personal tip: "Test this on your phone - mobile cursor behavior is totally different and you'll catch issues early"
What You Just Built
A complete cursor positioning system that works with:
- Plain text content
- Complex HTML formatting
- Edge cases like empty content
- Mobile devices with virtual keyboards
- All major browsers
Key Takeaways (Save These)
- Range API is king: Don't fight with input methods, use
document.createRange()for contenteditable elements - Text nodes matter: HTML tags create separate nodes, so you need to traverse the DOM tree properly
- Mobile needs special handling: Virtual keyboards and viewport changes require different logic
- Always validate: Check
isContentEditableand handle empty content gracefully
Your Next Steps
Pick one:
- Beginner: Build a simple rich text editor using these cursor functions
- Intermediate: Add text selection methods (selecting ranges, not just cursor positioning)
- Advanced: Implement undo/redo functionality that preserves cursor position
Tools I Actually Use
- Chrome DevTools: Selection tab shows exactly what the browser thinks is selected
- Range API Documentation: MDN Web Docs - bookmark this
- Can I Use: Selection API support - check before using advanced features