react-native-caret-overlay
v0.2.0
Published
Positions a React Native view at a TextInput's caret, natively, with no JS round trip
Downloads
427
Maintainers
Readme
react-native-caret-overlay
Render any React Native view at the native caret of a multiline TextInput.
It is intended for custom carets such as glowing bars, animated shapes, branded
indicators, or anything that cannot be expressed with selectionColor.
The package reads the caret position from the text field's native text layout.
It does not measure a mirrored <Text>, estimate glyph widths, or send the
position through JavaScript on every keystroke. This keeps the overlay aligned
during IME composition, line wrapping, emoji input, bidirectional text,
selection movement, and internal TextInput scrolling.
Requirements
- React Native New Architecture
- React Native 0.85 or a compatible Codegen version
- A multiline
TextInput - iOS and Android
Installation
npm install react-native-caret-overlayInstall iOS pods after adding the package:
cd ios
pod installRebuild the native application. A Metro reload or over-the-air JavaScript update cannot add the native view manager to an existing binary.
Basic usage
CaretOverlayView expects exactly two direct children, in this order:
- A subtree containing the
TextInput. The first native text input found in this subtree is tracked. - The custom caret view.
import {useState} from 'react';
import {Platform, StyleSheet, TextInput, View} from 'react-native';
import {CaretOverlayView} from 'react-native-caret-overlay';
export function Composer() {
const [text, setText] = useState('');
return (
<CaretOverlayView style={styles.field}>
<View>
<TextInput
value={text}
onChangeText={setText}
multiline
// Keep UIKit's caret geometry available for the iOS loupe.
caretHidden={Platform.OS !== 'ios'}
style={styles.input}
/>
</View>
<View pointerEvents="none" style={styles.caret}>
<View style={styles.glow} />
</View>
</CaretOverlayView>
);
}
const styles = StyleSheet.create({
field: {
flex: 1,
backgroundColor: '#111',
},
input: {
fontSize: 17,
lineHeight: 22,
padding: 0,
},
caret: {
position: 'absolute',
left: 0,
top: 0,
// On iOS this opaque layer masks the system caret without disabling the
// geometry UIKit needs for its multiline loupe.
backgroundColor: '#111',
},
glow: {
width: 3,
height: 30,
borderRadius: 2,
backgroundColor: '#fff',
shadowColor: '#fff',
shadowOpacity: 1,
shadowRadius: 8,
},
});The second child must start at the overlay's origin:
{position: 'absolute', left: 0, top: 0}Native code moves a native-owned container from that origin. Transforms and animations inside the React caret child are safe.
The custom caret's own height is used and vertically centered on the native text line. It may therefore be taller than the system caret. The caret child itself should be opaque on iOS; animate nested content rather than fading the mask.
Caret visibility and selections
The package positions the second child; it does not decide when that child should be visible. Keep the second child mounted so the native target remains stable, and hide it with opacity unless:
- the input is focused; and
- the selection is collapsed (
start === end).
const [focused, setFocused] = useState(false);
const [collapsed, setCollapsed] = useState(true);
<CaretOverlayView>
<View>
<TextInput
multiline
caretHidden={Platform.OS !== 'ios'}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onSelectionChange={event => {
const {start, end} = event.nativeEvent.selection;
setCollapsed(start === end);
}}
/>
</View>
<View
pointerEvents="none"
style={[styles.caret, !(focused && collapsed) && {opacity: 0}]}>
<CustomCaret />
</View>
</CaretOverlayView>On iOS, React Native implements caretHidden by returning CGRectZero from
caretRectForPosition:. That also makes UIKit's multiline loupe magnify the
wrong line. Leave the system caret enabled on iOS and have the custom caret
include an opaque background-colored mask beneath its animated content. During
long-press and selection gestures, the package hides the custom caret so the
loupe contains only UIKit's caret.
API
CaretOverlayView
A native view with standard React Native ViewProps and one additional prop:
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| enabled | boolean | true | Stops native caret positioning without unmounting the children. |
CaretOverlayViewProps
The exported TypeScript type for CaretOverlayView.
How positioning works
iOS
The wrapper finds the first UITextView inside its first child.
- Text edits are observed with
UITextViewTextDidChangeNotification. - While the field is editing, a display link observes selection-only movement
without replacing React Native's
UITextViewDelegate. contentOffsetis observed for internal multiline scrolling.- The base
UITextViewimplementation ofcaretRectForPosition:provides geometry even when React Native has hidden its caret. - That rect is converted into the overlay's coordinate system.
- A native-owned, non-interactive container avoids Fabric transform resets and is hidden while UIKit's loupe or selection interaction is active.
No delegate proxying, method swizzling, or private UIKit API is used.
Android
The wrapper finds the first EditText inside its first child.
- A
TextWatcherupdates after text edits. - An
OnPreDrawListenerhandles selection-only changes and scrolling. Layout.getPrimaryHorizontal()provides the horizontal caret coordinate.- Line top, line height, padding, and scroll offsets come from the same
Layoutused to draw the text. - Ancestor offsets are accumulated into the overlay's coordinate system.
Why native layout matters
A JavaScript mirror can be close for simple Latin text, but it can diverge from the input for:
- marked text and IME composition;
- Vietnamese tone composition;
- emoji and multi-code-point grapheme clusters;
- ligatures and font shaping;
- bidirectional text;
- a caret moved into the middle of a wrapped word;
- platform-specific padding and multiline scrolling.
This package asks the native text widget for the caret produced by the layout it already rendered, avoiding a second text-layout model.
Current limitations
- Only multiline
TextInputis supported. The iOS implementation tracks theUITextViewbacking a multiline React Native input. - The first text input found inside the first child is tracked.
- The caller owns focus and range-selection visibility.
- For a correct iOS loupe, callers must not set
caretHiddenon iOS and should mask the system caret beneath their custom caret. - A native rebuild is required when first installing the package.
Development status
The package is tested against React Native 0.85.3 with the New Architecture:
- Android Codegen and
assembleDebug - iOS Codegen, CocoaPods integration, and simulator compilation
- TypeScript consumption and Metro bundling from a React Native application
Device-level interaction should still be verified for the target application's fonts, IMEs, and keyboard configuration.
License
MIT
