Skip to content

UI Components

The starter comes with a set of basic components and a simple design system based on Nativewind to help you get started and save you time.

All those components can be found in the src/ui/core folder. Most of them are just wrappers around the React Native core components, using the styled function from Nativewind to prepare them for Tailwind CSS class names.

Our philosophy is to keep the components as simple as possible and to avoid adding too much logic to them. This way, they are easier to reuse and customize.

Based on your needs, you can either use them as they are or customize them to fit your needs. You can also create new ones based on the same approach.

./src/ui/
.
├── core
│   ├── activity-indicator.tsx
│   ├── bottom-sheet
│   ├── button.tsx
│   ├── image.tsx
│   ├── index.tsx
│   ├── input
│   ├── list
│   ├── modal
│   ├── pressable.tsx
│   ├── scroll-view.tsx
│   ├── select
│   ├── text.tsx
│   ├── touchable-opacity.tsx
│   └── view.tsx
├── error-handler
│   ├── error-fallback.tsx
│   └── index.tsx
├── focus-aware-status-bar.tsx
├── icons
├── index.tsx
├── screen.tsx
├── theme
│   ├── colors.js
│   ├── constants.tsx
│   └── index.ts
└── utils.tsx

Here is a simple example of more project specific component that uses some primitives components from the ui folder.

src/screens/feed/card.tsx
import React from 'react';
import type { Post } from '@/api';
import { Image, Pressable, Text, View } from '@/ui';
type Props = Post & { onPress?: () => void };
export const Card = ({ title, body, onPress = () => {} }: Props) => {
return (
<Pressable
className="m-2 block overflow-hidden rounded-xl bg-neutral-200 p-2 shadow-xl dark:bg-charcoal-900"
onPress={onPress}
>
<Image
className="h-56 w-full object-cover "
source={{
uri: 'https://images.unsplash.com/photo-1524758631624-e2822e304c36?ixlib=rb-1.2.1&ixid=MnwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2070&q=80',
}}
/>
<View>
<Text variant="md" numberOfLines={1} className="font-bold">
{title}
</Text>
<Text variant="xs" numberOfLines={3}>
{body}
</Text>
</View>
</Pressable>
);
};

View

The View component is a wrapper around the React Native View component with the styled function from Nativewind to accept Tailwind CSS class names.

/src/ui/core/view.tsx
import { styled } from 'nativewind';
import { SafeAreaView as NSafeAreaView, View as RNView } from 'react-native';
export const View = styled(RNView);
export const SafeAreaView = styled(NSafeAreaView);

Props

  • All React Native View Props are supported
  • className - Tailwind CSS class names

Use Case

import * as React from 'react';
import { View, Text } from '@/ui';
const MyComponent = () => {
return (
<View className="flex flex-col items-center justify-center">
<Text>My Component</Text>
</View>
);
};

Touchable Opacity and Pressable

The TouchableOpacity and Pressable components are wrappers around the React Native TouchableOpacity and Pressable components with the styled function from Nativewind to accept Tailwind CSS class names.

src/ui/core/touchable-opacity.tsx
import { styled } from 'nativewind';
import { TouchableOpacity as NTouchableOpacity } from 'react-native';
export const TouchableOpacity = styled(NTouchableOpacity);
src/ui/core/pressable.tsx
import { styled } from 'nativewind';
import { Pressable as NPressable } from 'react-native';
export const Pressable = styled(NPressable);

Props

  • All React Native TouchableOpacity and Pressable Props are supported
  • className - Tailwind CSS class names

Use Case

import * as React from 'react';
import { TouchableOpacity, Text } from '@/ui';
const MyComponent = () => {
return (
<TouchableOpacity
className="flex flex-col items-center justify-center bg-gray-100"
onPress={() => console.log('pressed')}
>
<Text>My Component</Text>
</TouchableOpacity>
);
};

Scroll View

The ScrollView component is a wrapper around the React Native ScrollView component with the styled function from Nativewind to accept Tailwind CSS class names as contentContainerStyle.

src/ui/core/scroll-view.tsx
import { styled } from 'nativewind';
import { ScrollView as NScrollView } from 'react-native';
export const ScrollView = styled(NScrollView, {
classProps: ['contentContainerStyle', 'className'],
});

Props

  • All React Native ScrollView Props are supported
  • className - Tailwind CSS class names that will be applied to the contentContainerStyle

Use Case

