npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

one-more-highlight

v1.2.0

Published

Multi-state substring highlighting for React. Highlight every match in one style, plus specific occurrences (by index, range, or list) in another.

Readme

omh · one-more-highlight

Multi-state substring highlighting for React.

License: MIT npm version npm downloads CI Latest release types React

Highlight every occurrence of a substring in one style, and highlight specific occurrences — by single index, index range, or arbitrary list of indices — in another style. TypeScript-first, headless-friendly, ~2KB brotlied, zero CSS shipped.

Dedicated to Chester Bennington. Inspired by the idea that every small light matters.

"I tried so hard and got so far…" — we built this so the right words could shine.


Why this exists

one-more-highlight gives you:

  • TypeScript-first — full types and a discriminated-union HighlightState that narrows correctly on the selector field (index, range, indices, term, or term + nth).
  • Multi-state styling as the headline feature — every match gets a base style, plus layered styles selected by index, range, or arbitrary list. Styles compose.
  • Headless useHighlight hook alongside the <Highlight> component, with a renderMatch render-prop for full per-match control.
  • Tiny — ~2 KB brotlied (ESM), 2 microscopic deps (clsx + escape-string-regexp).
  • Modern — React 18+/19, ESM + CJS dual build with .d.ts + .d.cts, tree-shakeable, SSR-safe.
import { Highlight } from 'one-more-highlight';

<Highlight
  text="time time time time time"
  searchWords={['time']}
  highlightClassName="bg-yellow-200"
  states={[
    { name: 'active',     index: 2,         className: 'bg-orange-500 ring-2' },
    { name: 'preview',    range: [0, 1],    className: 'bg-blue-100' },
    { name: 'bookmarked', indices: [3, 4],  className: 'underline' },
  ]}
/>

A single match can be in multiple states at once; their classNames concatenate and their styles shallow-merge.

Install

pnpm add one-more-highlight
# or: npm i one-more-highlight / yarn add one-more-highlight

Peer: react >= 18. Runtime deps: clsx, escape-string-regexp (both MIT, ~400 B combined).

Usage

Component (drop-in)

import { Highlight } from 'one-more-highlight';

<Highlight text="hello world" searchWords={['world']} />
// → "hello <mark>world</mark>"

Headless hook (DIY rendering)

import { useHighlight } from 'one-more-highlight';

function MyHighlighter({ text, query }: { text: string; query: string }) {
  const { segments } = useHighlight({ text, searchWords: [query] });
  return (
    <p>
      {segments.map((s, i) =>
        s.isMatch ? <mark key={i}>{s.text}</mark> : <span key={i}>{s.text}</span>,
      )}
    </p>
  );
}

Multi-state styling (the headline feature)

import { Highlight } from 'one-more-highlight';

<Highlight
  text={longText}
  searchWords={['React']}
  highlightClassName="hl-base"
  states={[
    { name: 'active',     index: activeIdx,   className: 'hl-active' },
    { name: 'recent',     range: [0, 4],      className: 'hl-recent' },
    { name: 'bookmarked', indices: bookmarks, className: 'hl-bookmark' },
  ]}
/>

Every match gets hl-base. Match activeIdx also gets hl-active. Matches 0–4 also get hl-recent. Matches in bookmarks also get hl-bookmark. Classes concatenate, styles shallow-merge in declaration order.

Render-prop for full per-match control

<Highlight
  text={text}
  searchWords={['error']}
  states={[{ name: 'active', index: 2 }]}
  renderMatch={(seg, { className, style, Tag }) => (
    <Tag className={className} style={style}>
      {seg.text}
      {seg.states.includes('active') && <ActiveBadge />}
    </Tag>
  )}
/>

renderMatch receives the resolved className/style/Tag for the match. Return whatever React node you want — string, fragment, custom element, null (renders raw text).

API

<Highlight> props

| Prop | Type | Default | Description | | --- | --- | --- | --- | | text | string | required | The text to highlight inside. | | searchWords | Array<string \| RegExp> | required | Terms to find. RegExps are cloned with g flag forced on. | | caseSensitive | boolean | false | Match case (string terms only; regex flags are honored). | | autoEscape | boolean | true | Escape regex special chars in string terms. | | sanitize | (s: string) => string | — | Pre-process text and search source before matching (e.g. for diacritic-insensitive search). | | findChunks | (input) => RawChunk[] | — | Custom matcher; replaces the default. | | states | HighlightState[] | — | Per-match layered styling. See below. | | overlapStrategy | 'merge' \| 'nest' \| 'first-wins' | 'merge' | How to handle overlapping matches. | | highlightTag | keyof JSX.IntrinsicElements \| Component | 'mark' | Element/component for matches. Custom components receive matchIndex and states props. | | highlightClassName | string | — | Base className for every match. | | highlightStyle | CSSProperties | — | Base inline style for every match. | | unhighlightTag | keyof JSX.IntrinsicElements | — | Element to wrap non-matches (default: no wrapper). | | unhighlightClassName | string | — | className for non-matches (only applied if unhighlightTag is set). | | unhighlightStyle | CSSProperties | — | Inline style for non-matches. | | renderMatch | (seg, defaults) => ReactNode | — | Full render-prop control over match output. | | as | keyof JSX.IntrinsicElements | 'span' | Root wrapper element. | | className | string | — | className on the root wrapper. | | style | CSSProperties | — | Inline style on the root wrapper. |

