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. 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.

  • Directoryui ## core ui and theme configuration
    • button.tsx
    • checkbox.tsx
    • colors.js
    • focus-aware-status-bar.tsx
    • Directoryicons/
    • image.tsx
    • index.tsx
    • input.tsx
    • list.tsx
    • modal.tsx
    • progress-bar.tsx
    • select.tsx
    • text.tsx
    • utils.tsx

List

The List component references the FlashList component from the @shopify/flash-list package.

src/ui/list.tsx
import { FlashList as NFlashList } from '@shopify/flash-list';
import { memo } from 'react';
import { ActivityIndicator, View } from 'react-native';
import Svg, { Circle, Path } from 'react-native-svg';
import { Text } from './text';
type Props = {
isLoading: boolean;
};
export const List = NFlashList;
export const EmptyList = memo(({ isLoading }: Props) => (
<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>
));
export const NoData = () => (
<Svg width={200} height={200} viewBox="0 0 647.636 632.174">
<Path
d="M411.146 142.174h-174.51a15.018 15.018 0 0 0-15 15v387.85l-2 .61-42.81 13.11a8.007 8.007 0 0 1-9.99-5.31l-127.34-415.95a8.003 8.003 0 0 1 5.31-9.99l65.97-20.2 191.25-58.54 65.97-20.2a7.99 7.99 0 0 1 9.99 5.3l32.55 106.32Z"
fill="#f2f2f2"
/>
<Path
d="m449.226 140.174-39.23-128.14a16.994 16.994 0 0 0-21.23-11.28l-92.75 28.39-191.24 58.55-92.75 28.4a17.015 17.015 0 0 0-11.28 21.23l134.08 437.93a17.027 17.027 0 0 0 16.26 12.03 16.79 16.79 0 0 0 4.97-.75l63.58-19.46 2-.62v-2.09l-2 .61-64.17 19.65a15.015 15.015 0 0 1-18.73-9.95L2.666 136.734a14.98 14.98 0 0 1 9.95-18.73l92.75-28.4 191.24-58.54 92.75-28.4a15.156 15.156 0 0 1 4.41-.66 15.015 15.015 0 0 1 14.32 10.61l39.05 127.56.62 2h2.08Z"
fill="#3f3d56"
/>
<Path
d="M122.68 127.82a9.016 9.016 0 0 1-8.61-6.366l-12.88-42.072a8.999 8.999 0 0 1 5.97-11.24L283.1 14.278a9.009 9.009 0 0 1 11.24 5.971l12.88 42.072a9.01 9.01 0 0 1-5.97 11.241l-175.94 53.864a8.976 8.976 0 0 1-2.63.395Z"
fill="#7eb55a"
/>
<Circle cx={190.154} cy={24.955} r={20} fill="#7eb55a" />
<Circle cx={190.154} cy={24.955} r={12.665} fill="#fff" />
<Path
d="M602.636 582.174h-338a8.51 8.51 0 0 1-8.5-8.5v-405a8.51 8.51 0 0 1 8.5-8.5h338a8.51 8.51 0 0 1 8.5 8.5v405a8.51 8.51 0 0 1-8.5 8.5Z"
fill="#e6e6e6"
/>
<Path
d="M447.136 140.174h-210.5a17.024 17.024 0 0 0-17 17v407.8l2-.61v-407.19a15.018 15.018 0 0 1 15-15h211.12Zm183.5 0h-394a17.024 17.024 0 0 0-17 17v458a17.024 17.024 0 0 0 17 17h394a17.024 17.024 0 0 0 17-17v-458a17.024 17.024 0 0 0-17-17Zm15 475a15.018 15.018 0 0 1-15 15h-394a15.018 15.018 0 0 1-15-15v-458a15.018 15.018 0 0 1 15-15h394a15.018 15.018 0 0 1 15 15Z"
fill="#3f3d56"
/>
<Path
d="M525.636 184.174h-184a9.01 9.01 0 0 1-9-9v-44a9.01 9.01 0 0 1 9-9h184a9.01 9.01 0 0 1 9 9v44a9.01 9.01 0 0 1-9 9Z"
fill="#7eb55a"
/>
<Circle cx={433.636} cy={105.174} r={20} fill="#7eb55a" />
<Circle cx={433.636} cy={105.174} r={12.182} fill="#fff" />
</Svg>
);

