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.
.├── 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.
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.
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.
import { styled } from 'nativewind';import { TouchableOpacity as NTouchableOpacity } from 'react-native';
export const TouchableOpacity = styled(NTouchableOpacity);
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
.
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 thecontentContainerStyle
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.
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
.
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 thecontentContainerStyle
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.
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.
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.
// In Text.tsximport { 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 namestx
- Translation keyvariant
- Text variant, one oftextVariants
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
.
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 ofbuttonVariants
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 onFocus
andonBlur
, or adding left and right icons to the input.
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 labelerror
- 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> );};
Modal
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.
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>;};
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 contenttitle
: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.
export * from './controlled-select';export * from './select';
Props
label
:string
- Input labelerror
:string
- Input error messageoptions
: array of{ label: string; value: string | number }
- List of items to select fromvalue
:string | number
- Selected item valueonSelect
:(option: Option) => void;
- Callback function to handle item selectionplaceholder
:string
- Placeholder textdisabled
: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.