import * as React from 'react';
import { ScrollView, Text } from '@/ui';
const MyComponent = () => {
return (
<ScrollView className="p-2">
<Text>My Component1</Text>
<Text>My Component2</Text>
</ScrollView>
);
};

Activity Indicator

The ActivityIndicator component is a wrapper around the React Native ActivityIndicator component with the styled function from Nativewind to accept Tailwind CSS class names.

src/ui/core/activity-indicator.tsx
import { styled } from 'nativewind';
import { ActivityIndicator as NActivityIndicator } from 'react-native';
export const ActivityIndicator = styled(NActivityIndicator);

Props

  • All React Native ActivityIndicator Props are supported
  • className - Tailwind CSS class names

Use Case

import * as React from 'react';
import { ActivityIndicator } from '@/ui';
const MyComponent = () => {
return <ActivityIndicator className="text-blue-500" />;
};

List

The List component is a wrapper around the @shopify/flash-list component with the styled function from Nativewind to accept Tailwind CSS class names as contentContainerStyle.

src/ui/core/list/index.tsx
import { FlashList as NFlashList } from '@shopify/flash-list';
export * from './empty-list';
export const List = NFlashList;

Props

  • All @shopify/flash-list Props are supported
  • className - Tailwind CSS class names that will be applied to the contentContainerStyle
EmptyList

We also provide an EmptyList component that you can use to display a message when the list is empty. Feel free to customize it to fit your needs.

src/ui/core/list/empty-list.tsx
import React from 'react';
import { ActivityIndicator } from 'react-native';
import { NoData } from '../../icons';
import { Text } from '../text';
import { View } from '../view';
type Props = {
isLoading: boolean;
};
export const EmptyList = React.memo(({ isLoading }: Props) => {
return (
<View className="min-h-[400px] flex-1 items-center justify-center">
{!isLoading ? (
<View>
<NoData />
<Text className="pt-4 text-center">Sorry! No data found</Text>
</View>
) : (
<ActivityIndicator />
)}
</View>
);
});

Use Case

import * as React from 'react';
import { List, EmptyList, Text } from '@/ui';
const MyComponent = () => {
return (
<List
data={['Item 1', 'Item 2']}
renderItem={({ item }) => <Text>{item}</Text>}
ListEmptyComponent={<EmptyList message="No items" />}
/>
);
};

Image

For the Image component, we use the expo-image library to provide a fast and performant image component. The Image component is a wrapper around the Image component from expo-image package with the styled function from Nativewind to accept Tailwind CSS className.

src/ui/core/image.tsx
import type { ImageProps } from 'expo-image';
import { Image as NImage } from 'expo-image';
import { styled } from 'nativewind';
import * as React from 'react';
const SImage = styled(NImage);
export type ImgProps = ImageProps & {
className?: string;
};
export const Image = ({
style,
className,
placeholder = 'L6PZfSi_.AyE_3t7t7R**0o#DgR4',
...props
}: ImgProps) => {
return (
<SImage
className={className}
placeholder={placeholder}
style={style}
{...props}
/>
);
};
export const preloadImages = (sources: string[]) => {
NImage.prefetch(sources);
};

Props

  • All expo-image Props are supported
  • className - Tailwind CSS class names

Use Case

import * as React from 'react';
import { Image } from '@/ui';
const MyComponent = () => {
return (
<Image
className="w-32 h-32"
source={{
uri: 'https://images.unsplash.com/photo-1524758631624-e2822e304c36',
}}
/>
);
};

Text

The Text component comes with a set of variants based on a simple object configuration that you can easily customize. With this custom Text component, you can use the translation key as the tx prop, and it will automatically translate the text based on the current locale, as well as support right-to-left (RTL) languages based on the selected locale.

