UI Components
The starter comes with a set of basic components and a simple design system based on Uniwind 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
The List component references the FlashList component from the @shopify/flash-list package.
import { FlashList as NFlashList } from '@shopify/flash-list';import * as 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 function NoData() { return ( <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-listProps 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" />} /> );};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 uniwind.
The cssInterop function from uniwind is used to apply styling and, in this way, the className property is applied to the style property of the Image component.
/* eslint-disable react-refresh/only-export-components */import type { ImageProps } from 'expo-image';import { Image as NImage } from 'expo-image';import * as React from 'react';import { withUniwind } from 'uniwind';
export type ImgProps = ImageProps & { className?: string;};
const StyledImage = withUniwind(NImage);
export function Image({ style, className, placeholder = 'L6PZfSi_.AyE_3t7t7R**0o#DgR4', ...props}: ImgProps) { return ( <StyledImage className={className} placeholder={placeholder} style={style} {...props} /> );}
export function preloadImages(sources: string[]) { NImage.prefetch(sources);}Props
- All
expo-imageProps 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', }} /> );};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.
/* eslint-disable better-tailwindcss/no-unknown-classes */import type { TextProps, TextStyle } from 'react-native';import type { TxKeyPath } from '@/lib/i18n';import * as React from 'react';import { I18nManager, Text as NNText, StyleSheet } from 'react-native';
import { twMerge } from 'tailwind-merge';import { translate } from '@/lib/i18n';
type Props = { className?: string; tx?: TxKeyPath;} & TextProps;
export function Text({ className = '', style, tx, children, ...props}: Props) { const textStyle = React.useMemo( () => twMerge( 'font-inter text-base font-normal text-black dark:text-white', 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
Section titled “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.
/* eslint-disable better-tailwindcss/no-unknown-classes */import type { PressableProps, View } from 'react-native';import type { VariantProps } from 'tailwind-variants';import * as React from 'react';import { ActivityIndicator, Pressable, Text } from 'react-native';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>;type Props = { label?: string; loading?: boolean; className?: string; textClassName?: string;} & ButtonVariants & Omit<PressableProps, 'disabled'>;
export function Button({ ref, label: text, loading = false, variant = 'default', disabled = false, size = 'default', className = '', testID, textClassName = '', ...props }: Props & { ref?: React.RefObject<View | null> }) { 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 ofvariantobjects keys (default:default)loading- Show loading indicator (default:false)label- Button labelsize- Button size, one of variantssizeobjects 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 * as React from 'react';
import { Button, View } from '@/components/ui';
import { Title } from './title';
export function 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> </> );}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.
/* eslint-disable better-tailwindcss/no-unknown-classes */import type { TextInputProps } from 'react-native';import * as React from 'react';import { I18nManager, TextInput as NTextInput, StyleSheet, 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: 'font-inter mt-0 rounded-xl border-[0.5px] border-neutral-300 bg-neutral-100 px-4 py-3 text-base/5 font-medium 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 type NInputProps = { label?: string; disabled?: boolean; error?: string;} & TextInputProps;
export function Input({ ref, ...props }: NInputProps & { ref?: React.Ref<NTextInput | null> }) { const { label, error, testID, onBlur: onBlurProp, onFocus: onFocusProp, ...inputProps } = props; const [isFocussed, setIsFocussed] = React.useState(false);
const onBlur = React.useCallback( (e: any) => { setIsFocussed(false); onBlurProp?.(e); }, [onBlurProp], );
const onFocus = React.useCallback( (e: any) => { setIsFocussed(true); onFocusProp?.(e); }, [onFocusProp], );
const styles = inputTv({ error: Boolean(error), focused: isFocussed, disabled: Boolean(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> );}Props
- All React Native TextInput Props are supported
label- Input labelerror- Input error message
For form handling with TanStack Form, use the Input component directly with form.Field. 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> );};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.
/* eslint-disable react-refresh/only-export-components *//** * 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 function 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 function Modal({ ref, snapPoints: _snapPoints = ['60%'] as (string | number)[], 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 mb-8 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);
function 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 function renderBackdrop(props: BottomSheetBackdropProps) { return <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. */
function 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-6" /> <View className="flex-1"> <Text className="text-center text-[16px] font-bold text-[#26313D] dark:text-white"> {title} </Text> </View> </View> )} <CloseButton close={dismiss} /> </> );});
function CloseButton({ close }: { close: () => void }) { return ( <Pressable onPress={close} className="absolute top-3 right-3 size-6 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-sheetProps 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
Section titled “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 better-tailwindcss/no-unknown-classes */import type { BottomSheetModal } from '@gorhom/bottom-sheet';import type { PressableProps } from 'react-native';import type { SvgProps } from 'react-native-svg';import { BottomSheetFlatList,
} from '@gorhom/bottom-sheet';import { FlashList } from '@shopify/flash-list';import * as React from 'react';import { Platform, Pressable, View } from 'react-native';import Svg, { Path } from 'react-native-svg';import { tv } from 'tailwind-variants';
import { useUniwind } from 'uniwind';import colors from '@/components/ui/colors';
import { CaretDown } from '@/components/ui/icons';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 function Options({ ref, options, onSelect, value, testID }: OptionsProps & { ref?: React.RefObject<BottomSheetModal | null> }) { const height = options.length * 70 + 100; const snapPoints = React.useMemo(() => [height], [height]); const { theme } = useUniwind(); const isDark = theme === '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 type SelectProps = { value?: string | number; label?: string; disabled?: boolean; error?: string; options?: OptionType[]; onSelect?: (value: string | number) => void; placeholder?: string; testID?: string;};
export function 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} /> </> );}
function Check({ ...props }: SvgProps) { return ( <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> );};For form handling with TanStack Form, use the Select component directly with form.Field. Read more about Handling Forms here.
Checkbox, Radio & Switch
Section titled “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 type { PressableProps } from 'react-native';import { MotiView } from 'moti';import * as React from 'react';import { useCallback } from 'react';import { I18nManager, Pressable,
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 type RootProps = { onChange: (checked: boolean) => void; checked?: boolean; className?: string; accessibilityLabel: string;} & Omit<PressableProps, 'onPress'>;
export type IconProps = { checked: boolean;};
export function 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;};
function Label({ text, testID, className = '' }: LabelProps) { return ( <Text testID={testID} className={`${className} pl-2`}> {text} </Text> );}
export function 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> );}
function CheckboxRoot({ checked = false, children, ...props }: RootProps) { return ( <Root checked={checked} accessibilityRole="checkbox" {...props}> {children} </Root> );}
function 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 function 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> );}
function RadioRoot({ checked = false, children, ...props }: RootProps) { return ( <Root checked={checked} accessibilityRole="radio" {...props}> {children} </Root> );}
function 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 function 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> );}function SwitchRoot({ checked = false, children, ...props }: RootProps) { return ( <Root checked={checked} accessibilityRole="switch" {...props}> {children} </Root> );}
function 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
onPressprop 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> );};