HarshitRV / HarshitRV/react-highlightable-input

caret drifts out of alignment when a highlight style changes the match's width

Open
#4 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
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:

Image

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 from highlightedContent once, 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
    via dangerouslySetInnerHTML. 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 (or font-weight, etc.) to
    highlightText is what surfaces it.
  • Underlying package limitation: the two-layer architecture cannot support
    width-changing highlight styles, and this constraint is undocumented and
    unguarded. highlightText accepts an arbitrary React.CSSProperties,
    which invites exactly the styles that break it.

Reproduction

  1. Render HighlightableTextInput wired with:
    highlightText(value, /#\w+/g, {
      color: "#6d5efc",
      backgroundColor: "rgba(109,94,252,0.15)",
      padding: "0 2px", // <-- width-changing; triggers the bug
    })
    
  2. Type some #adabple works.
  3. Watch the caret overlap the current character after the styled #adabple.

Proposed fix

Short term (docs + guardrails), in order of preference:

  1. Document the constraint in the README and highlightText JSDoc: 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).
  2. Offer a metric-safe recipe for the common "padded pill" look using
    box-shadow spread instead of padding (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
    });
    
  3. (Optional) Dev-time warning: detect width-changing keys in the style
    object passed to highlightText and console.warn in 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 + highlightText JSDoc 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.