We needed a rich text editor for our cross-platform note-taking app. The web version used TipTap, and we wanted the mobile version to feel native while keeping content perfectly synced. After trying every React Native editor library out there, we built our own. Here's why and how.
The Problem
When we started building Typelets' mobile app, we assumed we'd just plug in an existing rich text editor library. We were wrong.
What we tried:
-
react-native-pell-rich-editor- Laggy, inconsistent formatting -
react-native-cn-quill- Heavy bundle, sync issues with TipTap HTML -
@10play/tentap-editor- Promising, but we needed more control over the HTML output - Various markdown editors - Not the UX we wanted
The core issues:
- Performance - Most editors felt sluggish, especially with longer documents
- HTML Compatibility - Output didn't match TipTap's structure, causing sync nightmares
- Customization - We needed specific behaviors (task lists, code blocks) that were hard to customize
We needed an editor that:
- Felt native on mobile
- Produced HTML identical to TipTap's output
- Gave us full control over keyboard behavior
- Handled task lists, code blocks, and nested lists correctly
The Architecture Decision
Instead of fighting with libraries, we built a WebView bridge pattern:
┌─────────────────────────────────────────────────┐
│ React Native Layer │
│ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Toolbar │ │ Editor Component │ │
│ │ (Native) │ │ (WebView Host) │ │
│ └─────────────┘ └─────────────────────────┘ │
│ │ │ │
│ │ postMessage() │ │
│ ▼ ▼ │
├─────────────────────────────────────────────────┤
│ WebView Layer │
│ ┌─────────────────────────────────────────┐ │
│ │ contenteditable div + JavaScript │ │
│ │ (Custom selection, formatting, │ │
│ │ keyboard handlers) │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
Why this works:
- Native toolbar = snappy UI
- WebView editor = full control over HTML
- Message passing = clean separation of concerns
- Same HTML format = perfect TipTap compatibility
The Implementation
1. The Bridge Protocol
Communication happens via JSON messages between React Native and the WebView:
// WebView → React Native
interface EditorMessage {
type: 'content' | 'formats' | 'height';
html?: string;
formats?: FormatState;
height?: number;
}
// React Native → WebView (via injectJavaScript)
window.execCommand('bold');
window.execCommand('heading', 2);
window.setContent('<p>Initial content</p>');
2. The Core Editor Component
Click to see the full Editor component
// Editor.tsx (simplified)
export const Editor = ({
initialContent,
onChange,
colors
}: EditorProps) => {
const webViewRef = useRef<WebView>(null);
const [formats, setFormats] = useState<FormatState>({});
const handleMessage = (event: WebViewMessageEvent) => {
const data = JSON.parse(event.nativeEvent.data);
switch (data.type) {
case 'content':
onChange?.(data.html);
break;
case 'formats':
setFormats(data.formats);
break;
case 'height':
// Adjust WebView height dynamically
break;
}
};
const execCommand = (command: string, value?: string) => {
webViewRef.current?.injectJavaScript(
`window.execCommand('${command}'${value ? `, '${value}'` : ''});`
);
};
return (
<>
<Toolbar formats={formats} onCommand={execCommand} />
<WebView
ref={webViewRef}
source={{ html: generateEditorHTML(initialContent, colors) }}
onMessage={handleMessage}
keyboardDisplayRequiresUserAction={false}
/>
</>
);
};
3. The WebView HTML (The Secret Sauce)
This is where the magic happens. We generate HTML that includes:
- A contenteditable div
- Custom keyboard handlers
- Selection tracking
- Format detection
Click to see the WebView JavaScript
// Inside the WebView
const editor = document.getElementById('editor');
// Track selection changes to update toolbar
document.addEventListener('selectionchange', () => {
const formats = {
bold: document.queryCommandState('bold'),
italic: document.queryCommandState('italic'),
underline: document.queryCommandState('underline'),
// Custom detection for block elements
heading: detectHeadingLevel(),
bulletList: isInsideElement('UL:not([data-type])'),
orderedList: isInsideElement('OL'),
taskList: isInsideElement('[data-type="taskList"]'),
codeBlock: isInsideElement('PRE'),
blockquote: isInsideElement('BLOCKQUOTE'),
};
window.ReactNativeWebView.postMessage(
JSON.stringify({ type: 'formats', formats })
);
});
// Content changes
editor.addEventListener('input', () => {
window.ReactNativeWebView.postMessage(
JSON.stringify({ type: 'content', html: editor.innerHTML })
);
});
4. Custom Keyboard Handling (The Hard Part)
The biggest challenge was making Enter and Backspace behave correctly in different contexts. TipTap has specific behaviors for lists, code blocks, and task items. We had to replicate them:
Click to see keyboard handling code
editor.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const context = getSelectionContext();
if (context.inCodeBlock) {
// Insert literal newline, don't create new paragraph
insertTextAtCursor('\n');
}
else if (context.inTaskList) {
// Create new task item with same structure
createNewTaskItem();
}
else if (context.inList) {
// Create new list item, or exit list if empty
if (context.isEmpty) {
exitList();
} else {
createNewListItem();
}
}
else {
// Normal paragraph
document.execCommand('insertParagraph');
}
}
});
5. TipTap-Compatible HTML Structure
This is crucial. TipTap expects specific HTML structures. If your mobile editor generates different HTML, you'll have sync issues.
Task Lists (TipTap format):
<ul data-type="taskList">
<li data-type="taskItem" data-checked="false">
<label>
<input type="checkbox">
<span></span>
</label>
<div><p>Task text here</p></div>
</li>
</ul>
List Items (TipTap wraps content in <p>):
<ul>
<li><p>Item one</p></li>
<li><p>Item two</p></li>
</ul>
Code Blocks:
<pre><code class="language-javascript">const x = 1;</code></pre>
We had to ensure every operation in our mobile editor produces this exact structure.
The Web Side (TipTap)
On web, we use TipTap with custom extensions:
// editor-config.ts
export const createEditorExtensions = () => [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
codeBlock: false, // We use custom code block
}),
TaskList,
TaskItem.configure({ nested: true }),
CodeBlockLowlight.configure({ lowlight }),
Underline,
Link,
Highlight,
// Custom extensions
ExecutableCodeBlock,
ResizableImage,
SlashCommands,
];
The key insight: both editors output the same HTML format. No conversion needed.
Syncing Content
Because both platforms produce identical HTML, syncing is trivial:
// Shared Note type
interface Note {
id: string;
title: string;
content: string; // HTML - same format everywhere
updatedAt: Date;
}
Challenges We Solved
WebViews have fixed height by default. We needed the editor to grow with content:1. Dynamic Height
// In WebView
const reportHeight = () => {
const height = document.documentElement.scrollHeight;
window.ReactNativeWebView.postMessage(
JSON.stringify({ type: 'height', height })
);
};
new MutationObserver(reportHeight).observe(editor, {
childList: true,
subtree: true,
characterData: true,
});
We pass theme colors from React Native into the WebView:2. Theme Support
const generateEditorHTML = (content: string, colors: EditorColors) => `
<style>
body {
background: ${colors.background};
color: ${colors.foreground};
}
blockquote {
border-left: 3px solid ${colors.blockquoteBorder};
}
</style>
<div id="editor" contenteditable="true">${content}</div>
`;
On iOS, the keyboard doesn't dismiss easily with WebView. We added a "Done" button:3. Keyboard Dismissal
webViewRef.current?.injectJavaScript('document.activeElement.blur();');
Keyboard.dismiss();
When switching between apps or screens, selection can be lost:4. Selection Preservation
let savedSelection = null;
window.saveSelection = () => {
const sel = window.getSelection();
if (sel.rangeCount > 0) {
savedSelection = sel.getRangeAt(0).cloneRange();
}
};
window.restoreSelection = () => {
if (savedSelection) {
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(savedSelection);
}
};
Performance Results
After switching to this architecture:
| Metric | Result |
|---|---|
| Input latency | ~16ms (feels native) |
| Content sync | Instant (same format) |
| Bundle size | Smaller than Quill-based solutions |
| Large documents | Handles 10k+ words smoothly |
Lessons Learned
Don't fight the platform - WebView for rich text is fine. It's what every rich text editor uses anyway.
HTML is a great interchange format - Instead of trying to use the same library everywhere, use the same data format.
Keyboard handling is 80% of the work - Getting Enter/Backspace right in different contexts took longer than everything else combined.
Test on real devices - Simulator behavior differs from real devices, especially for keyboard and selection.
Copy TipTap's HTML exactly - Read TipTap's source to understand exactly what HTML it produces. Match it.
Should You Do This?
Yes, if:
- You need perfect compatibility with a web editor
- You want full control over behavior
- Performance matters to your users
- You have complex formatting needs (task lists, code blocks, etc.)
No, if:
- Basic formatting is enough
- You can live with some HTML differences
- You don't have time to handle edge cases
Resources
react-native-webview on GitHub
Building a rich text editor is hard. Building one that works across platforms is harder. But with the right architecture, it's absolutely doable.
The key insight is that you don't need the same library everywhere - you need the same data format.
Happy coding!
We built this for Typelets, a cross-platform note-taking app. If you're working on something similar, feel free to reach out!

Top comments (0)