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 exportThe 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
ViewPropsso the consumer can passstyle,testID, and other standard props - Extend the
StyleVariantstype 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:
| Prop | When to Use | Example Value |
|---|---|---|
accessibilityRole | Always on interactive elements | "button", "link", "checkbox" |
accessibilityLabel | Always — describes the element | "Delete product", "Toggle dark mode" |
accessibilityState | When element has state | { disabled: true }, { checked: true } |
accessibilityHint | When 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.tsextendsViewPropsandStyleVariants -
MyComponent.styles.tsusesStyleSheet.create((theme) => ({...}))with variants -
MyComponent.tsxcallsstyles.useVariants()before rendering -
MyComponent.tsxusesuseAnimatedPressfor interactive elements -
index.tsexports component, props type, and variant type -
accessibilityRoleandaccessibilityLabelset on all interactive elements - No inline styles, hardcoded colors, or hardcoded spacing values
- Named export (not default) from the component file