Rich Input
A rich input field with keyword-based autocomplete and in-input highlighting powered by <datalist>, OpaqueRange, and the Custom Highlight API
#rich-input
A rich input field
<rich-input>with keyword-based autocomplete and in-input highlighting powered by<datalist>, the OpaqueRange API, and the CSS Custom Highlight API.
The <rich-input> component is a rich input field that acts like a standard <input type="text"> so users can type ordinary text or search terms, but enhances it with contextual autocomplete and in-input highlighting for structured keyword:value entries (such as label:"We Play House Recordings" year:2026 playlist:"WPH Classics").
#Features
- Standard Input Ergonomics: Acts and feels like a regular
<input type="text">with standard value access, selection ranges, and events. - Dual Contextual Autocomplete:
- Keywords: Typing at the start of a token (e.g. typing
a) suggests configured keywords likeartist:andstyle:. - Values: Typing within a keyword value (e.g.
label:"Korlabel:K) suggests matching options like"Keinemusik"and"Kranky".
- Keywords: Typing at the start of a token (e.g. typing
- Range-Based Positioning via OpaqueRange: Positions autocomplete dropdown popovers anchored to the start of the active
OpaqueRange(e.g. at the opening quotation mark of a value) usingrange.getBoundingClientRect(), rather than shifting with the cursor (with a hidden mirror-div fallback in unsupported browsers). - Native In-Input Highlighting via CSS Custom Highlight API: Highlights keyword values inside the
<input>control using standard CSS rules like::highlight(label)or::highlight(year)without brittle mirror-div overlays. - Declarative Configuration via
<datalist>: Configure keywords and options purely in HTML by nesting standard<datalist>elements with<option>tags inside<rich-input>. - Rich Option Markup: Embed custom HTML markup (such as logos, images, icons, and avatars) directly inside
<option>elements for rich, visual suggestion popovers. - Form Associated: Implements
static formAssociated = trueandElementInternalsto participate seamlessly in<form>submission,FormData, and form reset lifecycles. - Shadow Parts Theming (
::part): Full CSS customizability using::part(input),::part(control),::part(popover),::part(suggestion-item), etc. - Accessible (W3C Combobox Pattern): ARIA 1.2 compliant combobox with keyboard navigation (
ArrowUp,ArrowDown,Enter,Tab,Escape),aria-expanded, andaria-activedescendant. - Graceful Fallback: Automatically feature-detects browser capabilities. In browsers with no
OpaqueRangesupport but with CSS Custom Highlight API support (e.g. Safari 17.2+, Firefox 141+), it falls back to an adapted[contenteditable]element to provide in-input syntax highlighting via standard DOMRanges. In environments withoutOpaqueRange, dropdown popovers are positioned using a hidden mirror-div measurement fallback.
#Component Anatomy & Shadow Parts
The visual below illustrates the internal Shadow DOM elements, exposed CSS Shadow Parts (::part), and CSS Custom Highlight pseudo-elements (::highlight), showing how they relate to one another:
<rich-input>: The host custom element wrapping the control, datalists, and suggestions popover.::part(control): The outer input container enclosing the icon, input, and clear button.::part(icon): The default leading search magnifying glass SVG icon (fallback inslot="leading").::highlight(<keyword>): Target pseudo-element for styling keyword values via the CSS Custom Highlight API (e.g.::highlight(label),::highlight(year)).::highlight(rich-input-keyword): Target pseudo-element for styling keyword prefixes (e.g.label:,year:).::highlight(rich-input-invalid): Target pseudo-element for marking unrecognized keywords or invalid keyword values (not in datalist) with a squiggly underline.::part(clear-button): The clear button (visible when text is present).::part(popover): The autocomplete dropdown popover container anchored to the start of the active range viaOpaqueRange(or mirror-div fallback).::part(suggestions-header): The header bar at the top of the suggestions popover.::part(suggestions-list): The<ul>container holding autocomplete suggestion items.::part(suggestion-item): Each suggestion<li>row.::part(suggestion-item-active): The currently focused / hovered suggestion row.::part(suggestion-item-selected): The suggestion row matching the value currently echoed in the input.::part(suggestion-image): The circular logo, icon, or avatar image prepended to rich suggestions.::part(suggestion-keyword): The keyword label text inside a keyword suggestion.::part(suggestion-value): The value label text inside a value suggestion.
#Quick Start
#1. Installation
Install via npm:
npm install rich-input
Or import directly in your HTML/JavaScript bundle:
import 'rich-input';
Or via CDN:
<script type="module" src="https://esm.sh/rich-input"></script>
#2. Basic Usage
Nest <datalist> elements inside <rich-input> to configure keywords and autocomplete suggestions:
<rich-input placeholder="Search music catalog..."> <datalist id="label" label="Record Label"> <option value="Defected"></option> <option value="House"></option> <option value="Keinemusik"></option> <option value="Kranky"></option> <option value="Madhouse Records"></option> <option value="Ninja Tune"></option> <option value="Warp Records"></option> <option value="We Play House Recordings"></option> <option value="XL Recordings"></option> </datalist> <datalist id="year" label="Release Year" data-type="number"> <option value="2026"></option> <option value="2025"></option> <option value="2024"></option> </datalist> <datalist id="playlist" label="Playlist"> <option value="WPH Classics"></option> <option value="Late Night Grooves"></option> </datalist> </rich-input>
#Datalist Configuration
Configuration is defined by standard HTML <datalist> elements placed inside the <rich-input> element:
| Element / Attribute | Type | Description |
|---|---|---|
<datalist id="..."> |
string |
Required. The keyword identifier used in queries (e.g. id="artist" produces artist:). Case-insensitive. |
<datalist label="..."> |
string |
Human-readable label displayed in suggestion headers. Defaults to capitalized id. |
<datalist data-type="..."> |
string |
Optional data type ("string" or "number"). |
<option value="..."> |
string |
The suggested value. If the value contains spaces, quotes are automatically added when inserted (e.g. "We Play House Recordings"). |
<option label="..."> |
string |
Optional descriptive label shown alongside the value. |
<option> children |
Node |
Optional image (<img>) prepended to the suggested value. |
Datalists can be added, updated, or removed dynamically at runtime; <rich-input> observes changes via slotchange and MutationObserver.
#Rich Option Markup
<rich-input> supports rich HTML markup inside <option> elements. For example, for record labels or artists, you can prepend a logo image:
<rich-input placeholder="Search..."> <datalist id="label" label="Record Label"> <option value="Defected"> <img src="https://raw.githubusercontent.com/bramus/rich-input/main/assets/defected.jpg" height="50" width="50" alt="Defected Logo"> Defected </option> <option value="House"> <img src="https://raw.githubusercontent.com/bramus/rich-input/main/assets/house.jpg" height="50" width="50" alt="House Logo"> House </option> <option value="Keinemusik"> <img src="https://raw.githubusercontent.com/bramus/rich-input/main/assets/keinemusik.jpg" height="50" width="50" alt="Keinemusik Logo"> Keinemusik </option> <option value="Kranky"> <img src="https://raw.githubusercontent.com/bramus/rich-input/main/assets/kranky.jpg" height="50" width="50" alt="Kranky Logo"> Kranky </option> <option value="Madhouse Records"> <img src="https://raw.githubusercontent.com/bramus/rich-input/main/assets/madhouse-records.jpg" height="50" width="50" alt="Madhouse Records Logo"> Madhouse Records </option> <option value="Ninja Tune"> <img src="https://raw.githubusercontent.com/bramus/rich-input/main/assets/ninja-tune.jpg" height="50" width="50" alt="Ninja Tune Logo"> Ninja Tune </option> <option value="Warp Records"> <img src="https://raw.githubusercontent.com/bramus/rich-input/main/assets/warp-records.png" height="50" width="50" alt="Warp Records Logo"> Warp Records </option> <option value="We Play House Recordings"> <img src="https://raw.githubusercontent.com/bramus/rich-input/main/assets/we-play-house-recordings.jpg" height="50" width="50" alt="We Play House Recordings Logo"> We Play House Recordings </option> <option value="XL Recordings"> <img src="https://raw.githubusercontent.com/bramus/rich-input/main/assets/xl-recordings.jpg" height="50" width="50" alt="XL Recordings Logo"> XL Recordings </option> </datalist> </rich-input>
When suggesting values for label:, <rich-input> sniffs the image inside the <option>, renders it alongside the option's text content, and exposes ::part(suggestion-image) for external CSS styling (e.g. as a 1em circular icon):
rich-input::part(suggestion-image) { width: 1em; height: 1em; border-radius: 50%; object-fit: cover; }
#Custom Leading Icon & Slots
<rich-input> provides named slots to customize elements inside the control:
slot="leading": Replace the leading icon. The default magnifying glass SVG is provided as fallback content inside the slot, so passing a custom element intoslot="leading"automatically replaces it without needing CSS overrides.slot="trailing": Add controls or elements after the clear button (e.g. submit button, voice input, keyboard shortcut badge).
#Passing a Custom Leading Icon
Provide your own SVG or image with slot="leading":
<rich-input placeholder="Search music catalog..."> <!-- Custom leading music icon --> <svg slot="leading" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M9 18V5l12-2v13"></path> <circle cx="6" cy="18" r="3"></circle> <circle cx="18" cy="16" r="3"></circle> </svg> <datalist id="genre" label="Genre"> <option value="House"></option> <option value="Techno"></option> </datalist> </rich-input>
When an element with slot="leading" is supplied, the default search magnifying glass icon is automatically suppressed. If no slotted element is provided, the default magnifying glass icon renders as fallback.
#Available Slots
| Slot Name | Description |
|---|---|
leading |
Custom leading icon or content. Defaults to the search magnifying glass icon (::part(icon)). |
trailing |
Custom content rendered after the clear button. |
| (default) | Unnamed slot where <datalist> configuration elements are placed (visually hidden). |
#The OpaqueRange API
The OpaqueRange API is a web platform standard introduced in Chromium 152 (Google Chrome, Microsoft Edge) that enables range-based operations over the text content of form controls (<input> and <textarea>).
Before OpaqueRange, web authors had to clone form controls into hidden <div>s to measure caret coordinates or apply highlights. OpaqueRange provides native access:
// Measure exact caret coordinates inside <input> const range = input.createValueRange(caretPos, caretPos); const rect = range.getBoundingClientRect(); // Anchor autocomplete popover at caret popover.style.left = `${rect.left}px`; popover.style.top = `${rect.bottom + 4}px`; // Highlight syntax directly inside <input> const valueRange = input.createValueRange(valStart, valEnd); const highlight = new Highlight(valueRange); CSS.highlights.set('label', highlight);
<rich-input> automatically checks typeof HTMLInputElement.prototype.createValueRange === 'function'. On supported browsers (Chromium 152+), caret tracking and ::highlight() are applied natively via OpaqueRange.
In browsers without OpaqueRange that support the CSS Custom Highlight API (such as Safari 17.2+ and Firefox 141+), <rich-input> automatically falls back to an adapted single-line [contenteditable] element inside its shadow DOM. This exposes standard DOM Text nodes so standard Range objects can be created and passed to CSS.highlights, delivering on-the-fly ::highlight() syntax highlighting across browsers. In environments lacking range positioning, popover dropdowns are positioned using a hidden mirror <div> text measurement fallback.
#Styling Highlights with the CSS Custom Highlight API
Values corresponding to configured keywords are registered into the global CSS.highlights registry and styled using standard CSS ::highlight(keyword) pseudo-elements in your stylesheet:
/* Style the value set in label:"Warp Records" */ ::highlight(label) { background-color: oklch(0.92 0.08 240); color: oklch(0.28 0.14 240); text-decoration: 2px underline solid oklch(0.5 0.15 240 / 0.5); } /* Style numeric year values like year:2026 */ ::highlight(year) { background-color: oklch(0.93 0.1 85); color: oklch(0.35 0.14 85); } /* Style artist names */ ::highlight(artist) { background-color: oklch(0.92 0.1 320); color: oklch(0.32 0.14 320); } /* Generic prefix highlight for keyword labels (e.g. "label:", "year:") */ ::highlight(rich-input-keyword) { color: #64748b; text-shadow: 0 0 1px rgba(0, 0, 0, 0.15); } /* Invalid highlight (squiggly underline for unrecognized keywords or values not present in datalist) */ ::highlight(rich-input-invalid) { text-decoration: underline wavy #ef4444; text-decoration-skip-ink: none; }
In browsers using the [contenteditable] fallback inside Shadow DOM (such as Safari and Firefox), <rich-input> automatically syncs document-level ::highlight() rules into its shadow stylesheet so that highlighting works across shadow boundaries without extra markup.
For self-contained widgets or instance-specific style overrides, <rich-input> also supports an optional embedded <style> block as a direct child, which is automatically injected into the shadow root:
<rich-input value='artist:"Aphex Twin" label:"Warp Records"'> <style> ::highlight(label) { background-color: #dbeafe; color: #1e40af; } </style> <datalist id="label" label="Record Label">...</datalist> </rich-input>
Note: Supported CSS properties on
::highlight()includecolor,background-color,text-decoration,text-shadow,-webkit-text-stroke-color,-webkit-text-stroke-width, and-webkit-text-fill-color.
#Styling the Input with Shadow Parts (::part)
Every internal element of <rich-input> is exposed via ::part():
/* Style the outer control container */ rich-input::part(control) { border-radius: 9999px; border: 2px solid #2563eb; padding: 0 1.25rem; background: #ffffff; } /* Style the native text input */ rich-input::part(input) { font-family: 'JetBrains Mono', monospace; font-size: 1rem; } /* Style the suggestions popover */ rich-input::part(popover) { border-radius: 12px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15); } /* Style active suggestion item */ rich-input::part(suggestion-item-active) { background-color: #dbeafe; }
#Available Shadow Parts
| Part Name | Description |
|---|---|
::part(control) |
The wrapper container enclosing the search icon, input, and clear button |
::part(input) |
The internal native <input type="text"> |
::part(icon) |
The default leading search icon SVG (fallback in slot="leading") |
::part(clear-button) |
The clear button (visible when text is present) |
::part(popover) |
The autocomplete popover container |
::part(suggestions-header) |
The header bar at the top of the popover |
::part(suggestions-list) |
The <ul> list element |
::part(suggestion-item) |
Each suggestion <li> item |
::part(suggestion-item-active) |
The currently focused / hovered suggestion item |
::part(suggestion-item-selected) |
The suggestion item matching the value currently echoed in the input |
::part(suggestion-keyword) |
Keyword name element in suggestion items |
::part(suggestion-value) |
Value element in suggestion items |
::part(suggestion-content) |
The content container inside each suggestion item |
::part(suggestion-image) |
Image or icon element rendered inside rich suggestion items |
#JavaScript API
#Properties
value(string): Gets or sets the search input value. Updates highlights and form value automatically.placeholder(string): Gets or sets the input placeholder text.disabled(boolean): Disables or enables the input control.name(string): Form field name when submitted inside a<form>.selectionStart/selectionEnd(number): Text selection / cursor indices.
#Methods
getParsedQuery(): Returns a parsed object representing the search query:{ "raw": "label:\"We Play House Recordings\" year:2026 chicago house", "text": "chicago house", "keywords": { "label": ["We Play House Recordings"], "year": ["2026"] }, "tokens": [...] }
getKeywords(): Returns an array of configured keyword definitions from the datalists.focus(options): Focuses the internal input.blur(): Removes focus from the internal input.select(): Selects all text inside the input.setSelectionRange(start, end, direction): Sets caret or selection range.
#Events
input: Dispatched when the search value changes (bubbles, composed).change: Dispatched on blur or when a search change is committed.search: Dispatched when the user pressesEnterwith suggestions closed.rich-input-select: Dispatched when an autocomplete suggestion is selected.event.detail:{ type, keyword, value, label, query }
#Form Integration
<rich-input> supports native <form> submission through standard ElementInternals:
<form id="search-form" action="/search" method="GET"> <rich-input name="q" placeholder="Search tracks..."> <datalist id="genre" label="Genre"> <option value="House"></option> <option value="Techno"></option> </datalist> </rich-input> <button type="submit">Search</button> </form> <script> const form = document.getElementById('search-form'); form.addEventListener('submit', (e) => { e.preventDefault(); const data = new FormData(form); console.log('Submitted query:', data.get('q')); }); </script>
#Building from Source
# Build ./dist package npm run build # Start local demo server npm start