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/components/ui
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.
import { FlashList as NFlashList } from '@shopify/flash-list';import React 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 = 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> );});
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 * as React from 'react';import { List, EmptyList, Text } from '@/components/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.
import type { ImageProps } from 'expo-image';import { Image as NImage } from 'expo-image';import { cssInterop } from 'nativewind';import * as React from 'react';
export type ImgProps = ImageProps & { className?: string;};
cssInterop(NImage, { className: 'style' });
export const Image = ({ style, className, placeholder = 'L6PZfSi_.AyE_3t7t7R**0o#DgR4', ...props}: ImgProps) => { return ( <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 * as React from 'react';import { Image } from '@/components/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.
import React 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 '@/lib/i18n';import { translate } from '@/lib/i18n';
interface Props extends TextProps { className?: string; tx?: TxKeyPath;}
export const Text = ({ className = '', style, tx, children, ...props}: Props) => { const textStyle = React.useMemo( () => twMerge( 'text-base text-black dark:text-white font-inter font-normal', className ), [className] );
const nStyle = React.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 namestx
- Translation key
Use Case
import * as 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
.
import React 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 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: 'size-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 = React.forwardRef<View, Props>( ( { label: text, loading = false, variant = 'default', disabled = false, size = 'default', className = '', testID, textClassName = '', ...props }, ref ) => { const styles = React.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 ofvariant
objects keys (default:default
)loading
- Show loading indicator (default:false
)label
- Button labelsize
- Button size, one of variantssize
objects keys (default:default
)className
- Tailwind CSS class names to be applied to the Button’s containertextClassName
- Additional styling for the Button’s label
Use Case
import React from 'react';
import { Button, View } from '@/components/ui';
import { Title } from './title';
export const Buttons = () => { return ( <> <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.
import * as React 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, View } from 'react-native';import { TextInput as NTextInput } 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 = React.forwardRef<NTextInput, NInputProps>((props, ref) => { const { label, error, testID, ...inputProps } = props; const [isFocussed, setIsFocussed] = React.useState(false); const onBlur = React.useCallback(() => setIsFocussed(false), []); const onFocus = React.useCallback(() => setIsFocussed(true), []);
const styles = React.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-formexport 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 labelerror
- 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 * as React from 'react';import { Input, View } from '@/components/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
if you don’t have a fixed height for the modal content.
/** * 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 * as React 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 = React.ForwardedRef<BottomSheetModal>;
type ModalHeaderProps = { title?: string; dismiss: () => void;};
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="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} enableDynamicSizing={false} 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 = React.memo(({ title, dismiss }: ModalHeaderProps) => { return ( <> {title && ( <View className="flex-row px-2 py-4"> <View className="size-[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 }) => { return ( <Pressable onPress={close} className="absolute right-3 top-3 size-[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 contenttitle
:string
- Modal title
Use Case
import * as React from 'react';import { Modal, useModal, View, Button, Text } from '@/components/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.
/* 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 * as React from 'react';import type { FieldValues } from 'react-hook-form';import { useController } from 'react-hook-form';import { Platform, 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 '@/components/ui/colors';import { CaretDown } from '@/components/ui/icons';
import type { InputControllerType } from './input';import { useModal } from './modal';import { Modal } 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 = React.forwardRef<BottomSheetModal, OptionsProps>( ({ options, onSelect, value, testID }, ref) => { const height = options.length * 70 + 100; const snapPoints = React.useMemo(() => [height], [height]); const { colorScheme } = useColorScheme(); const isDark = colorScheme === 'dark';
const renderSelectItem = React.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 = React.memo( ({ label, selected = false, ...props }: PressableProps & { selected?: boolean; label: string; }) => { return ( <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 = React.useCallback( (option: OptionType) => { onSelect?.(option.value); modal.dismiss(); }, [modal, onSelect] );
const styles = React.useMemo( () => selectTv({ error: Boolean(error), disabled, }), [error, disabled] );
const textValue = React.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> )} <Pressable 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 /> </Pressable> {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-formexport 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 = React.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 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 '@/components/ui';import { SelectInput, View } from '@/components/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.
import { MotiView } from 'moti';import React, { useCallback } from 'react';import { I18nManager, Pressable, type PressableProps, View,} from 'react-native';import Svg, { Path } from 'react-native-svg';
import colors from '@/components/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 = false, 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) => { return ( <Text testID={testID} className={` ${className} pl-2`}> {text} </Text> );};
export const CheckboxIcon = ({ checked = false }: 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 = false, children, ...props }: RootProps) => { return ( <Root checked={checked} accessibilityRole="checkbox" {...props}> {children} </Root> );};
const CheckboxBase = ({ checked = false, testID, label,
...props}: RootProps & { label?: string }) => { return ( <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 = false }: 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={`size-[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 = false, children, ...props }: RootProps) => { return ( <Root checked={checked} accessibilityRole="radio" {...props}> {children} </Root> );};
const RadioBase = ({ checked = false, testID, label, ...props}: RootProps & { label?: string }) => { return ( <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 = false }: 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 = false, children, ...props }: RootProps) => { return ( <Root checked={checked} accessibilityRole="switch" {...props}> {children} </Root> );};
const SwitchBase = ({ checked = false, testID, label, ...props}: RootProps & { label?: string }) => { return ( <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 statechecked
-boolean
- Determines the state of the component (default:false
)label
- Component’s labelaccessibilityLabel
- Component’s accessibility labelchildren
- Child components/elementsclassName
- Tailwind CSS class namesdisabled
:boolean
- Disable component (default:false
)
Use Case
import { Checkbox } from '@/components/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 '@/components/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> );};