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' } }]}
/>;
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' } },
]}
/>
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.
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 KB brotlied.