Skip to content

hardcoded focus outline, caret, and scrollbar colors are not theme-aware or overridable #5

Description

@HarshitRV

Summary

HighlightableTextInput ships three hardcoded style values that ignore the host
app's theme and are effectively impossible to override cleanly:

  • a blue focus outline (outline: 2px solid #00f) on the editable layer,
  • a black caret (caret-color: #000), invisible on dark backgrounds,
  • light-only scrollbar colors (#f1f1f1 / #888) on the shadow layer.

Because the component's stylesheet is injected unlayered, these rules win
over any consumer CSS placed in a cascade layer (e.g. Tailwind v4's @layer),
so the "obvious" override silently fails.

Environment

  • Package: react-highlightable-input@2.0.1
  • Framework: React 19 + Vite + Tailwind CSS v4 (also reproducible without Tailwind)
  • Browsers: all (rules are static CSS)

Current behavior

When the field is focused, a 2px solid blue box is drawn around the editable
area. When the component is wrapped (the documented styling approach), this
inner outline sits inside the wrapper's own focus ring and looks broken:

Image

On dark backgrounds the caret is invisible (black on dark), and the shadow
layer's scrollbar renders with light-gray colors regardless of theme.

Expected behavior

  • Focused fields should show an accessible focus indicator (WCAG 2.4.7),
    but one that respects the host theme and can be disabled/overridden without
    specificity or !important hacks.
  • The caret should be visible in both light and dark themes.
  • The scrollbar should follow the theme (or at least color-scheme).

Root cause

From the published build (dist/assets/HighlightableTextInput.css):

._container_4olre_1 { width: calc(100% - 2px); min-height: 44px }
._inputDiv_4olre_6 { caret-color: #000; color: #0000 !important }
._inputDiv_4olre_6 * { color: #0000 !important }
._inputDiv_4olre_6, ._shadowDiv_4olre_16 {
  width: 100%; height: 100%; position: absolute; top: 0; left: 0; overflow-y: scroll
}
._inputDiv_4olre_6:focus-visible { outline-offset: 2px; outline: 2px solid #00f }
._shadowDiv_4olre_16::-webkit-scrollbar-track { background: #f1f1f1 }
._shadowDiv_4olre_16::-webkit-scrollbar-thumb { background-color: #888; border: 3px solid #f1f1f1; border-radius: 20px }
._shadowDiv_4olre_16 { scrollbar-width: thin; scrollbar-color: #888 #f1f1f1 }

Source: lib/components/HighlightableTextInput/HighlightableTextInput.module.scss.

Two problems compound:

  1. Hardcoded values. #00f, #000, #f1f1f1, #888 are literal colors
    with no theming hook.
  2. Unlayered injection beats layered overrides. The CSS is injected via
    vite-plugin-lib-inject-css as a plain (unlayered) stylesheet. In the CSS
    cascade, any unlayered rule beats every rule inside a named @layer,
    regardless of specificity.
    So a consumer override written inside
    @layer components { ... } (the natural place in a Tailwind v4 app) never
    applies. The only workarounds today are unlayered overrides that out-specify
    the module selector, or !important.

Impact

  • Looks broken in any app with its own focus styling (very common), especially
    when following the README's recommended "wrap the component" pattern.
  • Invisible caret in dark mode = real usability bug.
  • Hard to fix from the outside because of the unlayered cascade issue.

Reproduction

  1. Add HighlightableTextInput to a Vite + Tailwind v4 app.
  2. Wrap it in a div with a border + focus-within:ring (as the README shows).
  3. Focus the field -> a blue 2px outline appears inside the wrapper's ring.
  4. Try to remove it with a rule inside @layer components -> it has no effect.
  5. Switch the app to dark mode -> the caret is invisible.

Proposed fix

Keep accessible defaults, but route every hardcoded value through a CSS custom
property so consumers can theme or disable it without specificity fights.

// HighlightableTextInput.module.scss
.inputDiv {
  caret-color: var(--rhi-caret-color, currentColor);
}

.inputDiv:focus-visible {
  // System accent adapts to OS/theme; fully overridable, incl. `none`.
  outline: var(--rhi-focus-outline, 2px solid Highlight);
  outline-offset: 2px;
}

.shadowDiv {
  scrollbar-width: thin;
  scrollbar-color: var(--rhi-scrollbar-thumb, currentColor)
                   var(--rhi-scrollbar-track, transparent);
}
.shadowDiv::-webkit-scrollbar-thumb {
  background-color: var(--rhi-scrollbar-thumb, currentColor);
}
.shadowDiv::-webkit-scrollbar-track {
  background: var(--rhi-scrollbar-track, transparent);
}

Notes:

  • caret-color: currentColor won't work directly because .inputDiv text is
    transparent; set the caret from a variable (default to a readable value or
    inherit from a non-transparent ancestor) so it stays visible.
  • Consider emitting the component CSS into a documented @layer (or
    publishing an unstyled entry) so consumer overrides compose predictably.
    Alternatively, keep it unlayered but document that overrides must also be
    unlayered.
  • Optionally expose an escape hatch prop (e.g. showFocusOutline?: boolean),
    though CSS variables cover the need and also handle caret/scrollbar.

Consumer usage after the fix

/* wrap the component and let the wrapper own the focus ring */
--rhi-focus-outline: none;
--rhi-caret-color: var(--foreground);

Acceptance criteria

  • Focus outline color is themeable via a CSS variable and can be set to
    none without !important.
  • Caret is visible in light and dark themes.
  • Scrollbar respects theme / color-scheme.
  • Documented guidance on overriding (including the unlayered-vs-@layer
    caveat) added to the README.
  • Changelog entry + version bump.

Workaround (until fixed)

Place overrides outside any @layer, scoped to the container id, so they
win on specificity:

#highlightableTextInput-container [contenteditable] {
  caret-color: var(--foreground);
}
#highlightableTextInput-container [contenteditable]:focus-visible {
  outline: none;
}
#highlightableTextInput-container [aria-hidden="true"] {
  scrollbar-width: none;
}
#highlightableTextInput-container [aria-hidden="true"]::-webkit-scrollbar {
  display: none;
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions