Skip to Content
GuidesCreating a Component

Creating a Component

This guide walks through building a shared UI component using RNCopilot’s 4-file pattern. We will create a StatusBadge component as the example, covering props, style variants, animated press interaction, accessibility, and barrel exports.

Shared components live in src/common/components/. Feature-specific components that are not reusable across the app should live in src/features/<name>/components/ instead.

The 4-File Pattern

Every shared component uses this directory structure:

src/common/components/StatusBadge/ ├── StatusBadge.types.ts # Props interface ├── StatusBadge.styles.ts # Styles with variants ├── StatusBadge.tsx # Implementation └── index.ts # Barrel export

The styles and types files are optional for very simple components, but recommended for consistency. All 33 built-in components follow this pattern.

Define the props interface

Create StatusBadge.types.ts with a props interface that extends from React Native base types. Import the style variants type to ensure the component accepts variant props.

// src/common/components/StatusBadge/StatusBadge.types.ts import type { ViewProps } from 'react-native'; import type { StatusBadgeStyleVariants } from './StatusBadge.styles'; /** Visual status variant for the badge. */ export type StatusBadgeStatus = 'success' | 'warning' | 'error' | 'info'; /** Props for the StatusBadge component. */ export interface StatusBadgeProps extends ViewProps, StatusBadgeStyleVariants { /** Text label displayed inside the badge. */ label: string; /** Semantic status that determines color. Defaults to `'info'`. */ status?: StatusBadgeStatus; /** Size of the badge. Defaults to `'md'`. */ size?: 'sm' | 'md' | 'lg'; /** Optional press handler. When provided, the badge becomes interactive. */ onPress?: () => void; }

Key conventions:

  • Extend ViewProps so the consumer can pass style, testID, and other standard props
  • Extend the StyleVariants type so variant props are type-checked
  • Document every prop with JSDoc comments
  • Export string-union types separately for reuse

Create styles with variants

Use StyleSheet.create from react-native-unistyles with the theme callback. The variants API maps prop values to style overrides.

// src/common/components/StatusBadge/StatusBadge.styles.ts import { StyleSheet, type UnistylesVariants } from 'react-native-unistyles'; export const styles = StyleSheet.create((theme) => ({ container: { flexDirection: 'row' as const, alignItems: 'center' as const, alignSelf: 'flex-start' as const, borderRadius: theme.metrics.borderRadius.full, variants: { status: { success: { backgroundColor: theme.colors.state.successBg, }, warning: { backgroundColor: theme.colors.state.warningBg, }, error: { backgroundColor: theme.colors.state.errorBg, }, info: { backgroundColor: theme.colors.state.infoBg, }, }, size: { sm: { paddingHorizontal: theme.metrics.spacing.p8, paddingVertical: theme.metrics.spacingV.p4, }, md: { paddingHorizontal: theme.metrics.spacing.p12, paddingVertical: theme.metrics.spacingV.p4, }, lg: { paddingHorizontal: theme.metrics.spacing.p16, paddingVertical: theme.metrics.spacingV.p8, }, }, }, }, dot: { borderRadius: theme.metrics.borderRadius.full, marginRight: theme.metrics.spacing.p4, variants: { status: { success: { backgroundColor: theme.colors.state.success }, warning: { backgroundColor: theme.colors.state.warning }, error: { backgroundColor: theme.colors.state.error }, info: { backgroundColor: theme.colors.state.info }, }, size: { sm: { width: 6, height: 6 }, md: { width: 8, height: 8 }, lg: { width: 10, height: 10 }, }, }, }, label: { fontWeight: '500', variants: { status: { success: { color: theme.colors.state.success }, warning: { color: theme.colors.state.warning }, error: { color: theme.colors.state.error }, info: { color: theme.colors.state.info }, }, size: { sm: { fontSize: theme.fonts.size.xs }, md: { fontSize: theme.fonts.size.sm }, lg: { fontSize: theme.fonts.size.md }, }, }, }, })); export type StatusBadgeStyleVariants = UnistylesVariants<typeof styles>;