src/ui/core/text.tsx
// In Text.tsx
import { styled } from 'nativewind';
import React from 'react';
import type { TextProps } from 'react-native';
import { StyleSheet, Text as NNText } from 'react-native';
import type { TxKeyPath } from '@/core';
import { isRTL, translate } from '@/core';
const SText = styled(NNText);
interface Props extends TextProps {
variant?: keyof typeof textVariants;
className?: string;
tx?: TxKeyPath;
}
export const textVariants = {
defaults: 'text-base text-black dark:text-white font-inter font-normal',
h1: 'text-[32px] leading-[48px] font-medium',
h2: 'text-[28px] leading-[42px] font-medium',
h3: 'text-[24px] leading-[36px] font-medium',
xl: 'text-[20px] leading-[30px]',
lg: 'text-[18px] leading-[30px]',
md: '',
sm: 'text-[14px] leading-[21px]',
xs: 'text-[12px] leading-[18px]',
error: ' text-[12px] leading-[30px] text-danger-500',
};
export const Text = ({
variant = 'md',
className = '',
style,
tx,
children,
...props
}: Props) => {
const content = tx ? translate(tx) : children;
return (
<SText
className={`
${textVariants.defaults}
${textVariants[variant]}
${className}
`}
style={StyleSheet.flatten([
{ writingDirection: isRTL ? 'rtl' : 'ltr' },
style,
])}
{...props}
>
{content}
</SText>
);
};

Make sure to update the textVariants object in ./src/ui/core/text.tsx to fit your needs.

Props

  • All React Native Text Props are supported
  • className - Tailwind CSS class names
  • tx - Translation key
  • variant - Text variant, one of textVariants objects keys (default: md)

Use Case

import * as React from 'react';
import { Text, View } from '@/ui';
const MyComponent = () => {
return (
<View className="flex flex-col items-center justify-center">
<Text variant="h1" tx="welcome" />
<Text variant="md" className="text-base">
Hello world
</Text>
</View>
);
};

Button

The starter comes with a simple Button component that you can use to create a basic TouchableOpacity with a Text component inside. The Button supports multiple variants: primary, secondary, and outline. These variants are based on a simple object configuration, making it easy to update and add new variants.

Every variant must contain styles for the container, indicator, and text keys. The container style will be applied to the TouchableOpacity, the text style will be applied to the Text component, and the indicator style will be applied to the ActivityIndicator component when the loading prop is set to true.

src/ui/core/button.tsx
import React from 'react';
import type { TouchableOpacityProps } from 'react-native';
import { ActivityIndicator } from './activity-indicator';
import { Text } from './text';
import { TouchableOpacity } from './touchable-opacity';
type Variant = {
container: string;
label: string;
indicator: string;
};
type VariantName = 'defaults' | 'primary' | 'outline' | 'secondary';
type BVariant = {
[key in VariantName]: Variant;
};
export const buttonVariants: BVariant = {
defaults: {
container:
'flex-row items-center justify-center rounded-full px-12 py-3 my-2',
label: 'text-[16px] font-medium text-white',
indicator: 'text-white h-[30px]',
},
primary: {
container: 'bg-black',
label: '',
indicator: 'text-white',
},
secondary: {
container: 'bg-primary-600',
label: 'text-secondary-600',
indicator: 'text-white',
},
outline: {
container: 'border border-neutral-400',
label: 'text-black dark:text-charcoal-100',
indicator: 'text-black',
},
};
interface Props extends TouchableOpacityProps {
variant?: VariantName;
label?: string;
loading?: boolean;
}
export const Button = ({
label,
loading = false,
variant = 'primary',
disabled = false,
...props
}: Props) => {
return (
<TouchableOpacity
disabled={disabled || loading}
className={`
${buttonVariants.defaults.container}
${buttonVariants[variant].container}
${disabled ? 'opacity-50' : ''}
`}
{...props}
>
{loading ? (
<ActivityIndicator
size="small"
className={`
${buttonVariants.defaults.indicator}
${buttonVariants[variant].indicator}
`}
/>
) : (
<Text
className={`
${buttonVariants.defaults.label}
${buttonVariants[variant].label}
`}
>
{label}
</Text>
)}
</TouchableOpacity>
);
};

Props

  • All React Native TouchableOpacity Props are supported
  • variant - Button variant, one of buttonVariants objects keys (default: primary)
  • loading - Show loading indicator (default: false)
  • label - Button label

Use Case

import * as React from 'react';
import { Button, View } from '@/ui';
const MyComponent = () => {
return (
<View className="flex flex-col items-center justify-center">
<Button
variant="primary"
label="Primary"
onPress={() => console.log('Primary Button Pressed')}
/>
<Button
variant="secondary"
label="Secondary"
onPress={() => console.log('Secondary Button Pressed')}
/>
<Button
variant="outline"
label="Outline"
onPress={() => console.log('Outline Button Pressed')}
/>
</View>
);
};

Input

We provide a simple Input component with a Text component for the label and a TextInput component for the input. This input comes with custom focus and error styling.

