HarshitRV / HarshitRV/react-highlightable-input
caret drifts out of alignment when a highlight style changes the match's width
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 1
- Forks
- 0
- PR merge metrics
- No merged PRs in 30d
Description
Summary
The caret and the visible text get out of sync whenever a highlight style
changes the horizontal advance width of a matched token - e.g.
padding, border, margin, letter-spacing, or font-weight. Each styled
match makes the rendered (shadow) text wider than the raw (editable) text, so
the caret progressively overlaps the characters you type after it.
Color and background-color alone are safe (they don't change width); this is
why @mention highlighting looks fine but a #tag with padding does not.
Environment
- Package:
react-highlightable-input@2.0.1 - React 19 + Vite (reproducible in any setup)
Current behavior
Highlighting #tags with a small horizontal padding on the span causes the
caret to sit on top of / ahead of the character being typed:
The drift accumulates: the more styled matches precede the caret, the further it
is displaced.
Expected behavior
The caret should stay aligned with the visible glyphs regardless of the highlight
style applied - or, at minimum, the constraint should be documented and enforced
so consumers don't hit this by accident.
Root cause
The component overlays two layers (lib/components/HighlightableTextInput/HighlightableTextInput.tsx):
-
Editable layer (
inputRef,contentEditable) - holds the raw text the
browser inserts as you type. Its text is transparent; the caret lives here.
It is only seeded fromhighlightedContentonce, on mount:useEffect(() => { if (inputRef.current && highlightedContent) { inputRef.current.innerHTML = highlightedContent; } // Seed once on mount; re-running would overwrite user edits. }, []); -
Shadow layer (
shadowRef,aria-hidden) - renders the highlighted HTML
viadangerouslySetInnerHTML. This is where the styled<span>s appear.
The two layers are overlaid pixel-for-pixel and rely on their text having
identical metrics. But the editable layer always contains unstyled text
(plain characters, no spans), while the shadow layer contains styled spans. Any
style that alters a match's width (padding/border/margin/letter-spacing/
font-weight/font-size/font-family) widens the shadow text only, breaking the
1:1 alignment the caret depends on.
highlightMentions (color only) and highlightText with just color /
background-color are metric-neutral, so they stay aligned. The bug appears as
soon as a width-changing property is used.
Is it a package bug or a styling bug?
Both, really:
- Styling trigger: passing
padding(orfont-weight, etc.) to
highlightTextis what surfaces it. - Underlying package limitation: the two-layer architecture cannot support
width-changing highlight styles, and this constraint is undocumented and
unguarded.highlightTextaccepts an arbitraryReact.CSSProperties,
which invites exactly the styles that break it.
Reproduction
- Render
HighlightableTextInputwired with:highlightText(value, /#\w+/g, { color: "#6d5efc", backgroundColor: "rgba(109,94,252,0.15)", padding: "0 2px", // <-- width-changing; triggers the bug }) - Type
some #adabple works. - Watch the caret overlap the current character after the styled
#adabple.
Proposed fix
Short term (docs + guardrails), in order of preference:
- Document the constraint in the README and
highlightTextJSDoc: only
metric-neutral styles are safe (color,background-color,text-decoration,
box-shadow,border-radius); avoid anything that changes advance width
(padding,border,margin,letter-spacing,font-weight,font-size,
font-family). - Offer a metric-safe recipe for the common "padded pill" look using
box-shadowspread instead ofpadding(no layout impact):highlightText(value, /#\w+/g, { color: "var(--accent)", backgroundColor: "var(--accent-soft)", borderRadius: "4px", boxShadow: "0 0 0 2px var(--accent-soft)", // fakes padding, keeps width }); - (Optional) Dev-time warning: detect width-changing keys in the style
object passed tohighlightTextandconsole.warnin development.
Long term (real fix): make the editable layer share the same box metrics as
the shadow layer - e.g. mirror the highlighted HTML into the editable layer with
text rendered transparent (the existing .inputDiv * { color: transparent }
rule already hides it), so padding/border apply to both layers identically. This
requires writing to contentEditable on input and restoring the caret
position after each update (selection save/restore via Range/Selection),
which is the classic hard part - but it's the only way to support arbitrary
styles without drift.
Acceptance criteria
- README +
highlightTextJSDoc document which CSS properties are safe. - A
box-shadow-based "padded" example is provided. - (Optional) Dev warning when width-changing style props are passed.
- (Stretch) Editable/shadow layers share metrics so any style stays aligned.
Workaround (used in the demo)
Use metric-neutral styles only. To get a padded look, replace padding with a
box-shadow spread and swap font-weight: bold for text-decoration: underline:
highlightText(value, /#\w+/g, {
color: "var(--rhi-highlight)",
backgroundColor: "var(--rhi-highlight-soft)",
borderRadius: "4px",
boxShadow: "0 0 0 2px var(--rhi-highlight-soft)",
// textDecoration: "underline", // safe emphasis (bold changes width)
});
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with lib/components/HighlightableTextInput/HighlightableTextInput.tsx and the README, then inspect the highlightText API and its JSDoc. Reproduce the drift with a width-changing style, and use the acceptance criteria to distinguish the documented metric-safe workaround from the optional warning and stretch alignment work.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100