useHighlight(options){ segments, getMatchCount }

Same options as <Highlight> minus the rendering props. Returns an object with:

  • segments — alternating MatchSegment / TextSegment covering the full text.
  • getMatchCount() — returns the number of matching segments; useful for validating states config or rendering "X results" UI.
type Segment = MatchSegment | TextSegment;

interface MatchSegment {
  text: string;
  isMatch: true;
  matchIndex: number;        // 0-based document order
  termIndex: number;         // index into searchWords that produced this match
  start: number;             // index in original text
  end: number;
  states: ReadonlyArray<string>;  // names of states this match belongs to
}

interface TextSegment {
  text: string;
  isMatch: false;
  start: number;
  end: number;
}

HighlightState selector forms

HighlightState is a discriminated union — each entry carries exactly one selector field that says which matches it applies to. TypeScript narrows on the field name.

// Five selector shapes, picked by which field is present:
{ name: 'active',     index: 2 }            // a single match
{ name: 'preview',    range: [4, 6] }       // an inclusive range
{ name: 'bookmarked', indices: [0, 4, 7] }  // an arbitrary list
{ name: 'feline',     term: 'cat' }         // every match of a search word
{ name: 'first-cat',  term: 'cat', nth: 0 } // a specific occurrence of a search word
const states = [
  { name: 'active',  index: 2,      className: 'is-active' },
  { name: 'preview', range: [0, 1], style: { background: '#5EEAD4' } },
];

Behavior notes

  • Overlapping matches default to merge (collapsed into one segment). Choose nest to keep each match individually addressable, or first-wins to drop later overlaps.
  • Indexing is global document order. Match #0 is the first match in the text regardless of which searchWords entry produced it.
  • Out-of-range state indices are silently ignored in production; a one-time console.warn fires in dev mode.
  • Regex defenses: consumer-supplied RegExp is always cloned, the g flag is forced on, and the sticky y flag is dropped (with a dev warning). This prevents the mutable-lastIndex footgun.
  • Accessibility: default <mark> carries native mark semantics. When highlightTag is overridden to a non-semantic element, role="mark" is added automatically. The shipped playground and docs palettes are tuned to WCAG 2.2 AAA contrast (≥ 7:1 for normal text) across every highlight/text pair — copy them as-is, or use them as a reference when building your own. See the Accessibility recipe for verification tools (WebAIM, axe-core, overlay widgets).
  • SSR: pipeline contains no window/document reads and produces deterministic markup.

Browser & runtime support

| Environment | Requirement | Notes | | --- | --- | --- | | Browsers | Modern evergreen (Chrome 112+, Firefox 140+, Safari 16.4+) | RegExp.escape() is used natively where available (Chrome 134+, Firefox 134+, Safari 18.4+); older evergreens fall back to escape-string-regexp. | | Node.js | 18+ | escape-string-regexp v5 is ESM-only and requires Node 18+. If you need Node 16, pin escape-string-regexp to v4 and add it to your own dependencies. | | React | 18 or 19 | Peer dependency. | | TypeScript | 5.0+ | exactOptionalPropertyTypes and verbatimModuleSyntax are used internally; consumers do not need these flags. |

Recipes

Diacritic-insensitive search

Strip diacritics from both the text and the search terms before matching, then render against the original text:

const normalize = (s: string) =>
  s.normalize('NFD').replace(/\p{Diacritic}/gu, '');

<Highlight
  text="Héllo wörld"
  searchWords={['hello', 'world']}
  sanitize={normalize}
/>
// highlights "Héllo" and "wörld" despite the accents

sanitize is applied to both the text and each search word before matching. The highlighted output always uses the original, un-normalized text.

Engines

one-more-highlight ships two rendering engines that share the same matching pipeline:

  • DOM engine (default) — <Highlight> from 'one-more-highlight'. Wraps each match in a <mark> node. Supports renderMatch, custom tags, and per-state inline style. Universal browser support.
  • CSS Custom Highlight API engine (opt-in) — <CssHighlight> from 'one-more-highlight/css'. Paints ranges via CSS.highlights with no per-match DOM nodes. Larger perf win on long text. See the engines/css-highlights docs page.

Roadmap

See docs/ROADMAP.md for the full v2+ plan. Short version:

  • Grapheme-aware matching via Intl.Segmenter
  • Fuzzy matching (Levenshtein)
  • Stable match IDs for references that survive data changes

Contributing

See CONTRIBUTING.md. Bug reports and edge-case fuzz cases especially welcome.

License

MIT © Ronen Mars. See LICENSE.


"In the end, it doesn't even matter" — except when it does. Every match. Every word. Every voice that mattered. R.I.P. Chester. 🤍