Key points about the variants API:

  • Each variant key (e.g., status, size) maps to a component prop
  • Variant values are merged into the base style when the matching prop is passed
  • UnistylesVariants<typeof styles> extracts the variant prop types for the component interface
  • Always use theme tokens — never hardcoded colors or spacing values

Implement the component

The implementation ties everything together. Use styles.useVariants() to activate the variant system, and useAnimatedPress for interactive components.

// src/common/components/StatusBadge/StatusBadge.tsx import { View, Pressable } from 'react-native'; import Animated from 'react-native-reanimated'; import { Text } from '@/common/components/Text'; import { useAnimatedPress } from '@/hooks/useAnimatedPress'; import { styles } from './StatusBadge.styles'; import type { StatusBadgeProps } from './StatusBadge.types'; const AnimatedPressable = Animated.createAnimatedComponent(Pressable); /** * A colored badge indicating status with a dot indicator and label. * Becomes pressable when an `onPress` handler is provided. * * @example * ```tsx * <StatusBadge label="Active" status="success" /> * <StatusBadge label="Review needed" status="warning" size="lg" onPress={handlePress} /> * ``` */ export function StatusBadge({ label, status = 'info', size = 'md', onPress, style, ...rest }: StatusBadgeProps) { const { animatedStyle, onPressIn, onPressOut } = useAnimatedPress(); // Activate the variant system -- this must be called before rendering styles.useVariants({ status, size }); const content = ( <> <View style={styles.dot} /> <Text style={styles.label}>{label}</Text> </> ); // Interactive variant if (onPress) { return ( <AnimatedPressable style={[styles.container, animatedStyle, style]} onPress={onPress} onPressIn={onPressIn} onPressOut={onPressOut} accessibilityRole="button" accessibilityLabel={label} accessibilityState={{ disabled: false }} {...rest} > {content} </AnimatedPressable> ); } // Static variant return ( <View style={[styles.container, style]} accessibilityRole="text" accessibilityLabel={label} {...rest} > {content} </View> ); }

Create the barrel export

The index.ts file re-exports the component and its types. Consumers should always import from the barrel, never from individual files.

// src/common/components/StatusBadge/index.ts export { StatusBadge } from './StatusBadge'; export type { StatusBadgeProps, StatusBadgeStatus } from './StatusBadge.types'; export type { StatusBadgeStyleVariants } from './StatusBadge.styles';

Accessibility Requirements

Every interactive component must include these accessibility props:

PropWhen to UseExample Value
accessibilityRoleAlways on interactive elements"button", "link", "checkbox"
accessibilityLabelAlways — describes the element"Delete product", "Toggle dark mode"
accessibilityStateWhen element has state{ disabled: true }, { checked: true }
accessibilityHintWhen the action is not obvious"Double tap to remove from cart"

For non-interactive display components, use accessibilityRole="text" or accessibilityRole="image" as appropriate.

// Interactive example <AnimatedPressable accessibilityRole="button" accessibilityLabel={t('products.addToCart')} accessibilityHint={t('products.addToCartHint')} accessibilityState={{ disabled: isOutOfStock }} onPress={handleAddToCart} >

Usage

Once created, import the component from its barrel:

import { StatusBadge } from '@/common/components/StatusBadge'; // In your screen or component <StatusBadge label="Active" status="success" /> <StatusBadge label="Pending" status="warning" size="sm" /> <StatusBadge label="Failed" status="error" onPress={() => router.push('/details')} />

Checklist

  • MyComponent.types.ts extends ViewProps and StyleVariants
  • MyComponent.styles.ts uses StyleSheet.create((theme) => ({...})) with variants
  • MyComponent.tsx calls styles.useVariants() before rendering
  • MyComponent.tsx uses useAnimatedPress for interactive elements
  • index.ts exports component, props type, and variant type
  • accessibilityRole and accessibilityLabel set on all interactive elements
  • No inline styles, hardcoded colors, or hardcoded spacing values
  • Named export (not default) from the component file
Last updated on