You can use it in the same way you use the TextInput component from React Native, but with additional props to customize the label and error styling.

We tried to keep the Input component as simple as possible, but you can add more functionality, such as onFocusandonBlur, or adding left and right icons to the input.

src/ui/core/input/input.tsx
import { styled, useColorScheme } from 'nativewind';
import * as React from 'react';
import type { TextInput, TextInputProps } from 'react-native';
import { StyleSheet } from 'react-native';
import { TextInput as NTextInput } from 'react-native';
import { isRTL } from '@/core';
import colors from '../../theme/colors';
import { Text } from '../text';
import { View } from '../view';
const STextInput = styled(NTextInput);
export interface NInputProps extends TextInputProps {
label?: string;
disabled?: boolean;
error?: string;
}
export const Input = React.forwardRef<TextInput, NInputProps>((props, ref) => {
const { label, error, ...inputProps } = props;
const { colorScheme } = useColorScheme();
const isDark = colorScheme === 'dark';
const [isFocussed, setIsFocussed] = React.useState(false);
const onBlur = React.useCallback(() => setIsFocussed(false), []);
const onFocus = React.useCallback(() => setIsFocussed(true), []);
const borderColor = error
? 'border-danger-600'
: isFocussed
? isDark
? 'border-white'
: 'border-neutral-600'
: isDark
? 'border-charcoal-700'
: 'border-neutral-400';
const bgColor = isDark
? 'bg-charcoal-800'
: error
? 'bg-danger-50'
: 'bg-neutral-200';
const textDirection = isRTL ? 'text-right' : 'text-left';
return (
<View className="mb-4">
{label && (
<Text
variant="md"
className={
error
? 'text-danger-600'
: isDark
? 'text-charcoal-100'
: 'text-black'
}
>
{label}
</Text>
)}
<STextInput
testID="STextInput"
ref={ref}
placeholderTextColor={colors.neutral[400]}
className={`mt-0 border-[1px] py-4 px-2 ${borderColor} rounded-md ${bgColor} text-[16px] ${textDirection} dark:text-charcoal-100`}
onBlur={onBlur}
onFocus={onFocus}
{...inputProps}
style={StyleSheet.flatten([
{ writingDirection: isRTL ? 'rtl' : 'ltr' },
])}
/>
{error && <Text variant="error">{error}</Text>}
</View>
);
});

Props

  • All React Native TextInput Props are supported
  • label - Input label
  • error - Input error message

Use Case

import * as React from 'react';
import { Input, View } from '@/ui';
const MyComponent = () => {
return (
<View className="flex flex-col items-center justify-center">
<Input label="Email" error="Email is required" />
</View>
);
};

We provide a simple Modal component using the @gorhom/bottom-sheet library to display a modal at the bottom of the screen.

We opt to use a bottom sheet instead of a modal to make it more flexible and easy to use. as well as having full control over the logic and the UI.

Based on your needs, you can use the Modal or DynamicModal if you don’t have a fixed height for the modal content.

