@shaquillehinds/react-native-essentials
v1.15.1
Published
Essential constants, styles and hooks for react native.
Maintainers
Readme
@shaquillehinds/react-native-essentials
A batteries‑included toolkit for React Native and Expo apps: screen‑relative layouts, a typography scale, buttons, press wrappers with spring feedback, providers for localization / portals / long‑running event tracking, gestures, animation helpers, MMKV storage accessors, and a bag of hooks and pure utilities.
The design goal is that you almost never write View, Text, TouchableOpacity, or pixel values by hand. Layouts are described with percentage‑based props that scale with the device, typography is a fixed scale of named sizes, and everything composes.
Table of contents
- AI agent rules
- Installation
- App setup
- Sizing model
- Layouts
- Typography
- Buttons
- Press wrappers
- Inputs
- Indicators, injectors, utilities, icons
- Modal building blocks
- TextStream
- Providers
- Render isolation
- Gestures
- Animations
- SVG
- Hooks
- Storage (MMKV)
- Scheduler
- Styles
- Utilities
- Algorithms
- Full export index
- Known caveats
AI agent rules
The package ships a rules file written for AI coding agents (Claude Code, Cursor, Codex, Copilot, etc.) at rules/AGENT_RULES.md. It tells an agent to use this package for layouts, text, buttons, spacing, and sizing instead of raw React Native primitives, and lists every export so it cannot invent APIs. Point your agent at it with any of the following.
Copy it into your project (recommended)
npx rne-rules # writes ./AGENTS.md
npx rne-rules cursor # writes ./.cursor/rules/react-native-essentials.mdc (alwaysApply)
npx rne-rules claude # writes ./.claude/rules/react-native-essentials.md
npx rne-rules docs/ai/rn-essentials.md # custom pathAdd --force to overwrite an existing file. Re-run after upgrading the package to pick up rule changes.
Reference it without copying (Claude Code)
CLAUDE.md supports @path imports, so a single line keeps the rules in sync with the installed version:
# CLAUDE.md
@node_modules/@shaquillehinds/react-native-essentials/rules/AGENT_RULES.mdReference it from a generic AGENTS.md
Before writing any React Native UI, read and follow
node_modules/@shaquillehinds/react-native-essentials/rules/AGENT_RULES.md.Installation
npm install @shaquillehinds/react-native-essentials
# or
yarn add @shaquillehinds/react-native-essentialsPeer dependencies (checked at runtime by checkRequiredDependencies()):
npx expo install react-native-reanimated react-native-gesture-handler react-native-safe-area-context react-native-mmkv react-native-svgThe package also imports eventemitter3 and buffer.
You can fail fast during development:
import { checkRequiredDependencies } from '@shaquillehinds/react-native-essentials';
if (__DEV__) checkRequiredDependencies();checkRequiredDependencies({ dependencies }) accepts a custom list of { name, packageName, required } if you want to extend the check. checkDependency(packageName) returns a boolean, and createDependencyError(featureName, packageName) builds a consistent error for optional features.
App setup
Fonts
Text components set fontFamily to the literal fontStyle value. The FontStyle union is:
'Thin' | 'Extra Light' | 'Light' | 'Regular' | 'Medium' | 'SemiBold' | 'Bold' | 'ExtraBold' | 'Black'Register your fonts under those exact names (only the ones you use):
import { useFonts } from 'expo-font';
const [fontsLoaded] = useFonts({
Regular: require('./assets/fonts/Sen-Regular.ttf'),
Medium: require('./assets/fonts/Sen-Medium.ttf'),
SemiBold: require('./assets/fonts/Sen-SemiBold.ttf'),
Bold: require('./assets/fonts/Sen-Bold.ttf'),
ExtraBold: require('./assets/fonts/Sen-ExtraBold.ttf'),
});Because fontStyle is passed straight through, you can also register additional custom names (e.g. a handwriting face) and pass them as fontStyle, casting past the union if you use TypeScript.
Globals
Importing the package executes essentials/utils/global, which installs:
| Global | Behaviour |
| ------------------------------- | --------------------------------------------------------------------------------------------------- |
| is(value) | Returns value if truthy, otherwise undefined. Handy for conditional JSX: {is(cond) && <X />}. |
| isDef(value) | value !== undefined |
| errMsg(error) | Error → message, string → itself, anything else → JSON.stringify |
| Array.prototype.filterMap(fn) | Map that skips falsy source items and drops null/undefined results |
Add a declaration file so TypeScript knows about them:
// @types/globals.d.ts
type NonFalsy<T> = Exclude<T, 0 | '' | false | null>;
declare module globalThis {
var is: <T>(value: T) => NonFalsy<T> | undefined;
var isDef: (value: unknown) => boolean;
var errMsg: (error: unknown) => string;
}Providers at the root
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import {
LocalizationProvider,
PortalProvider,
} from '@shaquillehinds/react-native-essentials';
export function AppProviders({ children }) {
return (
<GestureHandlerRootView>
<LocalizationProvider
sourceLanguage="en"
targetLanguage="en"
translation={async ({ sourceLanguage, targetLanguage, text }) => {
// call your translation backend; must resolve to a string
return text;
}}
>
<PortalProvider>{children}</PortalProvider>
</LocalizationProvider>
</GestureHandlerRootView>
);
}Only add the providers you use. LocalizationProvider is required for translate / useTranslation; PortalProvider for usePortal / usePortalComponent; EventTrackerProvider for event tracking.
Sizing model
All sizing is derived from Dimensions.get('screen') so that the same numbers produce proportionally identical layouts on every device.
Relative functions
Static versions (computed once at import):
| Function | Returns |
| -------------------------------------- | -------------------------------------------------------------------------------------- |
| relativeX(n) | n% of screen width |
| relativeY(n) | n% of screen height (adjusted by half the screen/window height difference) |
| relativeShort(n) | n% of MIN_DIMENSION |
| relativeLong(n) | n% of MAX_DIMENSION |
| normalize(n) | relativeLong(n * scale) rounded to the nearest pixel (scale = 0.11519078473722104) |
| normalizeShort(n) | relativeShort(n * scale) rounded to the nearest pixel |
| relativeXWorklet, relativeYWorklet | Worklet‑marked equivalents for Reanimated |
Reactive versions come from useDeviceOrientation() (see Hooks) and update when the device rotates. Layout and friends use the reactive versions internally.
normalize is what makes the font scale and radius tokens feel like "design points": on a device whose longer dimension is ~868pt, normalize(16) ≈ 16.
Spacing tuples
padding and margin accept a Spaces tuple — [number, number?, number?, number?] — following CSS shorthand order, where every value is a percentage:
| Tuple | top | right | bottom | left |
| -------------- | --- | ----- | ------ | ---- |
| [a] | a | a | a | a |
| [v, h] | v | h | v | h |
| [t, h, b] | t | h | b | h |
| [t, r, b, l] | t | r | b | l |
Top/bottom values are resolved with relativeY (percent of screen height); left/right values with relativeX (percent of screen width). transformSpacing({ margin, padding, orientation? }) and spacerStyles(type, { orientation? })(...values) are exported if you need the same resolution in your own code.
Tokens
type BorderSize = 'razor' | 'thin' | 'medium' | 'large';
// borderSizes: razor 0.1%, thin 0.25%, medium 0.5%, large 0.75% (of MIN_DIMENSION)
type RadiusSize =
| 'edgy'
| 'sharp'
| 'medium'
| 'soft'
| 'curvy'
| 'round'
| 'full';
// radiusSizes: normalize(5 | 10 | 15 | 20 | 25 | 30), full = relativeLong(100)
type FontSize =
| 'headingL'
| 'headingM'
| 'headingS'
| 'titleL'
| 'titleM'
| 'titleS'
| 'bodyL'
| 'bodyM'
| 'bodyS';
// fontSizes: normalize(26 | 24 | 22 | 20 | 18 | 16 | 14 | 12 | 10)
type ButtonSize = 'small' | 'medium' | 'large' | 'wide' | 'auto';borderSizes, radiusSizes, fontSizes, and buttonSizes are exported as plain objects for use inside StyleSheet.create.
buttonSizes (padding via normalizeShort):
| size | paddingHorizontal | paddingVertical | fontSize | borderRadius | width |
| ------ | ----------------- | --------------- | -------- | ------------ | ------------------- |
| small | 30 | 15 | bodyS | edgy | — |
| medium | 60 | 20 | bodyL | sharp | — |
| large | 120 | 25 | titleS | sharp | — |
| wide | 120 | 25 | titleM | sharp | relativeShort(88) |
| auto | 60 | 25 | titleM | sharp | '100%' |
Device constants
SCREEN_WIDTH, SCREEN_HEIGHT, WINDOW_WIDTH, WINDOW_HEIGHT, MAX_DIMENSION, MIN_DIMENSION, aspectRatio, initialOrientation ('portrait' | 'landscape'), isSmallDevice (width < 375 or height < 750), isLargeDevice (width > 1100), isTablet (aspect ratio ≤ 1.6), isIpad, isIOS, isAndroid, isWeb.
Note: isIOS, isAndroid, isWeb are true | undefined (not false), so they can be spread into conditional style arrays.
Layouts
Layout
The foundation. Renders a View, or a ScrollView when scrollable, or an animated equivalent when animated.
import { Layout } from '@shaquillehinds/react-native-essentials';
<Layout
width={90} // number → % of screen width; string → passed through ('100%')
height={20} // number → % of screen height; string → passed through
square={8} // sets width AND height to n% of the longer screen dimension
flex={[1, 0, 'auto']} // [flex] | [flex, flexShrink] | [flex, flexShrink, flexBasis]
center // alignItems: 'center'
centerX // justifyContent: 'center'
spaceBetween // justifyContent: 'space-between'
padding={[1, 3]}
margin={[2, 0, 0, 0]}
backgroundColor="#111"
borderColor="#333"
borderWidth="thin"
borderRadius="soft"
absolute
top={0}
left={0}
>
...
</Layout>;Props (LayoutProps<Scrollable>)
| Prop | Type | Notes |
| ----------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| width, height | DimensionValue | Numbers are percentages of the screen (relativeX / relativeY); strings and other values pass through. |
| square | number | Percentage of the longer screen dimension applied to both width and height (used when width/height are not numbers). |
| flex | [number] \| [number, number] \| [number, number, DimensionValue] | flex, flexShrink, flexBasis. |
| center | boolean | alignItems: 'center' — the cross axis. |
| centerX, spaceCenter | boolean | justifyContent: 'center' — the main axis. |
| spaceBetween, spaceEven, spaceStart, spaceEnd | boolean | justifyContent variants. Precedence: spaceEven > spaceBetween > centerX/spaceCenter > spaceStart > spaceEnd. |
| wrap | boolean | flexWrap: 'wrap' (default 'nowrap'). |
| flexDirection | FlexStyle['flexDirection'] | Prefer RowLayout. |
| alignSelf | FlexAlignType | |
| absolute | boolean | position: 'absolute' |
| top, bottom, left, right | DimensionValue | Passed through as‑is. |
| padding, margin | Spaces | Percentage tuples (see above). |
| backgroundColor | string | Applied to both the container and the content style. |
| borderColor | string | |
| borderWidth | BorderSize | Token, not a number. |
| borderRadius | RadiusSize | Token, not a number. |
| loading | boolean \| LoadingIndicatorProps | Replaces the whole layout with a LoadingIndicator. Only backgroundColor and the object's props are used. |
| skeleton | boolean \| { colors?: [string, string] } | Wraps children in a SkeletonViewIndicator with the layout's computed styles. Children are rendered invisibly to size the shimmer, so pass mock data while loading (see Skeleton loading). |
| scrollable | boolean | Renders a ScrollView. When true, the remaining props are typed as ScrollViewProps; otherwise ViewProps. |
| animated | boolean | Use an animated wrapper. |
| animatedType | 'reanimated' \| 'react-native' | Default 'reanimated'. |
| animatedStyle | StyleProp<AnimatedStyle<ViewStyle>> | Used instead of style when animated && animatedType === 'reanimated'. |
| style | StyleProp<ViewStyle> | Applied last. Ignored when using Reanimated animation (use animatedStyle). |
Everything else is forwarded to the underlying View / ScrollView (onLayout, onTouchStart, pointerEvents, collapsable, contentContainerStyle, ...).
Skeleton loading
A skeleton must have the exact dimensions and shape of the data it stands in for. Layout does not try to guess that shape: when skeleton is on it renders the layout's normal styles and its children at opacity: 0, then paints the shimmer over the area those children occupy. The skeleton therefore only ever knows about layout, never about data.
The pattern is:
- While loading, render the same component tree you would render with real data, but feed it mock data with realistic lengths (a plausible name, a two‑line description, a real‑looking price).
- Pass
skeleton={isLoading}to theLayout/RowLayout/TouchableLayoutthat wraps that tree. - When the data arrives, swap the mock data for the real data and set
skeletontofalse. Nothing else changes, so there is no layout shift.
const MOCK_USER = { name: 'Firstname Lastname', bio: 'A short two‑line bio that is about this long so the card keeps its height.' };
function UserCard({ user, loading }: { user?: User; loading: boolean }) {
const data = loading ? MOCK_USER : user!;
return (
<RowLayout skeleton={loading} padding={[2, 4]} borderRadius="soft" backgroundColor="#fff">
<Avatar uri={data.avatarUrl} />
<Layout flex={[1]}>
<Heading>{data.name}</Heading>
<Body>{data.bio}</Body>
</Layout>
</RowLayout>
);
}Rules of thumb:
- Never render an empty
<Layout skeleton />. With no children (and no explicitwidth/height) it has nothing to measure and collapses. - Mock data should fill the component the way real data would, including text length and list item count. Too little mock data gives a skeleton that is smaller than the loaded content.
- Put
skeletonon the smallest layout that wraps one unit of loading content (a card, a row, a list item) rather than on the whole screen, so static chrome such as headers stays visible. - For a single loading text node inside loaded chrome, use the text component's own
skeletonprop instead (see Typography). childrenmust not do anything with side effects (fetching, analytics) when rendered with mock data; they are still mounted, only hidden.- Use
loadinginstead only when a spinner is genuinely wanted and the placeholder size does not matter.
The centring rule
center is alignItems, centerX is justifyContent. That means:
- In a column
Layout,centercentres horizontally andcenterXcentres vertically. - In a
RowLayout,centercentres vertically andcenterXcentres horizontally. center centerXcentres on both axes in either case.
Style composition
- Plain view:
style={[contentStyle, viewStyle, style]} - Scroll view:
style={[viewStyle, style]},contentContainerStyle={[contentStyle, yourContentContainerStyle]}andoverflow: 'visible'is forced.
contentStyle holds padding, alignItems, flexDirection, flexWrap, justifyContent, and backgroundColor. viewStyle holds margin, width/height, alignSelf, position, flex, offsets, border, and backgroundColor.
RowLayout
Layout with flexDirection: 'row' appended after your style, so it always renders as a row. Same generic Scrollable parameter and props.
<RowLayout center spaceBetween margin={[2, 0, 0, 0]}>
<Title>Journals</Title>
<Press onPress={onImport}>
<ImportIcon />
</Press>
</RowLayout>ScreenLayout
Layout with { display: 'flex', flex: 1 } prepended, plus:
| Prop | Notes |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| safe | Wraps the layout in SafeAreaProvider → SafeAreaView with edges={['top', 'bottom']}. The safe view gets flex: 1, overflow: 'visible', and a backgroundColor copied from the backgroundColor prop or from style.backgroundColor. |
| scrollable | As in Layout. |
<ScreenLayout safe backgroundColor={theme.background} padding={[0, 3]}>
...
</ScreenLayout>
// centred splash / empty state
<ScreenLayout center centerX backgroundColor={theme.background}>
<Logo />
<Heading margin={[5, 0]}>Welcome</Heading>
</ScreenLayout>AnimatedLayout
const rStyle = useAnimatedStyle(() => ({ opacity: progress.value }));
<AnimatedLayout animatedStyle={rStyle} center padding={[2]}>
...
</AnimatedLayout>;Layout with animated fixed to true (Reanimated). Props: Omit<LayoutProps, 'animated'> & { animatedStyle? }. Remember that style is not applied in this mode.
TouchableLayout
A TouchableOpacity that accepts the same sizing/alignment/spacing/border/loading/skeleton props as Layout (no scrollable, no animated). All TouchableOpacityProps are forwarded.
SeparatorLayout
<SeparatorLayout lineColor="#444" margin={[3, 0]}>
<Body customColor="#888">OR</Body>
</SeparatorLayout>| Prop | Default |
| ------------------- | ------------------------------------- |
| lineColor | '#222222' |
| lineWidth | 0.5 |
| lineOpacity | 0.8 |
| + all LayoutProps | defaults: center, margin={[2, 0]} |
Renders RowLayout → line, children, line.
Typography
| Component | Default fontSize | Default fontStyle | fontSize accepted |
| ---------- | ------------------ | ------------------- | -------------------------------------- |
| BaseText | bodyM | Regular | any FontSize |
| Body | bodyM | Regular | bodyS | bodyM | bodyL |
| Title | titleM | Medium | titleS | titleM | titleL |
| Heading | headingS | SemiBold | headingS | headingM | headingL |
BaseTextProps (extends React Native TextProps and Spacing):
| Prop | Type | Notes |
| ------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------- |
| fontSize | FontSize | Resolved via useFontSizes(). |
| fontStyle | FontStyle | Written to fontFamily. |
| customColor | string | color. |
| center | boolean | textAlign: 'center' (otherwise 'left'). |
| lineHeight | 'short' \| 'tall' | 1.05× / 1.35× the font size. |
| letterSpacing | 'wide' \| 'extraWide' | 0.7 / 1.2. |
| numberOfLines | number | |
| onPress | (e) => void | |
| animate | boolean | Renders Reanimated Animated.Text. |
| animatedStyle | StyleProp<AnimatedStyle<TextStyle>> | Applied between the computed style and style. |
| translate | boolean | Wraps string children in TranslateText (see LocalizationProvider). |
| padding, margin | Spaces | Percent tuples (static relativeX/relativeY). |
| skeleton | SkeletonLoadingIndicatorProps | Object, not boolean. Wraps the text in a SkeletonViewIndicator sized by the (hidden) text, so pass mock text of realistic length. margin moves to the wrapper; colors, disableAnimation, style inside the object go to the wrapper. |
| style | StyleProp<TextStyle> | Applied last. |
<Heading fontSize="headingL" customColor="white" margin={[5, 0]}>Welcome</Heading>
<Title fontSize="titleS">Entries</Title>
<Body numberOfLines={1} customColor={theme.typeface.secondary} margin={[0, 1]}>
{entry.journalName}
</Body>
<Body translate center fontSize="bodyL">{description}</Body>Text skeletons follow the same rule as Layout skeletons: the text is rendered at opacity: 0 with its real font size, line height and padding, and the shimmer covers exactly that box. Enable it with an object (an empty one is fine) and render mock text of the length you expect:
<Heading skeleton={loading ? {} : undefined}>{loading ? 'Placeholder headline' : post.title}</Heading>
<Body numberOfLines={2} skeleton={loading ? { colors: ['#222', '#333'] } : undefined}>
{loading ? 'Two lines of placeholder body copy that is roughly as long as a real excerpt would be.' : post.excerpt}
</Body>Use a text skeleton for a single text node inside otherwise‑loaded chrome (a price, a username). When a whole card is loading, put skeleton on the wrapping Layout instead so all children shimmer as one block. Because ButtonProps extends BaseTextProps, skeleton on a BaseButton is forwarded to its label only; the button frame stays visible.
Recommended pattern: wrap BaseText once per role in your project so colours come from your theme, then use those wrappers everywhere.
export function Title(props: PropsWithChildren<TitleTextProps>) {
return (
<BaseText
{...props}
customColor={theme.typeface.primary}
fontSize={props.fontSize ?? 'titleM'}
fontStyle={props.fontStyle ?? 'SemiBold'}
/>
);
}TranslateText, LocalizationComponent, and TranslationComponent are also exported; they are the internals behind translate and can be used directly.
Buttons
BaseButton
<BaseButton
buttonSize="wide"
backgroundColor={['#4A87F2', '#7B4AF2']} // string or gradient array
customFontColor="#fff"
borderRadius="round" // RadiusSize or number
leftComponent={<Icon />}
leftComponentGap={8}
loading={saving}
onPress={save}
>
Save
</BaseButton>ButtonProps extends Omit<BaseTextProps, 'style'> (so translate, fontSize, fontStyle, numberOfLines, margin, padding all apply to the label) and adds:
| Prop | Type | Default / notes |
| --------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------- |
| buttonSize | ButtonSize | 'medium'. Drives padding, font size, radius, width (see Tokens). |
| backgroundColor | string \| string[] | An array renders an AbsoluteLinearGradient behind the label. |
| gradientStart, gradientEnd, gradientOpacities | | Forwarded to the gradient. |
| customFontColor | string | Label and spinner colour. |
| fontStyle | FontStyle | 'Medium' |
| fontSize | FontSize | From buttonSize. Line height is 1.3×. |
| textStyle | StyleProp<AnimatedStyle<TextStyle>> | Merged into the label's animatedStyle. |
| borderColor | string | 'transparent' |
| borderWidth | BorderSize | 'thin' |
| borderRadius | RadiusSize \| number | From buttonSize. Inner view uses radius - 1 with overflow: 'hidden'. |
| alignSelf | FlexAlignType | 'center' |
| shadow | ShadowStylesProps | Applied via shadowStyles. |
| leftComponent, rightComponent | JSX.Element | Rendered inside the row around the label. |
| leftComponentGap, rightComponentGap | number | Label marginLeft / marginRight. |
| loading | boolean \| LoadingIndicatorProps | Hides the label (opacity 0), shows a small ActivityIndicator, disables press. |
| disabled | boolean | Sets activeOpacity 0.5 and disables. |
| activeOpacity | number | 0.8 |
| enableRapidPress | boolean | Forwarded to Press (plain TouchableOpacity, no double‑tap protection). |
| animate | boolean | Inner view becomes Reanimated Animated.View. |
| style | StyleProp<AnimatedStyle<ViewStyle>> | Applied to the inner view. |
| onPress | | |
Children default to the string 'Submit'.
Structure: Press (margin, shadow, border, width, alignSelf) → inner view (radius, background/gradient) → row (padding, leftComponent, BaseText, rightComponent) + optional absolute spinner.
Project wrapper pattern:
export function PrimaryButton({
customFontColor,
backgroundColor,
...props
}: PropsWithChildren<ButtonProps>) {
return (
<BaseButton
{...props}
customFontColor={customFontColor ?? theme.typeface.primary}
backgroundColor={backgroundColor ?? theme.lightBackground}
/>
);
}Press wrappers
Four components share the same press model. They listen to onTouchStart / onTouchMove / onTouchEnd on an animated view rather than using Pressable.
| Component | Renders | Animation |
| ------------------- | ----------------------------------------------------------------- | ---------------------------------------------- |
| Press | Reanimated Animated.View | spring scale → 0.95, opacity → activeOpacity |
| RNPress | RN Animated.View | 200ms timing, native driver |
| PressableLayout | AnimatedLayout (all LayoutProps) | Reanimated |
| RNPressableLayout | Layout animated animatedType="react-native" (all LayoutProps) | RN Animated |
Shared props:
| Prop | Default | Notes |
| ---------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| onPress(e: GestureResponderEvent) | | Fires on touch end after activationDelay. |
| onLongPress(snapshot) | | Receives a GestureResponderNativeEventSnapshot (pageX, pageY, locationX, locationY, timestamp, identifier, target, force), not the synthetic event. |
| longPressDuration | 800 ms | |
| activationDelay | 50 ms | Wait before firing onPress so a move‑cancel can win. |
| activeOpacity | 0.9 | |
| disabled | | Renders at opacity 0.5 and ignores touches. |
| disableAnimation | | Skip the scale/opacity feedback. |
| disableDoubleTapProtection | | By default a second activation within minDoubleTapProtectionDuration is ignored. |
| minDoubleTapProtectionDuration | 750 ms | |
| stopPropagation, preventDefault, persist | | Applied to the touch events. |
| style | | For Press/RNPress this is the animated view's style. For PressableLayout it is combined with the press animation and passed as animatedStyle‑equivalent internally. |
Press only: enableRapidPress renders a plain TouchableOpacity with onPress/onLongPress/activeOpacity and no protection.
A touch that moves more than 10px from its start point cancels the press.
<Press onPress={onImport}><ImportIcon size={22} /></Press>
<PressableLayout
center spaceBetween padding={[1, 3]} borderRadius="soft" backgroundColor="#1a1a1a"
onPress={() => open(item)}
onLongPress={() => showOptions(item)}
>
...
</PressableLayout>Inputs
BaseInput
A RowLayout wrapper around a TextInput with focus/error border colours and slots on either side.
<BaseInput
backgroundColor={theme.lightBackground}
textInputProps={{
placeholder: 'Enter password',
autoCapitalize: 'none',
onChangeText,
}}
focusedBorderColor="#4A87F2"
blurredBorderColor="transparent"
erroredBorderColor="#E55774"
hasError={!!error}
LeftComponent={<LockIcon />}
refTextInput={inputRef}
margin={[2, 0]}
/>BaseInputProps = the following + LayoutProps:
| Prop | Notes |
| ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
| backgroundColor | required |
| textInputProps | required TextInputProps; placeholder defaults to 'Type here...'. |
| hasError | Switches to erroredBorderColor. |
| focusedBorderColor / blurredBorderColor / erroredBorderColor | Defaults #4A87F2 / transparent / #E55774. Border width is 1. |
| LeftComponent, RightComponent | Each reduces the input's width by 10% (100% → 90% → 80%). |
| TextInputComponent | Custom input renderer receiving TextInputProps & { ref }. |
| refTextInput | MutableRefObject<TextInput \| null> — receives the inner ref. |
| refStateInput | Ref<StateInputRef> — when provided (and no TextInputComponent), renders StateTextInput. |
| refStateInputValidator | (text) => boolean — gate for StateTextInput updates. |
Default padding is [1.5, 4] on iOS and [0.4, 4] on Android; tapping the row focuses the input.
StateTextInput
A TextInput that owns its value in state and exposes it through refStateInput:
const stateRef = useRef<StateInputRef>(null);
<StateTextInput
refStateInput={stateRef}
refStateInputValidator={(t) => t.length <= 10}
placeholder="Name"
/>;
// later
stateRef.current?.value;
stateRef.current?.setValue('');StateInputRef = { value: string; setValue: Dispatch<SetStateAction<string>> }. If the validator returns false, the value is not updated and onChangeText is not called.
Indicators, injectors, utilities, icons
LoadingIndicator
Full‑size (100% × 100%) centred ActivityIndicator size="large".
| Prop | Notes |
| ---------------------------------------------- | --------------------------------- |
| TopComponent, BottomComponent | Rendered above/below the spinner. |
| backgroundColor, opacity, animationColor | |
| absolute | position: 'absolute' |
Used by Layout loading. Example of a full‑screen overlay:
<Layout
loading={{
absolute: true,
opacity: 0.9,
backgroundColor: theme.background,
animationColor: theme.accent,
BottomComponent: (
<Layout margin={[2, 0, 0, 0]}>
<Title fontSize="titleL">{message}</Title>
</Layout>
),
}}
/>SkeletonViewIndicator
Shimmering gradient overlay (Reanimated + react-native-svg). Props: colors?: [string, string] (default ['#ECECEC', '#FBFAFE']), disableAnimation?, children, plus ViewProps. Used by Layout / TouchableLayout skeleton.
It sizes itself from its children: they are rendered at opacity: 0 (still mounted, still measured) and an absolutely‑filled gradient is drawn on top with overflow: 'hidden'. It has no intrinsic size of its own, so always give it either children populated with mock data or an explicit width/height via style.
<SkeletonViewIndicator style={{ borderRadius: radiusSizes.soft }}>
<Body>{loading ? 'Placeholder text of the same length' : text}</Body>
</SkeletonViewIndicator>Prefer Layout skeleton over using this directly; it gives you the layout's spacing, radius and sizing for free.
ViewDimensionsInjector
Measures itself with onLayout and renders renderItem(layoutRectangle) once dimensions are known. Props: renderItem, absolute?, justifyContent? (default 'center'), aligntItems? (default 'center'; note the prop spelling).
ComponentMounter
Delayed mount/unmount of a component, driven either by props or by an imperative controller.
const mounter = useRef<ComponentMounterController>(null);
<ComponentMounter
ref={mounter}
component={<Toast />}
unMountDelayInMilliSeconds={200}
/>;
mounter.current?.mountComponent({ onOpen });
mounter.current?.unMountComponent({ duration: 300, onClose });
mounter.current?.hardUnMountComponent();Props: component (required), showComponent?, setShowComponent?, onComponentShow?, onComponentClose?, mountDelayInMilliSeconds?, unMountDelayInMilliSeconds?, mountDefault?, keepMountedOnReopen? (default false). When showComponent turns true while already mounted (for example a reopen inside the unmount delay), the default behaviour is to flip setShowComponent(false) and hard‑unmount on the next change; pass keepMountedOnReopen to cancel the pending unmount and stay mounted instead. Callbacks and delays are read from the latest props on every mount/unmount, so they do not need to be memoised.
RadioIcon
<RadioIcon
isSelected={selected}
selectedColor="#4A87F2"
unSelectedColor="#D9D9D9"
size={2}
borderWidth="medium"
alwaysShowCenter
/>Built from two Layout square={...} borderRadius="full" views. size is a percentage of the longer screen dimension (default 2).
Modal building blocks
These are low‑level pieces for composing your own modals.
| Component | Purpose |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ModalWrapper | Absolute‑fill view with zIndex: maxZIndex. Props: enableBackgroundContentPress (pointerEvents="box-none"), useNativeModal (renders RN Modal visible transparent statusBarTranslucent wrapping a GestureHandlerRootView), disableAndroidBackButton, onRequestClose. |
| ModalBackgroundAnimated | TouchableWithoutFeedback → Reanimated absolute‑fill view. Props: onPress, style, animatedStyle, avoidStatusBar (adds marginTop: StatusBar.currentHeight), children (default: rgba(0,0,0,.2) scrim). |
| ModalForegroundWrapper | Plain view with zIndex: maxZIndex on iOS. Wrap your animated foreground in it so it sits above the background on iOS and so the keyboard doesn't shift it on Android. |
maxZIndex (999999999) is exported from styles.
TextStream
Types out a string character by character.
const ref = useRef<TextStreamRef>(null);
<TextStream
ref={ref}
autoStream
streamCharacterDelay={15}
CustomTextComponent={Body}
onStreamFinish={done}
>
{message}
</TextStream>;
ref.current?.stopStream();Props (TextStreamProps<T extends TextProps>): autoStream, startStreamDelay, streamCharacterDelay (default 15ms), skipCharacterDelayInterval / skipCharacterDelayAmount (every N characters, skip the delay for M characters), CustomTextComponent, onStreamFinish, ref: { startStream, stopStream }, plus the wrapped text component's props. String children (or arrays of strings) are streamed; when the text grows the stream continues from the last index, when it shrinks it finishes immediately.
Providers
LocalizationProvider
<LocalizationProvider
sourceLanguage="en"
targetLanguage={userLanguage}
translation={({ sourceLanguage, targetLanguage, text }) => api.translate(...)}
initialLanguagesRecord={bundledTranslations}
initialLanguagesRecordRetriever={({ sourceLanguage, targetLanguage }) => api.bulk(...)}
>| Prop | Type |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| sourceLanguage, targetLanguage | LanguageCode (keys of LanguageCodesEnglishMappings, e.g. 'en', 'en-US', 'fr') |
| translation | ({ sourceLanguage, targetLanguage, text }) => Promise<string> |
| initialLanguagesRecord? | LanguagesRecord = Partial<Record<LanguageCode, Record<hash, string>>> |
| initialLanguagesRecordRetriever? | ({ sourceLanguage, targetLanguage }) => Promise<{ original: string; translated: string }[]> — fetched once per target language when nothing is cached for it |
Behaviour:
- If
sourceLanguageandtargetLanguageshare the same first two letters,translatereturns the input unchanged. - Cache key is
sha256(text.trim(), 'base64'); the record is persisted in MMKV underessentials-localization-<sourceLanguage>viacreateStorageAccessors. - Concurrent requests for the same text share one in‑flight promise.
- Non‑string results are treated as errors; on error the trimmed input is returned.
- Context value:
{ translate(text): Promise<string>, clearLocalCache() }.useLocalization()returns it ornull.
Consumers: any text component / BaseButton with translate, useTranslation({ text }), or TranslateText directly.
PortalProvider
<PortalProvider unMountBufferTimeMS={100} updateBufferTimeMS={0}>Renders children, then an absolute‑fill pointerEvents="box-none" host containing every mounted portal item.
usePortal(CustomPortalContext?)→{ mount(key, element, onMount?), update(key, element), unmount(key, onUnMount?) }ornull.usePortalComponent({ name, Component, disable?, CustomPortalContext? })mountsComponenton first render, updates it when it changes, and unmounts on cleanup; returns the portal context.unMountBufferTimeMS(default 100) delays removal so a lateupdatecan cancel it;updateBufferTimeMSthrottles updates per item.- Pass
CustomPortalContext(a context created fromPortalContext's type) to run several independent portal hosts.
EventTrackerProvider
Persists "events" (uploads, exports, background jobs) in MMKV and polls their status while in_progress.
<EventTrackerProvider
statusCheckFnRegistry={{
upload: async (event, trigger) => ({ ...event, status: await checkUpload(event.id) }),
}}
>| Prop | Notes |
| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| statusCheckFnRegistry | Record<string, StatusCheckFn>; StatusCheckFn = (event, triggerType?) => Promise<EventTracker> |
| maxStoredEventTrackers?, defaultMaxInProgressTime?, defaultStatusCheckInterval? | Declared props. |
EventTracker fields: id, name, status: 'in_progress' | 'done' | 'failed' | 'cancelled', description, createdAt, updatedAt, statusCheckFnId, and optional extraData, image, extra, type, url, expires, statusCheckInterval (default 30000ms), maxTimeInProgress, triggerStatusCheckFnOn?: ('expired' | 'maxTimeInProgress')[].
Behaviour: on mount, expired events are deleted and in‑progress ones start a Scheduler.Schedule. Each tick calls the registered function; a status change is stored and, if no longer in progress, polling stops. Exceeding maxTimeInProgress marks the event failed (optionally calling the check function with 'maxTimeInProgress'); passing expires deletes it (optionally calling with 'expired'). Missing registry entries delete the event.
Hooks: useEventTracker() → { addEventTracker(event), removeEventTracker(id), deleteEvent(event), clearEvents(), markEventsAsSeen() }; useTrackerEvents() → { events, seen }. Storage accessors eventsStorage, seenEventsStorage, unSeenEventsStorage are exported.
Data collection
Typed, context‑based accumulation of partial data (multi‑step forms):
type Signup = { email: string; name: string };
const CollectedDataContext =
createContext<CollectedDataContextValue<Partial<Signup>>>(undefined);
const DataCollectionContext =
createContext<DataCollectionContextValue<Partial<Signup>>>(undefined);
export const signup = createDataCollector<Signup>({
CollectedDataContext,
DataCollectionContext,
});
// <signup.Provider> ... </signup.Provider>
const { collectData, collectedDataRef } = signup.useDataCollection()!;
collectData({ key: 'email', value }); // or collectData(prev => ({ ...prev, name }))
const { collected } = signup.useCollectedData()!;DataCollectionProvider, useDataCollection(ctx), useCollectedData(ctx) are also exported individually.
Render isolation
Lets a subtree subscribe to a parent's fast‑changing values without re‑rendering the parent's other children.
type Obs = { scrollY: number; isDragging: boolean };
function Owner() {
const isolateRef = useIsolateRef<Obs>();
const { startObserving, stopObserving } = useIsolateObservables({
ref: isolateRef,
observables: { scrollY, isDragging },
isObserving: true,
});
return (
<IsolateRefDependant ref={isolateRef}>
<Header isolateRef={isolateRef} />
</IsolateRefDependant>
);
}
function Header({ isolateRef }: { isolateRef: IsolateRef<Obs> }) {
const { observables, stopObservation } = isolateRef.current!.useObservation({
scrollY: (y) => y > 100, // predicate: re-render only when it returns true
isDragging: true, // re-render on any change
});
return <Title>{observables.scrollY}</Title>;
}useIsolateObservables({ ref, observables, isObserving? })emits an update through anEventEmitterwheneverobservableschanges while observing, tracking which keys changed. Returns{ startObserving, stopObserving }.- The ref exposes
useObservation(subscribeTo?)→{ observables, stopObservation }andget(key)(read the latest snapshot without subscribing). IsolateRefDependantrenders its children only onceref.currentis populated (retries up to 5 times with increasing delay).
Types: IsolateObservables, IsolateRef<T>, IsolateRefObject<T>, IsolateRefData<T>, IsolateObserve<T>, UpdatedIsolateObservables, ChangedIsolateObservables.
Gestures
All three wrap children in a GestureDetector from react-native-gesture-handler.
| Component | Props |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| DragGesture | onDragStart(e), onDrag(e), onDragEnd?(e) (pan gesture, worklet callbacks), minDistance? (default 1), disable?, enableContentScroll? (runs simultaneously with a Gesture.Native() so scroll views inside still scroll) |
| SwipeGesture | direction: 'UP' \| 'DOWN' \| 'LEFT' \| 'RIGHT', onActivation(e) (fling, runOnJS) |
| TwoFingerLongPressGesture | onActivation(e) (two pointers, 1000ms) |
<SwipeGesture direction="UP" onActivation={onSwipeUp}>
<SwipeGesture direction="DOWN" onActivation={onSwipeDown}>
<Layout width={90} height={10} />
</SwipeGesture>
</SwipeGesture>Animations
useDragAnimation
const { onDragStart, onDrag, dragAnimatedStyle, translationX, translationY } =
useDragAnimation();
<DragGesture
onDragStart={onDragStart}
onDrag={(e) => onDrag({ posX: e.translationX, posY: e.translationY })}
>
<AnimatedLayout animatedStyle={dragAnimatedStyle} square={10} />
</DragGesture>;onDrag({ posX, posY, minPosX?, maxPosX?, minPosY?, maxPosY? }) clamps to ± half the screen by default. Also returns prevTranslationX/Y.
ArcSpinnerAnimation
<ArcSpinnerAnimation size={24} color="#999" /> — a rotating arc (1s linear loop).
AnimateComponent (React Native Animated)
Declarative wrapper that creates an Animated.Value, Animated.ValueXY, or a string‑interpolated value depending on initialPosition, builds the composition, and renders Animated.View with the style you derive.
const ref = useRef<AnimateComponentRef<number>>(null);
<AnimateComponent
ref={ref}
initialPosition={0}
toPosition={[{ type: 'timing', toValue: 1, duration: 300, useNativeDriver: true }]}
autoStart
returnToStart
loop={3}
style={(value, { inputRange }) => ({ opacity: value })}
onAnimationEnd={...}
>
{children}
</AnimateComponent>
ref.current?.reverse();initialPosition:number→AnimateValueComponent;string→AnimateStringValueComponent(usesuseAnimatedStringValue,typelimited totiming | spring);{ x, y }→AnimateXYValueComponent.toPosition: one config or an array (sequence). Config = RNTimingAnimationConfig | SpringAnimationConfig | DecayAnimationConfigwith atypediscriminator.autoStart,returnToStart,loop(iterations),onAnimationEnd.- Ref:
{ start, stop, reset, reverse, setValue, value }(setValueisundefinedfor the string variant).
SVG path animations
AnimateSVGPathValueComponent (mode: 'InterpolatePathProps') animates a single 0→1 value and lets you interpolate any PathProps:
<AnimateSVGPathValueComponent
mode="InterpolatePathProps"
animationConfig={{ type: 'timing', duration: 800, useNativeDriver: true }}
autoStart
pathProps={(value, { inputRange }) => ({
d: PATH,
strokeDashoffset: value.interpolate({ inputRange, outputRange: [100, 0] }),
})}
/>AnimateSVGPathValuesComponent (mode: 'AnimatedPathProps') animates several path props from from to a list of to values, in parallel or sequence:
<AnimateSVGPathValuesComponent
mode="AnimatedPathProps"
config={{ type: 'spring', useNativeDriver: true }}
pathProps={{ d: PATH, stroke: '#fff' }}
animatedPathProps={[
{ name: 'strokeWidth', from: 1, to: [4, 2] },
{
name: 'fillOpacity',
from: 0,
to: [1],
config: { type: 'timing', duration: 300, useNativeDriver: true },
},
]}
isSequence
autoStart
/>Both render AnimatedPath (Animated.createAnimatedComponent(Path), also exported) and expose { start, stop, reset, reverse } plus value / values through ref.
SVG
AbsoluteLinearGradient— absolutely positioned full‑size SVG gradient. Props:colors: string[],opacities?(default[1, 1]),start?(default{x:'0',y:'0'}),end?(default{x:'1',y:'0'}),style?. Stops are evenly spaced.LinearGradient— aViewwithposition: 'relative'containingAbsoluteLinearGradientand your children. Same props plusstyle.
Hooks
useDeviceOrientation
Subscribes to Dimensions changes and returns { screenWidth, screenHeight, orientation, relativeX, relativeY, relativeShort, relativeLong, relativeXWorklet, relativeYWorklet, relativeShortWorklet, relativeLongWorklet, normalize, normalizeShort }. Use this (not the static functions) when values must react to rotation or inside Reanimated worklets.
useFontSizes(fontScale = 1)
Returns the FontSize → number map computed with the reactive normalize.
useViewDimensions()
const [layout, onLayout] = useViewDimensions(); — layout is LayoutRectangle | null.
useImageSize / useImageSizes / calculateSize
Fit remote images into maxWidth × maxHeight preserving aspect ratio.
useImageSize({ image, maxWidth, maxHeight })→{ imageSize: { width, height }, calculateSize }useImageSizes({ images, maxWidth, maxHeight })→{ imageSizes, calculateSize }(sequentialImage.getSize)calculateSize({ imageWidth, imageHeight, maxWidth, maxHeight })→{ displayWidth, displayHeight }
useInputRef({ inputValidationFunction? })
Returns [inputRef, onChangeText, inputValue, defaultInputValue]. Keeps the current text on inputRef.current.value without re‑rendering; when a validator rejects input, the hook switches the field to controlled mode so the rejected text is reverted. Wire all four returned values when using a validator.
useKeyboardListeners({ listeners, keyboardHeightRef?, subscribeCondition? })
Registers the given Keyboard listeners (keyboardDidShow, keyboardWillHide, ...) and tracks the keyboard height in a ref. Returns { keyboardHeightRef }.
useScrollableItems({ itemsFetchingFunction, limit?, minFetchDuration?, fetchCooldown?, itemsFetchingFunctionArgs? })
Pagination state for FlatList‑style lists.
const list = useScrollableItems<Post, { userId: string }>({
limit: 20,
itemsFetchingFunction: ({ limit, skip, userId }) =>
api.posts({ limit, skip, userId }),
itemsFetchingFunctionArgs: { userId },
});
<FlatList
data={list.items}
onLayout={list.onLayout}
onEndReached={list.onItemsEndReached}
refreshing={list.refreshingItems}
onRefresh={list.onRefreshItems}
/>;Returns { items, setItems, onLayout, onItemsEndReached, onRefreshItems, refreshingItems, loading, setLoading, updateListItem(id, key, item), removeListItem(id, key), fetchItems({ refresh? }) }. Fetching stops once a page returns fewer than limit items.
useDebounce({ delayInMilliSecs, onTrigger, onPreTrigger?, onPostTrigger?, onDebounceInvocation? })
Returns { debounce(overrides?), cancelDebounce }. Each debounce() call restarts a Scheduler.Timer; overrides can replace the delay and callbacks for that invocation. Default delay 5000ms if 0/falsy.
useInterval({ intervalInMilliSecs, onTrigger, autoStart?, onPreTrigger?, onPostTrigger?, onIntervalInvocation? })
Returns { startInterval(overrides?), stopInterval }. Cleans up on unmount.
useTimeout({ cb, ms }, deps)
setTimeout that resets whenever deps change and clears on unmount.
useTimer({ seconds, onTimerEnds, start })
Countdown; returns { minutes, seconds } and calls onTimerEnds at zero.
useTranslation({ text })
Returns the translated string (initially text) using LocalizationProvider. Logs an error if the provider is missing.
useAnimatedStringValue(inputRange, outputRange, config?)
Creates a stable RN Animated.Value and returns { value, interpolatedValue } where interpolatedValue maps numbers to strings (colours, degrees, ...).
useIsolateObservables / useIsolateRef
See Render isolation.
Storage (MMKV)
All accessors share one MMKV instance (storageAccessorsInstance, id 'rne-csa').
createStorageAccessors(key)
const settings = createStorageAccessors<{ theme: 'dark' | 'light' }>(
'settings'
);
settings.store({ theme: 'dark' });
settings.retrieve(); // { theme: 'dark' } | und