Deep Dive · React · Contenteditable
Building a Highlighting Input with contenteditable
A focused look at how two overlapping divs, a handful of regexes, and react-contenteditable combine to give users inline highlighting of mentions, hashtags, and emails - without shipping a full rich-text editor.
Most “highlighting input” tutorials reach immediately for ProseMirror, Slate, or Lexical. Those are magnificent tools - and complete overkill when the requirement is:
let users type plain text and highlight tokens like @mentions
,#hashtags, and email addresses.
This is not a rich-text editor. There are no formatting toolbars, no custom document schemas, no embedded blocks. The only magic is that certain patterns light up with color as you type. The rest is regular text.
The implementation behind this is surprisingly simple once you understand the core trick: two overlapping divs. One captures raw input, one renders highlighted output - and a bit of CSS keeps them perfectly in sync.
import "./styles.css"; import React, { useMemo, useRef, useState } from "react"; import ContentEditable from "react-contenteditable"; const REGEX_MENTIONS = /@[^\s]+/g; const REGEX_HASHTAGS = /#[^\s]+/g; const REGEX_EMAILS = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi; function applyHighlights(text) { return text .replace(REGEX_EMAILS, (r) => `<span class="hl-email">${r}</span>`) .replace(REGEX_MENTIONS, (r) => `<span class="hl-mention">${r}</span>`) .replace(REGEX_HASHTAGS, (r) => `<span class="hl-hashtag">${r}</span>`); } export default function App() { const [text, setText] = useState("Hey @developer, check out #frontend - email me at hello@example.com!"); const highlightRef = useRef(null); const highlightedHtml = useMemo(() => applyHighlights(text), [text]); return ( <div className="demo-preview"> <div> <div className="demo-preview-label">Try it - type @mention, #hashtag, or email@example.com</div> <div className="editor-parent"> <ContentEditable className="highlight-layer" ref={highlightRef} disabled={true} html={highlightedHtml} /> <ContentEditable className="editor-layer" onChange={(e) => setText(e.target.value)} html={text} /> </div> </div> <div className="token-legend"> <div className="legend-item"><span className="legend-swatch swatch-mention" />@mention</div> <div className="legend-item"><span className="legend-swatch swatch-hashtag" />#hashtag</div> <div className="legend-item"><span className="legend-swatch swatch-email" />email</div> </div> </div> ); }
The Two-Layer Architecture
The entire technique rests on a single insight: separate where text is typed from where it is displayed. Both layers occupy the exact same bounding box. Both use identical font, size, line-height, and padding so that characters line up pixel-perfectly.
The bottom layer is a disabled (read-only)
ContentEditable that receives processed HTML where matched tokens
are wrapped in colored <span> elements. It is purely visual.
The top layer sits directly over it with
position: absolute, an invisible color: transparent,
and a visible caret-color. The user types here, sees the caret,
but the actual colored highlights show through from the layer below.
The critical constraint: every CSS property that affects text
layout - font-size, font-family, line-height,
padding, white-space, word-break -
must be identical on both layers. A single pixel of drift and the
highlights misalign.
Token Detection with Regex
Three regex patterns cover the token types this input recognizes. They run on the plain-text value, not on DOM nodes, which keeps the logic simple and testable.
const REGEX_MENTIONS = /@[^\s]+/g;
const REGEX*HASHTAGS = /#[^\s]+/g;
const REGEX_EMAILS = /\b[A-Z0-9.*%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
function applyHighlights(text) {
// Run emails first - they contain @ which would match the mention pattern
return text
.replace(REGEX_EMAILS,
(r) => `<span class="hl-email">${r}</span>`)
.replace(REGEX_MENTIONS,
(r) => `<span class="hl-mention">${r}</span>`)
.replace(REGEX_HASHTAGS,
(r) => `<span class="hl-hashtag">${r}</span>`);
}Read-onlyWhy react-contenteditable?
The obvious question is: why not just use a textarea?
A textarea is fine for plain text, but it cannot highlight parts of the text inline. In this case, I needed @mentions, #hashtags, and emails to visually light up while the user was typing. That is the main reason I moved to a div with contenteditable.
contenteditable made that possible, and react-contenteditable made it easier to use that editable div inside React. The value of the library is mostly in the React integration: wiring changes back to state, passing content in again, and avoiding some low-level DOM handling yourself.
What it does not do is solve editor behavior for you. You still have to handle caret position, paste behavior, IME input, and browser differences yourself.
The overlay pattern helps here because it keeps the editable layer simple. Typing happens in one layer, while the styled output is rendered in another layer that you fully control.
The short version: textarea was too limited for inline highlighting, contenteditable gave the visual flexibility, and react-contenteditable made the React side easier to manage.
The Full Rendering Flow
Putting it all together, here is what happens from a keystroke to a colored token appearing on screen:
- User types a character into the editor layer (top div).
onChangefires; the raw text value is stored in React state.useMemore-runsapplyHighlightson the new text.- Matched tokens are replaced with
<span class=“hl-*”>elements in the output string. - The processed HTML is set on the highlight layer (bottom div) via the
htmlprop. - The bottom layer re-renders with colored spans; the transparent top layer keeps showing the user’s caret.
Security note: the highlight layer renders innerHTML
directly. In this architecture the application generates the HTML (not the user),
so it is safe - but if you ever pass user input into the highlighted HTML string
without escaping first, you open an XSS vector. Always escape user-provided text
before wrapping it in span tags.
Limitations & When to Reach for an Editor
This pattern covers a specific, bounded use case well. Knowing its limits prevents it from being stretched into the wrong shape.
Pixel-perfect alignment is fragile. Any difference in font rendering across OS / browser combinations can cause the highlight layer to drift. Test on Windows (GDI vs DirectWrite) and macOS carefully.
Regex tokenization is shallow. It handles simple surface patterns but not context-sensitive ones (e.g., valid vs invalid usernames that require a server lookup for autocomplete).
Not suitable for document editors. As soon as users need bold, italics, lists, or embedded images, reach for Lexical or ProseMirror.
Accessibility caveats. Screen readers interact with
contenteditableinconsistently. Addrole=“textbox”,aria-multiline=“true”, andaria-label. The highlight layer should bearia-hidden=“true”.