Props

  • All @shopify/flash-list Props are supported

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.

Use Case

import 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 additional styling provided by nativewind. The cssInterop function from nativewind is used to apply styling and, in this way, the className property is applied to the style property of the Image component.

src/ui/image.tsx
import type { ImageProps } from 'expo-image';
import { Image as NImage } from 'expo-image';
import { cssInterop } from 'nativewind';
export type ImgProps = ImageProps & {
className?: string;
};
cssInterop(NImage, { className: 'style' });
export const Image = ({
style,
className,
placeholder = 'L6PZfSi_.AyE_3t7t7R**0o#DgR4',
...props
}: ImgProps) => (
<NImage
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 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

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/text.tsx
import { useMemo } from 'react';
import type { TextProps, TextStyle } from 'react-native';
import { I18nManager, StyleSheet, Text as NNText } from 'react-native';
import { twMerge } from 'tailwind-merge';
import type { TxKeyPath } from '@/core/i18n';
import { translate } from '@/core/i18n';
interface Props extends TextProps {
className?: string;
tx?: TxKeyPath;
}
export const Text = ({
className = '',
style,
tx,
children,
...props
}: Props) => {
const textStyle = useMemo(
() =>
twMerge(
'text-base text-black dark:text-white font-inter font-normal',
className
),
[className]
);
const nStyle = useMemo(
() =>
StyleSheet.flatten([
{
writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr',
},
style,
]) as TextStyle,
[style]
);
return (
<NNText className={textStyle} style={nStyle} {...props}>
{tx ? translate(tx) : children}
</NNText>
);
};

Props

  • All React Native Text Props are supported
  • className - Tailwind CSS class names
  • tx - Translation key

Use Case

import React from 'react';
import { Text, View } from 'react-native';
const MyComponent = () => {
return (
<View className="flex flex-col items-center justify-center">
<Text className="text-2xl" tx="welcome" />
<Text className="text-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 Pressable with a Text using Tailwind CSS classes and variant definitions. These variants’ logic is based on the tailwind-variants package.

The tv function from tailwind-variants is used to create a function that generates a styling configuration object for the Button component based on slot definitions, variant, size , disabled status, full-width, and default variants. Consequently, the styles defines the styles for the Button based on the provided props using the button function.

Each variant should include styles for the container, indicator, and label keys. The container style is for the Pressable, the label style is for the Text component, and the indicator style is for the ActivityIndicator component when the loading prop is true.

src/ui/button.tsx
import { forwardRef, useMemo } from 'react';
import type { PressableProps, View } from 'react-native';
import { ActivityIndicator, Pressable, Text } from 'react-native';
import type { VariantProps } from 'tailwind-variants';
import { tv } from 'tailwind-variants';
const TEXT_WHITE = 'text-white';
const button = tv({
slots: {
container: 'my-2 flex flex-row items-center justify-center rounded-md px-4',
label: 'font-inter text-base font-semibold',
indicator: 'h-6 text-white',
},
variants: {
variant: {
default: {
container: 'bg-black dark:bg-white',
label: 'text-white dark:text-black',
indicator: 'text-white dark:text-black',
},
secondary: {
container: 'bg-primary-600',
label: 'text-secondary-600',
indicator: TEXT_WHITE,
},
outline: {
container: 'border border-neutral-400',
label: 'text-black dark:text-neutral-100',
indicator: 'text-black dark:text-neutral-100',
},
destructive: {
container: 'bg-red-600',
label: TEXT_WHITE,
indicator: TEXT_WHITE,
},
ghost: {
container: 'bg-transparent',
label: 'text-black underline dark:text-white',
indicator: 'text-black dark:text-white',
},
link: {
container: 'bg-transparent',
label: 'text-black',
indicator: 'text-black',
},
},
size: {
default: {
container: 'h-10 px-4',
label: 'text-base',
},
lg: {
container: 'h-12 px-8',
label: 'text-xl',
},
sm: {
container: 'h-8 px-3',
label: 'text-sm',
indicator: 'h-2',
},
icon: { container: 'h-9 w-9' },
},
disabled: {
true: {
container: 'bg-neutral-300 dark:bg-neutral-300',
label: 'text-neutral-600 dark:text-neutral-600',
indicator: 'text-neutral-400 dark:text-neutral-400',
},
},
fullWidth: {
true: {
container: '',
},
false: {
container: 'self-center',
},
},
},
defaultVariants: {
variant: 'default',
disabled: false,
fullWidth: true,
size: 'default',
},
});
type ButtonVariants = VariantProps<typeof button>;
interface Props extends ButtonVariants, Omit<PressableProps, 'disabled'> {
label?: string;
loading?: boolean;
className?: string;
textClassName?: string;
}
export const Button = forwardRef<View, Props>(
(
{
label: text,
loading = false,
variant = 'default',
disabled = false,
size = 'default',
className = '',
testID,
textClassName = '',
...props
},
ref
) => {
const styles = useMemo(
() => button({ variant, disabled, size }),
[variant, disabled, size]
);
return (
<Pressable
disabled={disabled || loading}
className={styles.container({ className })}
{...props}
ref={ref}
testID={testID}
>
{props.children ? (
props.children
) : (
<>
{loading ? (
<ActivityIndicator
size="small"
className={styles.indicator()}
testID={testID ? `${testID}-activity-indicator` : undefined}
/>
) : (
<Text
testID={testID ? `${testID}-label` : undefined}
className={styles.label({ className: textClassName })}
>
{text}
</Text>
)}
</>
)}
</Pressable>
);
}
);

Props

  • All React Native Pressable Props are supported.
  • variant - Button variant, one of variant objects keys (default: default)
  • loading - Show loading indicator (default: false)
  • label - Button label
  • size - Button size, one of variants size objects keys (default: default)
  • className - Tailwind CSS class names to be applied to the Button’s container
  • textClassName - Additional styling for the Button’s label

Use Case

src/components/buttons.tsx
import { Button, View } from '@/ui';
import { Title } from './title';
export const Buttons = () => (
<>
<Title text="Buttons" />
<View>
<View className="flex-row flex-wrap">
<Button label="small" size="sm" className="mr-2" />
<Button
label="small"
loading
size="sm"
className="mr-2 min-w-[60px]"
/>
<Button
label="small"
size="sm"
variant="secondary"
className="mr-2"
/>
<Button label="small" size="sm" variant="outline" className="mr-2" />
<Button
label="small"
size="sm"
variant="destructive"
className="mr-2"
/>
<Button label="small" size="sm" variant="ghost" className="mr-2" />
<Button label="small" size="sm" disabled className="mr-2" />
</View>
<Button label="Default Button" />
<Button label="Secondary Button" variant="secondary" />
<Button label="Outline Button" variant="outline" />
<Button label="Destructive Button" variant="destructive" />
<Button label="Ghost Button" variant="ghost" />
<Button label="Button" loading={true} />
<Button label="Button" loading={true} variant="outline" />
<Button label="Default Button Disabled" disabled />
<Button
label="Secondary Button Disabled"
disabled
variant="secondary"
/>
</View>
</>
);

Input

We provide a simple Input component with a Text component for the label and a TextInput component for the input.

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.

The component utilizes the tv function from Tailwind Variants to define styling slots and variants for different states such as focused, error, and disabled. These styles are applied dynamically based on the component’s state and props.

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

src/ui/input.tsx
import React, { forwardRef, useCallback, useMemo, useState } from 'react';
import type {
Control,
FieldValues,
Path,
RegisterOptions,
} from 'react-hook-form';
import { useController } from 'react-hook-form';
import type { TextInputProps } from 'react-native';
import { I18nManager, StyleSheet, TextInput as NTextInput,View } from 'react-native';
import { tv } from 'tailwind-variants';
import colors from './colors';
import { Text } from './text';
const inputTv = tv({
slots: {
container: 'mb-2',
label: 'text-grey-100 mb-1 text-lg dark:text-neutral-100',
input:
'mt-0 rounded-xl border-[0.5px] border-neutral-300 bg-neutral-100 px-4 py-3 font-inter text-base font-medium leading-5 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white',
},
variants: {
focused: {
true: {
input: 'border-neutral-400 dark:border-neutral-300',
},
},
error: {
true: {
input: 'border-danger-600',
label: 'text-danger-600 dark:text-danger-600',
},
},
disabled: {
true: {
input: 'bg-neutral-200',
},
},
},
defaultVariants: {
focused: false,
error: false,
disabled: false,
},
});
export interface NInputProps extends TextInputProps {
label?: string;
disabled?: boolean;
error?: string;
}
type TRule<T extends FieldValues> =
| Omit<
RegisterOptions<T>,
'disabled' | 'valueAsNumber' | 'valueAsDate' | 'setValueAs'
>
| undefined;
export type RuleType<T extends FieldValues> = { [name in keyof T]: TRule<T> };
export type InputControllerType<T extends FieldValues> = {
name: Path<T>;
control: Control<T>;
rules?: RuleType<T>;
};
interface ControlledInputProps<T extends FieldValues>
extends NInputProps,
InputControllerType<T> {}
export const Input = forwardRef<NTextInput, NInputProps>((props, ref) => {
const { label, error, testID, ...inputProps } = props;
const [isFocussed, setIsFocussed] = useState(false);
const onBlur = useCallback(() => setIsFocussed(false), []);
const onFocus = useCallback(() => setIsFocussed(true), []);
const styles = useMemo(
() =>
inputTv({
error: Boolean(error),
focused: isFocussed,
disabled: Boolean(props.disabled),
}),
[error, isFocussed, props.disabled]
);
return (
<View className={styles.container()}>
{label && (
<Text
testID={testID ? `${testID}-label` : undefined}
className={styles.label()}
>
{label}
</Text>
)}
<NTextInput
testID={testID}
ref={ref}
placeholderTextColor={colors.neutral[400]}
className={styles.input()}
onBlur={onBlur}
onFocus={onFocus}
{...inputProps}
style={StyleSheet.flatten([
{ writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr' },
{ textAlign: I18nManager.isRTL ? 'right' : 'left' },
inputProps.style,
])}
/>
{error && (
<Text
testID={testID ? `${testID}-error` : undefined}
className="text-sm text-danger-400 dark:text-danger-600"
>
{error}
</Text>
)}
</View>
);
});
// only used with react-hook-form
export function ControlledInput<T extends FieldValues>(
props: ControlledInputProps<T>
) {
const { name, control, rules, ...inputProps } = props;
const { field, fieldState } = useController({ control, name, rules });
return (
<Input
ref={field.ref}
autoCapitalize="none"
onChangeText={field.onChange}
value={(field.value as string) || ''}
{...inputProps}
error={fieldState.error?.message}
/>
);
}

Props

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

We provide also 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.

Use Case

import 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 if you don’t have a fixed height for the modal content.

src/ui/modal.tsx
/**
* Modal
* Dependencies:
* - @gorhom/bottom-sheet.
*
* Props:
* - All `BottomSheetModalProps` props.
* - `title` (string | undefined): Optional title for the modal header.
*
* Usage Example:
* import { Modal, useModal } from '@gorhom/bottom-sheet';
*
* function DisplayModal() {
* const { ref, present, dismiss } = useModal();
*
* return (
* <View>
* <Modal
* snapPoints={['60%']} // optional
* title="Modal Title"
* ref={ref}
* >
* Modal Content
* </Modal>
* </View>
* );
* }
*
*/
import type {
BottomSheetBackdropProps,
BottomSheetModalProps,
} from '@gorhom/bottom-sheet';
import { BottomSheetModal, useBottomSheet } from '@gorhom/bottom-sheet';
import type { ForwardedRef } from 'react';
import {
forwardRef,
memo,
useCallback,
useImperativeHandle,
useMemo,
useRef,
} from 'react';
import { Pressable, View } from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { Path, Svg } from 'react-native-svg';
import { Text } from './text';
type ModalProps = BottomSheetModalProps & {
title?: string;
};
type ModalRef = ForwardedRef<BottomSheetModal>;
type ModalHeaderProps = {
title?: string;
dismiss: () => void;
};
export const useModal = () => {
const ref = useRef<BottomSheetModal>(null);
const present = useCallback((data?: unknown) => {
ref.current?.present(data);
}, []);
const dismiss = useCallback(() => {
ref.current?.dismiss();
}, []);
return { ref, present, dismiss };
};
export const Modal = forwardRef(
(
{
snapPoints: _snapPoints = ['60%'],
title,
detached = false,
...props
}: ModalProps,
ref: ModalRef
) => {
const detachedProps = useMemo(() => getDetachedProps(detached), [detached]);
const modal = useModal();
const snapPoints = useMemo(() => _snapPoints, [_snapPoints]);
useImperativeHandle(
ref,
() => (modal.ref.current as BottomSheetModal) || null
);
const renderHandleComponent = useCallback(
() => (
<>
<View className="mb-8 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}
/>
);
}
);
/**
* Custom Backdrop
*/
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
const CustomBackdrop = ({ style }: BottomSheetBackdropProps) => {
const { close } = useBottomSheet();
return (
<AnimatedPressable
onPress={() => close()}
entering={FadeIn.duration(50)}
exiting={FadeOut.duration(20)}
style={[style, { backgroundColor: 'rgba(0, 0, 0, 0.4)' }]}
/>
);
};
export const renderBackdrop = (props: BottomSheetBackdropProps) => (
<CustomBackdrop {...props} />
);
/**
*
* @param detached
* @returns
*
* @description
* In case the modal is detached, we need to add some extra props to the modal to make it look like a detached modal.
*/
const getDetachedProps = (detached: boolean) => {
if (detached) {
return {
detached: true,
bottomInset: 46,
style: { marginHorizontal: 16, overflow: 'hidden' },
} as Partial<BottomSheetModalProps>;
}
return {} as Partial<BottomSheetModalProps>;
};
/**
* ModalHeader
*/
const ModalHeader = memo(({ title, dismiss }: ModalHeaderProps) => (
<>
{title && (
<View className="flex-row px-2 py-4">
<View className="h-[24px] w-[24px]" />
<View className="flex-1">
<Text className="text-center text-[16px] font-bold text-[#26313D] dark:text-white">
{title}
</Text>
</View>
</View>
)}
<CloseButton close={dismiss} />
</>
));
const CloseButton = ({ close }: { close: () => void }) => (
<Pressable
onPress={close}
className="absolute right-3 top-3 h-[24px] w-[24px] items-center justify-center "
hitSlop={{ top: 20, bottom: 20, left: 20, right: 20 }}
accessibilityLabel="close modal"
accessibilityRole="button"
accessibilityHint="closes the modal"
>
<Svg
className="fill-neutral-300 dark:fill-white"
width={24}
height={24}
fill="none"
viewBox="0 0 24 24"
>
<Path d="M18.707 6.707a1 1 0 0 0-1.414-1.414L12 10.586 6.707 5.293a1 1 0 0 0-1.414 1.414L10.586 12l-5.293 5.293a1 1 0 1 0 1.414 1.414L12 13.414l5.293 5.293a1 1 0 0 0 1.414-1.414L13.414 12l5.293-5.293Z" />
</Svg>
</Pressable>
);

Props

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

Use Case

import React from 'react';
import { Modal, useModal, View, Button, Text } from '@/ui';
const MyComponent = () => {
const modal = 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>
</View>
);
};

Select

We provide a simple Select 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.

The component uses the tv function from Tailwind Variants to define styling slots and variants for different states such as error, and disabled. These styles are applied dynamically based on the component’s state and props.

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

src/ui/select.tsx
/* eslint-disable max-lines-per-function */
import {
BottomSheetFlatList,
type BottomSheetModal,
} from '@gorhom/bottom-sheet';
import { FlashList } from '@shopify/flash-list';
import { useColorScheme } from 'nativewind';
import { forwardRef, memo, useCallback, useMemo } from 'react';
import type { FieldValues } from 'react-hook-form';
import { useController } from 'react-hook-form';
import { Platform, TouchableOpacity, View } from 'react-native';
import { Pressable, type PressableProps } from 'react-native';
import type { SvgProps } from 'react-native-svg';
import Svg, { Path } from 'react-native-svg';
import { tv } from 'tailwind-variants';
import colors from '@/ui/colors';
import { CaretDown } from '@/ui/icons';
import type { InputControllerType } from './input';
import { Modal, useModal } from './modal';
import { Text } from './text';
const selectTv = tv({
slots: {
container: 'mb-4',
label: 'text-grey-100 mb-1 text-lg dark:text-neutral-100',
input:
'border-grey-50 mt-0 flex-row items-center justify-center rounded-xl border-[0.5px] p-3 dark:border-neutral-500 dark:bg-neutral-800',
inputValue: 'dark:text-neutral-100',
},
variants: {
focused: {
true: {
input: 'border-neutral-600',
},
},
error: {
true: {
input: 'border-danger-600',
label: 'text-danger-600 dark:text-danger-600',
inputValue: 'text-danger-600',
},
},
disabled: {
true: {
input: 'bg-neutral-200',
},
},
},
defaultVariants: {
error: false,
disabled: false,
},
});
const List = Platform.OS === 'web' ? FlashList : BottomSheetFlatList;
export type OptionType = { label: string; value: string | number };
type OptionsProps = {
options: OptionType[];
onSelect: (option: OptionType) => void;
value?: string | number;
testID?: string;
};
function keyExtractor(item: OptionType) {
return `select-item-${item.value}`;
}
export const Options = forwardRef<BottomSheetModal, OptionsProps>(
({ options, onSelect, value, testID }, ref) => {
const height = options.length * 70 + 100;
const snapPoints = useMemo(() => [height], [height]);
const { colorScheme } = useColorScheme();
const isDark = colorScheme === 'dark';
const renderSelectItem = useCallback(
({ item }: { item: OptionType }) => (
<Option
key={`select-item-${item.value}`}
label={item.label}
selected={value === item.value}
onPress={() => onSelect(item)}
testID={testID ? `${testID}-item-${item.value}` : undefined}
/>
),
[onSelect, value, testID]
);
return (
<Modal
ref={ref}
index={0}
snapPoints={snapPoints}
backgroundStyle={{
backgroundColor: isDark ? colors.neutral[800] : colors.white,
}}
>
<List
data={options}
keyExtractor={keyExtractor}
renderItem={renderSelectItem}
testID={testID ? `${testID}-modal` : undefined}
estimatedItemSize={52}
/>
</Modal>
);
}
);
const Option = memo(
({
label,
selected = false,
...props
}: PressableProps & {
selected?: boolean;
label: string;
}) => (
<Pressable
className="flex-row items-center border-b border-neutral-300 bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-800"
{...props}
>
<Text className="flex-1 dark:text-neutral-100 ">{label}</Text>
{selected && <Check />}
</Pressable>
)
);
export interface SelectProps {
value?: string | number;
label?: string;
disabled?: boolean;
error?: string;
options?: OptionType[];
onSelect?: (value: string | number) => void;
placeholder?: string;
testID?: string;
}
interface ControlledSelectProps<T extends FieldValues>
extends SelectProps,
InputControllerType<T> {}
export const Select = (props: SelectProps) => {
const {
label,
value,
error,
options = [],
placeholder = 'select...',
disabled = false,
onSelect,
testID,
} = props;
const modal = useModal();
const onSelectOption = useCallback(
(option: OptionType) => {
onSelect?.(option.value);
modal.dismiss();
},
[modal, onSelect]
);
const styles = useMemo(
() =>
selectTv({
error: Boolean(error),
disabled,
}),
[error, disabled]
);
const textValue = useMemo(
() =>
value !== undefined
? options?.filter((t) => t.value === value)?.[0]?.label ?? placeholder
: placeholder,
[value, options, placeholder]
);
return (
<>
<View className={styles.container()}>
{label && (
<Text
testID={testID ? `${testID}-label` : undefined}
className={styles.label()}
>
{label}
</Text>
)}
<TouchableOpacity
className={styles.input()}
disabled={disabled}
onPress={modal.present}
testID={testID ? `${testID}-trigger` : undefined}
>
<View className="flex-1">
<Text className={styles.inputValue()}>{textValue}</Text>
</View>
<CaretDown />
</TouchableOpacity>
{error && (
<Text
testID={`${testID}-error`}
className="text-sm text-danger-300 dark:text-danger-600"
>
{error}
</Text>
)}
</View>
<Options
testID={testID}
ref={modal.ref}
options={options}
onSelect={onSelectOption}
/>
</>
);
};
// only used with react-hook-form
export function ControlledSelect<T extends FieldValues>(
props: ControlledSelectProps<T>
) {
const { name, control, rules, onSelect: onNSelect, ...selectProps } = props;
const { field, fieldState } = useController({ control, name, rules });
const onSelect = useCallback(
(value: string | number) => {
field.onChange(value);
onNSelect?.(value);
},
[field, onNSelect]
);
return (
<Select
onSelect={onSelect}
value={field.value}
error={fieldState.error?.message}
{...selectProps}
/>
);
}
const Check = ({ ...props }: SvgProps) => (
<Svg
width={25}
height={24}
fill="none"
viewBox="0 0 25 24"
{...props}
className="stroke-black dark:stroke-white"
>
<Path
d="m20.256 6.75-10.5 10.5L4.506 12"
strokeWidth={2.438}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);

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 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 Select

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.

Checkbox, Radio & Switch

We provide a set of three simple and customizable components including a Checkbox, a Radio, and a Switch, which share the same logic under the hood.

The Checkbox, Switch, and Radio components are very similar as they share a common structure and are supposed to handle boolean values, their primary difference being the icon they display and the associated accessibility label. Each component accepts a range of props, allowing us to customize their appearance, behavior, and accessibility features.

For handling common functionality like handling press events and accessibility states we have the Root component. It wraps its children in a Pressable component and passes along props.

Animations are applied to the icons using the MotiView component from the moti library. These animations change the appearance of the icons based on their checked state.

src/ui/checkbox.tsx
import { MotiView } from 'moti';
import { useCallback } from 'react';
import {
I18nManager,
Pressable,
type PressableProps,
View,
} from 'react-native';
import Svg, { Path } from 'react-native-svg';
import colors from '@/ui/colors';
import { Text } from './text';
const SIZE = 20;
const WIDTH = 50;
const HEIGHT = 28;
const THUMB_HEIGHT = 22;
const THUMB_WIDTH = 22;
const THUMB_OFFSET = 4;
export interface RootProps extends Omit<PressableProps, 'onPress'> {
onChange: (checked: boolean) => void;
checked?: boolean;
className?: string;
accessibilityLabel: string;
}
export type IconProps = {
checked: boolean;
};
export const Root = ({
checked,
children,
onChange,
disabled,
className = '',
...props
}: RootProps) => {
const handleChange = useCallback(() => {
onChange(!checked);
}, [onChange, checked]);
return (
<Pressable
onPress={handleChange}
className={`flex-row items-center ${className} ${
disabled ? 'opacity-50' : ''
}`}
accessibilityState={{ checked }}
disabled={disabled}
{...props}
>
{children}
</Pressable>
);
};
type LabelProps = {
text: string;
className?: string;
testID?: string;
};
const Label = ({ text, testID, className = '' }: LabelProps) => (
<Text testID={testID} className={` ${className} pl-2`}>
{text}
</Text>
);
export const CheckboxIcon = ({ checked }: IconProps) => {
const color = checked ? colors.primary[300] : colors.charcoal[400];
return (
<MotiView
style={{
height: SIZE,
width: SIZE,
borderColor: color,
}}
className="items-center justify-center rounded-[5px] border-2"
from={{ backgroundColor: 'transparent', borderColor: '#CCCFD6' }}
animate={{
backgroundColor: checked ? color : 'transparent',
borderColor: color,
}}
transition={{
backgroundColor: { type: 'timing', duration: 100 },
borderColor: { type: 'timing', duration: 100 },
}}
>
<MotiView
from={{ opacity: 0 }}
animate={{ opacity: checked ? 1 : 0 }}
transition={{ opacity: { type: 'timing', duration: 100 } }}
>
<Svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<Path
d="m16.726 7-.64.633c-2.207 2.212-3.878 4.047-5.955 6.158l-2.28-1.928-.69-.584L6 12.66l.683.577 2.928 2.477.633.535.591-.584c2.421-2.426 4.148-4.367 6.532-6.756l.633-.64L16.726 7Z"
fill="#fff"
/>
</Svg>
</MotiView>
</MotiView>
);
};
const CheckboxRoot = ({ checked, children, ...props }: RootProps) => (
<Root checked={checked} accessibilityRole="checkbox" {...props}>
{children}
</Root>
);
const CheckboxBase = ({
checked = false,
testID,
label,
...props
}: RootProps & { label?: string }) => (
<CheckboxRoot checked={checked} testID={testID} {...props}>
<CheckboxIcon checked={checked} />
{label ? (
<Label
text={label}
testID={testID ? `${testID}-label` : undefined}
className="pr-2"
/>
) : null}
</CheckboxRoot>
);
export const Checkbox = Object.assign(CheckboxBase, {
Icon: CheckboxIcon,
Root: CheckboxRoot,
Label,
});
export const RadioIcon = ({ checked }: IconProps) => {
const color = checked ? colors.primary[300] : colors.charcoal[400];
return (
<MotiView
style={{
height: SIZE,
width: SIZE,
borderColor: color,
}}
className="items-center justify-center rounded-[20px] border-2 bg-transparent"
from={{ borderColor: '#CCCFD6' }}
animate={{
borderColor: color,
}}
transition={{ borderColor: { duration: 100, type: 'timing' } }}
>
<MotiView
className={`h-[10px] w-[10px] rounded-[10px] ${
checked && 'bg-primary-300'
} `}
from={{ opacity: 0 }}
animate={{ opacity: checked ? 1 : 0 }}
transition={{ opacity: { duration: 50, type: 'timing' } }}
/>
</MotiView>
);
};
const RadioRoot = ({ checked, children, ...props }: RootProps) => (
<Root checked={checked} accessibilityRole="radio" {...props}>
{children}
</Root>
);
const RadioBase = ({
checked = false,
testID,
label,
...props
}: RootProps & { label?: string }) => (
<RadioRoot checked={checked} testID={testID} {...props}>
<RadioIcon checked={checked} />
{label ? (
<Label text={label} testID={testID ? `${testID}-label` : undefined} />
) : null}
</RadioRoot>
);
export const Radio = Object.assign(RadioBase, {
Icon: RadioIcon,
Root: RadioRoot,
Label,
});
export const SwitchIcon = ({ checked }: IconProps) => {
const translateX = checked
? THUMB_OFFSET
: WIDTH - THUMB_WIDTH - THUMB_OFFSET;
const backgroundColor = checked ? colors.primary[300] : colors.charcoal[400];
return (
<View className="w-[50px] justify-center">
<View className="overflow-hidden rounded-full">
<View
style={{
width: WIDTH,
height: HEIGHT,
backgroundColor,
}}
/>
</View>
<MotiView
style={{
height: THUMB_HEIGHT,
width: THUMB_WIDTH,
position: 'absolute',
backgroundColor: 'white',
borderRadius: 13,
right: 0,
}}
animate={{
translateX: I18nManager.isRTL ? translateX : -translateX,
}}
transition={{ translateX: { overshootClamping: true } }}
/>
</View>
);
};
const SwitchRoot = ({ checked, children, ...props }: RootProps) => (
<Root checked={checked} accessibilityRole="switch" {...props}>
{children}
</Root>
);
const SwitchBase = ({
checked = false,
testID,
label,
...props
}: RootProps & { label?: string }) => (
<SwitchRoot checked={checked} testID={testID} {...props}>
<SwitchIcon checked={checked} />
{label ? (
<Label text={label} testID={testID ? `${testID}-label` : undefined} />
) : null}
</SwitchRoot>
);
export const Switch = Object.assign(SwitchBase, {
Icon: SwitchIcon,
Root: SwitchRoot,
Label,
});

Props

  • All React Native Pressable Props are supported excluding onPress prop
  • onChange - (checked: boolean) => void;` - Callback function to handle component’s state
  • checked - boolean- Determines the state of the component (default:false)
  • label - Component’s label
  • accessibilityLabel - Component’s accessibility label
  • children - Child components/elements
  • className - Tailwind CSS class names
  • disabled: boolean - Disable component (default: false)

Use Case

import { Checkbox } from '@/ui';
const App = () => {
const [checked, setChecked] = useState(false);
return (
<Checkbox
checked={checked}
onChange={setChecked}
accessibilityLabel="accept terms of condition"
label="I accept terms and conditions"
/>
);
};

By default the component will render a label with the text you passed as label prop and clicking on the label will toggle the component as well.

For rendering a custom Checkbox, you can use the Checkbox.Root, Checkbox.Icon, and Checkbox.Label components.

import { Checkbox } from '@/ui';
const App = () => {
const [checked, setChecked] = useState(false);
return (
<Checkbox.Root
checked={checked}
onChange={setChecked}
accessibilityLabel="accept terms of condition"
>
<Checkbox.Icon checked={checked} />
<Checkbox.Label text="I agree to terms and conditions" />
</Checkbox.Root>
);
};