src/ui/core/modal/modal.tsx
import type { BottomSheetModalProps } from '@gorhom/bottom-sheet';
import { BottomSheetModal } from '@gorhom/bottom-sheet';
import * as React from 'react';
import { View } from '../view';
import { renderBackdrop } from './modal-backdrop';
import { ModalHeader } from './modal-header';
import type { ModalProps, ModalRef } from './types';
export const useModal = () => {
const ref = React.useRef<BottomSheetModal>(null);
const present = React.useCallback((data?: any) => {
ref.current?.present(data);
}, []);
const dismiss = React.useCallback(() => {
ref.current?.dismiss();
}, []);
return { ref, present, dismiss };
};
export const Modal = React.forwardRef(
(
{
snapPoints: _snapPoints = ['60%'],
title,
detached = false,
...props
}: ModalProps,
ref: ModalRef
) => {
const detachedProps = React.useMemo(
() => getDetachedProps(detached),
[detached]
);
const modal = useModal();
const snapPoints = React.useMemo(() => _snapPoints, [_snapPoints]);
React.useImperativeHandle(
ref,
() => (modal.ref.current as BottomSheetModal) || null
);
const renderHandleComponent = React.useCallback(
() => (
<>
<View className="mt-2 h-1 w-12 self-center rounded-lg bg-gray-400 dark:bg-gray-700" />
<ModalHeader title={title} dismiss={modal.dismiss} />
</>
),
[title, modal.dismiss]
);
return (
<BottomSheetModal
{...props}
{...detachedProps}
ref={modal.ref}
index={0}
snapPoints={snapPoints}
backdropComponent={props.backdropComponent || renderBackdrop}
handleComponent={renderHandleComponent}
/>
);
}
);
const getDetachedProps = (detached: boolean) => {
if (detached) {
return {
detached: true,
bottomInset: 46,
style: { marginHorizontal: 16, overflow: 'hidden' },
} as Partial<BottomSheetModalProps>;
}
return {} as Partial<BottomSheetModalProps>;
};
src/ui/core/modal/dynamic-modal.tsx
import type { BottomSheetModal } from '@gorhom/bottom-sheet';
import {
BottomSheetView,
useBottomSheetDynamicSnapPoints,
} from '@gorhom/bottom-sheet';
import * as React from 'react';
import { Modal, useModal } from './modal';
import type { DynamicModalProps, ModalRef } from './types';
export const DynamicModal = React.forwardRef(
(
{ snapPoints = ['CONTENT_HEIGHT'], children, ...props }: DynamicModalProps,
ref: ModalRef
) => {
const modal = useModal();
const {
animatedHandleHeight,
animatedSnapPoints,
animatedContentHeight,
handleContentLayout,
} = useBottomSheetDynamicSnapPoints(snapPoints as Array<number | string>); // cast to remove shared values type
React.useImperativeHandle(
ref,
() => (modal.ref.current as BottomSheetModal) || null
);
return (
<Modal
{...props}
ref={modal.ref}
snapPoints={animatedSnapPoints}
handleHeight={animatedHandleHeight}
contentHeight={animatedContentHeight}
>
<BottomSheetView onLayout={handleContentLayout}>
{children}
</BottomSheetView>
</Modal>
);
}
);

Props

  • All @gorhom/bottom-sheet Props are supported
  • children - Modal content
  • title: string - Modal title

Use Case

import * as React from 'react';
import { Modal, useModal, View, Button, Text } from '@/ui';
const MyComponent = () => {
const modal = useModal();
const dynamicModal = useModal();
return (
<View className="flex flex-col items-center justify-center">
<Button variant="primary" label="Show Modal" onPress={modal.present} />
<Modal ref={modal.ref} title="modal title" snapPoints={['60%']}>
<Text>Modal Content</Text>
</Modal>
<Button
variant="primary"
label="Show Modal"
onPress={dynamicModal.present}
/>
<DynamicModal ref={dynamicModal.ref} title="Dynamic modal title">
<Text> Dynamic modal Content</Text>
</DynamicModal>
</View>
);
};

Controlled Input

We provide a simple ControlledInput component that uses the Input component under the hood but with a useController hook from react-hook-form to make it ready to use with react-hook-form library.

Read more about Handling Forms here.

Select

We provide a simple SelectInput component using a bottom sheet with a simple List component to select an item from a list of items.

We opt to use a bottom sheet instead of a dropdown to make it more flexible and easy to use on both iOS and Android and also to minimize the number of dependencies in the starter.

Feel free to update the component implementation to fit your need and as you keep the same Props signature for the SelectInput component the component will work with our form handling solution without any changes.

src/ui/core/select/index.tsx
export * from './controlled-select';
export * from './select';

Props

  • label: string - Input label
  • error: string - Input error message
  • options : array of { label: string; value: string | number } - List of items to select from
  • value : string | number - Selected item value
  • onSelect: (option: Option) => void; - Callback function to handle item selection
  • placeholder: string- Placeholder text
  • disabled: boolean - Disable select input (default: false)

Use Case

import * as React from 'react';
import type { Option } from '@/ui';
import { SelectInput, View } from '@/ui';
const options: Option[] = [
{ value: 'chocolate', label: 'Chocolate' },
{ value: 'strawberry', label: 'Strawberry' },
{ value: 'vanilla', label: 'Vanilla' },
];
const MyComponent = () => {
const [value, setValue] = React.useState<string | number | undefined>();
return (
<View className="flex flex-col items-center justify-center">
<Select
label="Select"
error="Select is required"
options={options}
value={value}
onSelect={(option) => setValue(option.value)}
/>
</View>
);
};

Controlled Input

We provide a simple ControlledSelect component that uses the Select component under the hood but with a useController hook from react-hook-form to make it ready to use with react-hook-form library.

Read more about Handling Forms here.