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
stylestyles the outer container<Text>.textPropsforwards everything else to the container —numberOfLines,onPress,accessibilityLabel, and so on.<HighlightText>is wrapped withforwardRef— arefattaches 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
onTextLayoutand wheneversearchWords/segments change even if the layout doesn't (RN wouldn't re-fireonTextLayoutwhentextis 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
numberOfLinestruncation, only matches on rendered lines are meaningful — RN reports layout for rendered lines only. - Composes with a
textProps.onTextLayoutyou 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'smeasureLayout(againstrelativeTo) ormeasure(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.layoutRefis separate fromref(which still forwards the raw container<Text>), so existingrefconsumers 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 areStyleProp<TextStyle>objects. - No
<mark>/role="mark". React Native has nomarkaccessibility role. For an accessible callout, passaccessibilityLabelviatextProps, or userenderMatchto render your own node. - No CSS Custom Highlight API engine.
/cssis 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.