Skip to main content

React Native engine

one-more-highlight/native ships an opt-in rendering engine for React Native. Matches render as nested <Text> runs inside a container <Text>. The matching pipeline is identical to the web engines — same selectors, overlap strategies, and multi-state styling — only the render step differs.

Opt in​

react-native is an optional peer dependency — it is pulled in only when you import /native, so web-only consumers are unaffected.

import { HighlightText } from 'one-more-highlight/native';

<HighlightText
text="the quick brown fox"
searchWords={['quick', 'fox']}
highlightStyle={{ backgroundColor: '#FFF166' }}
states={[{ name: 'active', term: 'fox', style: { fontWeight: 'bold' } }]}
/>;

Controlled ranges​

<HighlightText> takes the same mutually exclusive source union as the web components: either searchWords or ranges, never both. On mobile this is usually the more natural of the two — the search that produced the results already ran on a server:

<HighlightText
text={article.body}
ranges={hits.map((h) => ({ start: h.from, end: h.to, id: h.objectID }))}
highlightStyle={{ backgroundColor: '#FFF166' }}
/>;

Offsets are clamped to the text and malformed entries are dropped, termId groups ranges for { term } selectors, and metadata rides through to predicates and renderMatch — all identical to the web engine. See the controlled ranges guide for the full semantics.

Styling​

React Native has no className — style with highlightStyle, unhighlightStyle, and HighlightState.style, all StyleProp<TextStyle>. Per-state styles cascade in declaration order — the last matching state wins, same as the web engine.

<HighlightText
text={text}
searchWords={['cat']}
highlightStyle={{ backgroundColor: '#FFF166' }}
states={[
{ name: 'active', index: 2, style: { backgroundColor: '#A8FF80' } },
{ name: 'pinned', indices: [3, 5], style: { backgroundColor: '#FFADD6' } },
]}
/>

A predicate selector is available too — the whole selector set is shared, only the styling surface differs:

<HighlightText
text={text}
ranges={hits}
states={[
{
name: 'strong',
match: (m) => Number(m.range.metadata?.score) >= 0.85,
style: { backgroundColor: '#A8FF80' },
},
]}
/>

As on web, the predicate is compared by identity — hoist it or wrap it in useCallback so the pipeline isn't re-run every render.

renderMatch​

Full control over a match. Receives the segment and the merged highlight + per-state style:

import { Text } from 'react-native';

<HighlightText
text={text}
searchWords={['fox']}
renderMatch={(seg, { style }) => (
<Text style={style} onPress={() => focus(seg.matchIndex)}>
{seg.text}
</Text>
)}
/>;

Container props and refs​

  • style styles the outer container <Text>.
  • textProps forwards everything else to the container — numberOfLines, onPress, accessibilityLabel, and so on.
  • <HighlightText> is wrapped with forwardRef — a ref attaches to the container <Text>.

Headless hook​

useHighlight has no platform dependency — render the segments however you like:

import { Text } from 'react-native';
import { useHighlight } from 'one-more-highlight/native';

function Highlighted({ text, term }: { text: string; term: string }) {
const { segments } = useHighlight({ text, searchWords: [term] });
return (
<Text>
{segments.map((seg, i) =>
seg.isMatch ? (
<Text key={i} style={{ backgroundColor: '#FFF166' }}>
{seg.text}
</Text>
) : (
seg.text
),
)}
</Text>
);
}

Scroll to a match​

Match <Text> runs are virtual nodes — no onLayout, no host handle — and RN exposes no substring measurement, so the finest primitive available is the box of the line a match falls on, relative to the root <Text>. onMatchesLayout hands you exactly that, derived from the already-computed match offsets (no re-running indexOf):

onMatchesLayout={(matches) => {
// matches: { matchIndex, termIndex, start, end, lineIndex, y, height }[]
// `y` / `height` = the line's box relative to the root Text's top.
}}
  • Fires on onTextLayout and whenever searchWords/segments change even if the layout doesn't (RN wouldn't re-fire onTextLayout when text is unchanged), so offsets never go stale.
  • Emits [] when a re-match finds nothing, so you can clear state.
  • A match that wraps across lines reports its first line.
  • Under numberOfLines truncation, only matches on rendered lines are meaningful — RN reports layout for rendered lines only.
  • Composes with a textProps.onTextLayout you supply (both run).

For a full scroll-to-match against a list row, resolve the match into the row's coordinate space with the imperative layoutRef:

import { useRef } from 'react';
import { View, Text } from 'react-native';
import { HighlightText } from 'one-more-highlight/native';
import type { HighlightLayoutHandle } from 'one-more-highlight/native';

function Row({ text, needle, listRef }) {
const layout = useRef<HighlightLayoutHandle>(null);
const rowRef = useRef<View>(null);

async function scrollToMatch() {
// Measure the "active" match's line against the list row, then scroll.
const box = await layout.current?.measureMatch(0, rowRef);
if (box) listRef.current?.scrollToOffset({ offset: box.y });
}

return (
<View ref={rowRef}>
<HighlightText
text={text}
searchWords={[needle]}
states={[{ name: 'active', index: 0 }]}
layoutRef={layout}
/>
</View>
);
}
  • getMatchLayout(matchIndex) → sync, from cached layout: { start, end, lineIndex, y, height } | null (null before the first layout or for an unknown index).
  • measureMatch(matchIndex, relativeTo?) → async: composes the root Text's measureLayout (against relativeTo) or measure (window) with the cached line-y, resolving the match's coords in ancestor or window space. Leave converting that to list/window scroll offsets to you — absolute coords go stale every scroll frame.
  • layoutRef is separate from ref (which still forwards the raw container <Text>), so existing ref consumers are unaffected.

Web has no equivalent: DOM matches are real elements there, so scrollIntoView already covers scroll-to-match.

Type exports​

/native exports the same type surface as the web entry, split in two.

RN-flavored — HighlightState and each of its members (HighlightStateOne, HighlightStateRange, HighlightStateMany, HighlightStateTerm, HighlightStateTermNth, HighlightStatePredicate), plus HighlightTextProps, UseHighlightOptions, UseHighlightResult, MatchDefaults, MatchLayout, MeasuredMatch and HighlightLayoutHandle. These swap className / CSS style for StyleProp<TextStyle>.

Platform-neutral, re-exported from the shared pipeline — HighlightRange, HighlightSource, MatchContext, MatchContextBase, SearchMatchContext, RangeMatchContext, MatcherOptions, Segment, MatchSegment, TextSegment, OverlapStrategy, RawChunk and FindChunksInput. These are the same types the web entry exports — identical because the matching pipeline is shared.

The RN state union derives from the core one, so a selector form added on web shows up here without a second definition to keep in sync.

Differences from the DOM engine​

  • No className. Styles are StyleProp<TextStyle> objects.
  • No <mark> / role="mark". React Native has no mark accessibility role. For an accessible callout, pass accessibilityLabel via textProps, or use renderMatch to render your own node.
  • No CSS Custom Highlight API engine. /css is web-only; there is no RN analog.

Platform caveats​

Background color, line height, and vertical alignment of nested text spans behave differently across iOS and Android. Android in particular shifts the baseline when a span changes fontSize, and does not clip borderRadius on text spans. numberOfLines truncation is controlled by the outer container <Text> via textProps.

Metro resolution​

New Metro (RN 0.79+) resolves the /native subpath via the package exports map. Older Metro resolves it through a bundled path shim, so no configuration is needed either way.

Bundle impact​

The /native entry is its own tree-shaking root — web consumers pay nothing for it, and the RN entry ships at ~2.9 KB brotlied.