Creating Reusable UI Components for Expo Router
This guide covers building production-quality, portable UI components inspired by shadcn/ui, Base UI, Radix, and Konsta UI. Components follow iOS San Francisco design guidelines with liquid glass aesthetics and prioritize native primitives with graceful fallbacks.
Philosophy
Core Principles
- Portable & Copy-Paste Ready - Components should be self-contained and easy to copy between projects
- Native-First - Always check for Expo Router primitives before building custom solutions
- iOS Design Language - Use San Francisco style guide as the baseline for all platforms
- Compound Components - Break complex components into composable sub-components
- CSS Variables for Customization - Use design tokens for theming, not hardcoded values
- Accessibility Built-In - Keyboard handling, safe areas, and screen reader support by default
Inspiration Sources
| Library | Learn From |
|---|---|
| shadcn/ui | Component structure, copy-paste architecture |
| Radix UI | Compound component patterns, accessibility primitives |
| Base UI | Headless component APIs, composition patterns |
| Konsta UI | iOS liquid glass aesthetics, platform-adaptive styling |
Component File Structure
src/components/ui/
├── button.tsx # Default (shared) implementation
├── button.ios.tsx # iOS-specific overrides (optional)
├── button.web.tsx # Web-specific overrides (optional)
└── button.android.tsx # Android-specific overrides (optional)
Metro Resolution Priority:
.ios.tsx/.android.tsx/.web.tsx(platform-specific).native.tsx(iOS + Android).tsx(fallback for all platforms)
Design Tokens & CSS Variables
Global Theme Variables
Define customizable design tokens in src/global.css:
css1@import "tailwindcss/theme.css" layer(theme); 2@import "tailwindcss/preflight.css" layer(base); 3@import "tailwindcss/utilities.css"; 4 5/* Import Apple system colors */ 6@import "./css/sf.css"; 7 8@layer theme { 9 @theme { 10 /* Typography Scale */ 11 --font-sans: system-ui; 12 --font-mono: ui-monospace; 13 --font-rounded: ui-rounded; 14 15 /* Component Tokens */ 16 --component-radius: 12px; 17 --component-radius-lg: 16px; 18 --component-radius-full: 9999px; 19 20 /* Spacing Scale */ 21 --spacing-xs: 4px; 22 --spacing-sm: 8px; 23 --spacing-md: 12px; 24 --spacing-lg: 16px; 25 --spacing-xl: 24px; 26 27 /* Animation */ 28 --transition-fast: 150ms; 29 --transition-normal: 200ms; 30 --transition-slow: 300ms; 31 } 32} 33 34/* Platform-specific overrides */ 35@media ios { 36 :root { 37 --font-sans: system-ui; 38 --font-rounded: ui-rounded; 39 --component-radius: 10px; 40 } 41} 42 43@media android { 44 :root { 45 --font-sans: normal; 46 --font-rounded: normal; 47 --component-radius: 8px; 48 } 49}
Apple System Colors
Create platform-adaptive colors in src/css/sf.css:
css1@layer base { 2 html { 3 color-scheme: light dark; 4 } 5} 6 7:root { 8 /* Primary Colors */ 9 --sf-blue: light-dark(rgb(0 122 255), rgb(10 132 255)); 10 --sf-green: light-dark(rgb(52 199 89), rgb(48 209 89)); 11 --sf-red: light-dark(rgb(255 59 48), rgb(255 69 58)); 12 --sf-orange: light-dark(rgb(255 149 0), rgb(255 159 10)); 13 --sf-yellow: light-dark(rgb(255 204 0), rgb(255 214 10)); 14 --sf-purple: light-dark(rgb(175 82 222), rgb(191 90 242)); 15 --sf-pink: light-dark(rgb(255 45 85), rgb(255 55 95)); 16 17 /* Gray Scale */ 18 --sf-gray: light-dark(rgb(142 142 147), rgb(142 142 147)); 19 --sf-gray-2: light-dark(rgb(174 174 178), rgb(99 99 102)); 20 --sf-gray-3: light-dark(rgb(199 199 204), rgb(72 72 74)); 21 --sf-gray-4: light-dark(rgb(209 209 214), rgb(58 58 60)); 22 --sf-gray-5: light-dark(rgb(229 229 234), rgb(44 44 46)); 23 --sf-gray-6: light-dark(rgb(242 242 247), rgb(28 28 30)); 24 25 /* Text Colors */ 26 --sf-text: light-dark(rgb(0 0 0), rgb(255 255 255)); 27 --sf-text-2: light-dark(rgb(60 60 67 / 0.6), rgb(235 235 245 / 0.6)); 28 --sf-text-3: light-dark(rgb(60 60 67 / 0.3), rgb(235 235 245 / 0.3)); 29 --sf-text-placeholder: light-dark(rgb(60 60 67 / 0.3), rgb(235 235 245 / 0.3)); 30 31 /* Background Colors */ 32 --sf-bg: light-dark(rgb(255 255 255), rgb(0 0 0)); 33 --sf-bg-2: light-dark(rgb(242 242 247), rgb(28 28 30)); 34 --sf-grouped-bg: light-dark(rgb(242 242 247), rgb(0 0 0)); 35 --sf-grouped-bg-2: light-dark(rgb(255 255 255), rgb(28 28 30)); 36 37 /* Border & Fill */ 38 --sf-border: light-dark(rgb(60 60 67 / 0.12), rgb(84 84 88 / 0.65)); 39 --sf-fill: light-dark(rgb(120 120 128 / 0.2), rgb(120 120 128 / 0.32)); 40 41 /* Link Color */ 42 --sf-link: var(--sf-blue); 43} 44 45/* iOS: Use native platform colors */ 46@media ios { 47 :root { 48 --sf-blue: platformColor(systemBlue); 49 --sf-green: platformColor(systemGreen); 50 --sf-red: platformColor(systemRed); 51 --sf-orange: platformColor(systemOrange); 52 --sf-yellow: platformColor(systemYellow); 53 --sf-purple: platformColor(systemPurple); 54 --sf-pink: platformColor(systemPink); 55 --sf-gray: platformColor(systemGray); 56 --sf-gray-2: platformColor(systemGray2); 57 --sf-gray-3: platformColor(systemGray3); 58 --sf-gray-4: platformColor(systemGray4); 59 --sf-gray-5: platformColor(systemGray5); 60 --sf-gray-6: platformColor(systemGray6); 61 --sf-text: platformColor(label); 62 --sf-text-2: platformColor(secondaryLabel); 63 --sf-text-3: platformColor(tertiaryLabel); 64 --sf-text-placeholder: platformColor(placeholderText); 65 --sf-bg: platformColor(systemBackground); 66 --sf-bg-2: platformColor(secondarySystemBackground); 67 --sf-grouped-bg: platformColor(systemGroupedBackground); 68 --sf-grouped-bg-2: platformColor(secondarySystemGroupedBackground); 69 --sf-border: platformColor(separator); 70 --sf-fill: platformColor(tertiarySystemFill); 71 --sf-link: platformColor(link); 72 } 73} 74 75/* Register as Tailwind theme colors */ 76@layer theme { 77 @theme { 78 --color-sf-blue: var(--sf-blue); 79 --color-sf-green: var(--sf-green); 80 --color-sf-red: var(--sf-red); 81 --color-sf-orange: var(--sf-orange); 82 --color-sf-yellow: var(--sf-yellow); 83 --color-sf-purple: var(--sf-purple); 84 --color-sf-pink: var(--sf-pink); 85 --color-sf-gray: var(--sf-gray); 86 --color-sf-gray-2: var(--sf-gray-2); 87 --color-sf-gray-3: var(--sf-gray-3); 88 --color-sf-gray-4: var(--sf-gray-4); 89 --color-sf-gray-5: var(--sf-gray-5); 90 --color-sf-gray-6: var(--sf-gray-6); 91 --color-sf-text: var(--sf-text); 92 --color-sf-text-2: var(--sf-text-2); 93 --color-sf-text-3: var(--sf-text-3); 94 --color-sf-text-placeholder: var(--sf-text-placeholder); 95 --color-sf-bg: var(--sf-bg); 96 --color-sf-bg-2: var(--sf-bg-2); 97 --color-sf-grouped-bg: var(--sf-grouped-bg); 98 --color-sf-grouped-bg-2: var(--sf-grouped-bg-2); 99 --color-sf-border: var(--sf-border); 100 --color-sf-fill: var(--sf-fill); 101 --color-sf-link: var(--sf-link); 102 } 103}
Accessing CSS Variables in JavaScript
tsx1import { useCSSVariable } from "@/tw"; 2 3function MyComponent() { 4 const primaryColor = useCSSVariable("--sf-blue"); 5 const borderColor = useCSSVariable("--sf-border"); 6 7 return ( 8 <View style={{ borderColor }}> 9 <Text style={{ color: primaryColor }}>Hello</Text> 10 </View> 11 ); 12}
Compound Component Pattern
Use compound components for complex, multi-element UI. This provides flexibility while maintaining cohesive behavior.
Template Structure
tsx1"use client"; 2 3import React, { createContext, use } from "react"; 4import { View, Text, Pressable } from "@/tw"; 5import { cn } from "@/lib/utils"; 6import type { ViewProps, TextProps } from "react-native"; 7 8// 1. Define Context for shared state 9interface ComponentContextValue { 10 variant: "default" | "outline" | "ghost"; 11 size: "sm" | "md" | "lg"; 12 disabled?: boolean; 13} 14 15const ComponentContext = createContext<ComponentContextValue | null>(null); 16 17function useComponentContext() { 18 const context = use(ComponentContext); 19 if (!context) { 20 throw new Error("Component parts must be used within Component.Root"); 21 } 22 return context; 23} 24 25// 2. Root component provides context 26interface RootProps extends ViewProps { 27 variant?: ComponentContextValue["variant"]; 28 size?: ComponentContextValue["size"]; 29 disabled?: boolean; 30} 31 32function Root({ 33 variant = "default", 34 size = "md", 35 disabled, 36 children, 37 className, 38 ...props 39}: RootProps) { 40 return ( 41 <ComponentContext value={{ variant, size, disabled }}> 42 <View 43 {...props} 44 className={cn( 45 "flex-row items-center", 46 disabled && "opacity-50", 47 className 48 )} 49 > 50 {children} 51 </View> 52 </ComponentContext> 53 ); 54} 55 56// 3. Sub-components consume context 57function Label({ className, ...props }: TextProps) { 58 const { size } = useComponentContext(); 59 60 return ( 61 <Text 62 {...props} 63 className={cn( 64 "text-sf-text", 65 size === "sm" && "text-sm", 66 size === "md" && "text-base", 67 size === "lg" && "text-lg", 68 className 69 )} 70 /> 71 ); 72} 73 74function Icon({ className, ...props }: ViewProps) { 75 const { size } = useComponentContext(); 76 77 const sizeClass = { 78 sm: "w-4 h-4", 79 md: "w-5 h-5", 80 lg: "w-6 h-6", 81 }[size]; 82 83 return ( 84 <View {...props} className={cn(sizeClass, className)} /> 85 ); 86} 87 88// 4. Export as compound component 89export const Component = { 90 Root, 91 Label, 92 Icon, 93}; 94 95// 5. Convenience export for simple usage 96export function SimpleComponent(props: RootProps & { label: string }) { 97 const { label, ...rootProps } = props; 98 return ( 99 <Component.Root {...rootProps}> 100 <Component.Label>{label}</Component.Label> 101 </Component.Root> 102 ); 103}
Native-First Component Development
Check for Expo Router Primitives First
Before building custom components, check if Expo Router or Expo provides a native primitive:
| Component Need | Check First |
|---|---|
| Navigation Stack | expo-router Stack |
| Tab Navigation | expo-router Tabs |
| Modals/Sheets | presentation: "modal" or presentation: "formSheet" |
| Links | expo-router Link |
| Icons | expo-symbols (SF Symbols) |
| Date Picker | @react-native-community/datetimepicker |
| Segmented Control | @react-native-segmented-control/segmented-control |
| Blur Effects | expo-blur or expo-glass-effect |
| Haptics | expo-haptics |
| Safe Areas | react-native-safe-area-context |
Platform Detection
tsx1// Check current platform 2if (process.env.EXPO_OS === "ios") { 3 // iOS-specific behavior 4} else if (process.env.EXPO_OS === "android") { 5 // Android-specific behavior 6} else if (process.env.EXPO_OS === "web") { 7 // Web-specific behavior 8} 9 10// Check for specific features 11import { isLiquidGlassAvailable } from "expo-glass-effect"; 12const GLASS = isLiquidGlassAvailable(); // iOS 26+ liquid glass
Platform-Specific File Example: Switch
switch.tsx (default - re-exports native):
tsx1export { Switch, type SwitchProps } from "react-native";
switch.web.tsx (web - iOS-styled custom):
tsx1"use client"; 2 3import { useState, useRef, useEffect } from "react"; 4import { 5 View, 6 Animated, 7 PanResponder, 8 StyleSheet, 9 Pressable, 10} from "react-native"; 11 12export type SwitchProps = { 13 value?: boolean; 14 onValueChange?: (value: boolean) => void; 15 disabled?: boolean; 16 thumbColor?: string; 17 trackColor?: { true: string; false: string }; 18 ios_backgroundColor?: string; 19}; 20 21export function Switch({ 22 value = false, 23 onValueChange, 24 disabled = false, 25 thumbColor = "#fff", 26 trackColor = { true: "#34C759", false: "#E9E9EA" }, 27 ios_backgroundColor, 28}: SwitchProps) { 29 const [isOn, setIsOn] = useState(value); 30 const animatedValue = useRef(new Animated.Value(value ? 1 : 0)).current; 31 32 useEffect(() => { 33 setIsOn(value); 34 Animated.spring(animatedValue, { 35 toValue: value ? 1 : 0, 36 useNativeDriver: false, 37 friction: 8, 38 tension: 40, 39 }).start(); 40 }, [value, animatedValue]); 41 42 const toggle = () => { 43 if (disabled) return; 44 const newValue = !isOn; 45 setIsOn(newValue); 46 onValueChange?.(newValue); 47 }; 48 49 const translateX = animatedValue.interpolate({ 50 inputRange: [0, 1], 51 outputRange: [2, 22], 52 }); 53 54 const bgColor = animatedValue.interpolate({ 55 inputRange: [0, 1], 56 outputRange: [ 57 ios_backgroundColor || trackColor.false, 58 trackColor.true, 59 ], 60 }); 61 62 return ( 63 <Pressable onPress={toggle} disabled={disabled}> 64 <Animated.View 65 style={[ 66 styles.track, 67 { backgroundColor: bgColor }, 68 disabled && styles.disabled, 69 ]} 70 > 71 <Animated.View 72 style={[ 73 styles.thumb, 74 { backgroundColor: thumbColor, transform: [{ translateX }] }, 75 ]} 76 /> 77 </Animated.View> 78 </Pressable> 79 ); 80} 81 82const styles = StyleSheet.create({ 83 track: { 84 width: 51, 85 height: 31, 86 borderRadius: 15.5, 87 justifyContent: "center", 88 }, 89 thumb: { 90 width: 27, 91 height: 27, 92 borderRadius: 13.5, 93 shadowColor: "#000", 94 shadowOffset: { width: 0, height: 2 }, 95 shadowOpacity: 0.2, 96 shadowRadius: 2, 97 elevation: 2, 98 }, 99 disabled: { 100 opacity: 0.5, 101 }, 102});
Accessibility Patterns
Keyboard Avoidance
For forms with text input, proper keyboard handling is critical:
tsx1import { 2 useReanimatedKeyboardAnimation, 3 useKeyboardHandler, 4} from "react-native-keyboard-controller"; 5import { useAnimatedStyle } from "react-native-reanimated"; 6import { useSafeAreaInsets } from "react-native-safe-area-context"; 7 8function KeyboardAwareForm({ children }: { children: React.ReactNode }) { 9 const { bottom } = useSafeAreaInsets(); 10 const { height, progress } = useReanimatedKeyboardAnimation(); 11 12 const animatedStyle = useAnimatedStyle(() => ({ 13 paddingBottom: Math.max(bottom, Math.abs(height.value)), 14 })); 15 16 return ( 17 <Animated.View style={[{ flex: 1 }, animatedStyle]}> 18 {children} 19 </Animated.View> 20 ); 21}
Safe Area Handling
Always account for safe areas on notched devices:
tsx1import { useSafeAreaInsets } from "react-native-safe-area-context"; 2 3function SafeContainer({ children }: { children: React.ReactNode }) { 4 const { top, bottom, left, right } = useSafeAreaInsets(); 5 6 return ( 7 <View 8 style={{ 9 flex: 1, 10 paddingTop: top, 11 paddingBottom: bottom, 12 paddingLeft: left, 13 paddingRight: right, 14 }} 15 > 16 {children} 17 </View> 18 ); 19}
Form Accessibility Pattern
tsx1import { View, Text, TextInput } from "@/tw"; 2import { useSafeAreaInsets } from "react-native-safe-area-context"; 3import { KeyboardAwareScrollView } from "react-native-keyboard-controller"; 4 5interface FormFieldProps { 6 label: string; 7 hint?: string; 8 error?: string; 9 children: React.ReactNode; 10} 11 12function FormField({ label, hint, error, children }: FormFieldProps) { 13 return ( 14 <View className="gap-1"> 15 <Text 16 className="text-sf-text-2 text-sm font-medium" 17 accessibilityRole="text" 18 > 19 {label} 20 </Text> 21 {children} 22 {hint && !error && ( 23 <Text className="text-sf-text-3 text-xs">{hint}</Text> 24 )} 25 {error && ( 26 <Text 27 className="text-sf-red text-xs" 28 accessibilityRole="alert" 29 > 30 {error} 31 </Text> 32 )} 33 </View> 34 ); 35} 36 37function AccessibleForm() { 38 const { bottom } = useSafeAreaInsets(); 39 40 return ( 41 <KeyboardAwareScrollView 42 contentContainerStyle={{ 43 padding: 16, 44 paddingBottom: bottom + 16, 45 gap: 16, 46 }} 47 keyboardShouldPersistTaps="handled" 48 > 49 <FormField label="Email" hint="We'll never share your email"> 50 <TextInput 51 className="bg-sf-fill rounded-xl px-4 py-3 text-sf-text" 52 placeholder="you@example.com" 53 keyboardType="email-address" 54 autoCapitalize="none" 55 autoComplete="email" 56 textContentType="emailAddress" 57 accessibilityLabel="Email address" 58 /> 59 </FormField> 60 61 <FormField label="Password"> 62 <TextInput 63 className="bg-sf-fill rounded-xl px-4 py-3 text-sf-text" 64 placeholder="••••••••" 65 secureTextEntry 66 autoComplete="password" 67 textContentType="password" 68 accessibilityLabel="Password" 69 /> 70 </FormField> 71 </KeyboardAwareScrollView> 72 ); 73}
iOS Liquid Glass Styling
Detecting Liquid Glass Support
tsx1import { isLiquidGlassAvailable } from "expo-glass-effect"; 2 3const GLASS = isLiquidGlassAvailable(); 4 5const HEADER_OPTIONS = GLASS 6 ? { 7 headerTransparent: true, 8 headerShadowVisible: false, 9 headerBlurEffect: "none", 10 } 11 : { 12 headerTransparent: true, 13 headerBlurEffect: "systemChromeMaterial", 14 headerShadowVisible: true, 15 };
Tab Bar with Glass Effect
tsx1import { BlurView } from "expo-blur"; 2 3function GlassTabBarBackground() { 4 return ( 5 <BlurView 6 intensity={100} 7 tint="systemChromeMaterial" 8 style={StyleSheet.absoluteFill} 9 /> 10 ); 11} 12 13// Usage in Tabs 14const TAB_OPTIONS = 15 process.env.EXPO_OS === "ios" 16 ? { 17 tabBarBackground: GlassTabBarBackground, 18 tabBarStyle: { position: "absolute" }, 19 } 20 : {};
Glass Card Component
tsx1import { BlurView } from "expo-blur"; 2import { View } from "@/tw"; 3import { cn } from "@/lib/utils"; 4 5interface GlassCardProps extends React.ComponentProps<typeof View> { 6 intensity?: number; 7} 8 9function GlassCard({ 10 intensity = 50, 11 className, 12 children, 13 ...props 14}: GlassCardProps) { 15 if (process.env.EXPO_OS !== "ios") { 16 // Fallback for non-iOS 17 return ( 18 <View 19 {...props} 20 className={cn( 21 "bg-sf-bg-2/80 rounded-2xl overflow-hidden", 22 className 23 )} 24 > 25 {children} 26 </View> 27 ); 28 } 29 30 return ( 31 <View 32 {...props} 33 className={cn("rounded-2xl overflow-hidden", className)} 34 > 35 <BlurView 36 intensity={intensity} 37 tint="systemChromeMaterial" 38 style={StyleSheet.absoluteFill} 39 /> 40 <View className="relative">{children}</View> 41 </View> 42 ); 43}
Form Components Pattern
The Form compound component demonstrates all principles together:
tsx1"use client"; 2 3import React, { createContext, use } from "react"; 4import { View, Text, TextInput, ScrollView, TouchableHighlight } from "@/tw"; 5import { useSafeAreaInsets } from "react-native-safe-area-context"; 6import { cn } from "@/lib/utils"; 7import { useCSSVariable } from "@/tw"; 8 9// Context for form styling 10const FormContext = createContext<{ 11 listStyle: "grouped" | "inset"; 12 sheet?: boolean; 13}>({ listStyle: "inset" }); 14 15// List wrapper with pull-to-refresh 16function List({ 17 children, 18 listStyle = "inset", 19 sheet, 20 ...props 21}: React.ComponentProps<typeof ScrollView> & { 22 listStyle?: "grouped" | "inset"; 23 sheet?: boolean; 24}) { 25 const { bottom } = useSafeAreaInsets(); 26 27 return ( 28 <FormContext value={{ listStyle, sheet }}> 29 <ScrollView 30 contentContainerStyle={{ paddingVertical: 16, gap: 24 }} 31 contentInsetAdjustmentBehavior="automatic" 32 scrollIndicatorInsets={{ bottom }} 33 className={cn( 34 sheet ? "bg-transparent" : "bg-sf-grouped-bg", 35 props.className 36 )} 37 {...props} 38 > 39 {children} 40 </ScrollView> 41 </FormContext> 42 ); 43} 44 45// Section groups related items 46function Section({ 47 children, 48 title, 49 footer, 50 ...props 51}: React.ComponentProps<typeof View> & { 52 title?: string; 53 footer?: string; 54}) { 55 const { listStyle, sheet } = use(FormContext); 56 const isInset = listStyle === "inset"; 57 58 return ( 59 <View style={{ paddingHorizontal: isInset ? 16 : 0 }} {...props}> 60 {title && ( 61 <Text className="uppercase text-sf-text-2 text-sm px-5 pb-2"> 62 {title} 63 </Text> 64 )} 65 <View 66 className={cn( 67 sheet ? "bg-sf-bg-2" : "bg-sf-grouped-bg-2", 68 isInset ? "rounded-xl overflow-hidden" : "border-y border-sf-border" 69 )} 70 > 71 {React.Children.map(children, (child, index) => ( 72 <> 73 {child} 74 {index < React.Children.count(children) - 1 && ( 75 <View className="border-b border-sf-border ml-4" /> 76 )} 77 </> 78 ))} 79 </View> 80 {footer && ( 81 <Text className="text-sf-text-2 text-sm px-5 pt-2">{footer}</Text> 82 )} 83 </View> 84 ); 85} 86 87// Individual form item with optional navigation 88function Item({ 89 children, 90 onPress, 91 href, 92 ...props 93}: React.ComponentProps<typeof View> & { 94 onPress?: () => void; 95 href?: string; 96}) { 97 const underlayColor = useCSSVariable("--sf-gray-4"); 98 99 const content = ( 100 <View className="flex-row items-center px-4 py-3 min-h-[44px]" {...props}> 101 {children} 102 </View> 103 ); 104 105 if (!onPress && !href) return content; 106 107 return ( 108 <TouchableHighlight 109 onPress={onPress} 110 underlayColor={underlayColor} 111 className="web:hover:bg-sf-fill web:transition-colors" 112 > 113 {content} 114 </TouchableHighlight> 115 ); 116} 117 118// Text label 119function Label({ className, ...props }: React.ComponentProps<typeof Text>) { 120 return ( 121 <Text 122 {...props} 123 className={cn("text-sf-text text-base flex-1", className)} 124 /> 125 ); 126} 127 128// Hint/value on the right 129function Hint({ className, ...props }: React.ComponentProps<typeof Text>) { 130 return ( 131 <Text 132 {...props} 133 className={cn("text-sf-text-2 text-base", className)} 134 /> 135 ); 136} 137 138// Export compound component 139export const Form = { 140 List, 141 Section, 142 Item, 143 Label, 144 Hint, 145};
Usage
tsx1<Form.List> 2 <Form.Section title="Account" footer="Your account settings"> 3 <Form.Item href="/profile"> 4 <Form.Label>Profile</Form.Label> 5 <Form.Hint>John Doe</Form.Hint> 6 <ChevronRight /> 7 </Form.Item> 8 <Form.Item href="/email"> 9 <Form.Label>Email</Form.Label> 10 <Form.Hint>john@example.com</Form.Hint> 11 <ChevronRight /> 12 </Form.Item> 13 </Form.Section> 14 15 <Form.Section title="Preferences"> 16 <Form.Item> 17 <Form.Label>Dark Mode</Form.Label> 18 <Switch value={darkMode} onValueChange={setDarkMode} /> 19 </Form.Item> 20 </Form.Section> 21</Form.List>
Haptic Feedback
Platform-Safe Haptics
lib/haptics.ts (native):
tsx1import * as Haptics from "expo-haptics"; 2 3export const haptics = { 4 light: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light), 5 medium: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium), 6 heavy: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy), 7 success: () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success), 8 warning: () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning), 9 error: () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error), 10 selection: () => Haptics.selectionAsync(), 11};
lib/haptics.web.ts (web - no-op):
tsx1export const haptics = { 2 light: () => {}, 3 medium: () => {}, 4 heavy: () => {}, 5 success: () => {}, 6 warning: () => {}, 7 error: () => {}, 8 selection: () => {}, 9};
Usage in Components
tsx1import { haptics } from "@/lib/haptics"; 2 3function HapticButton({ onPress, children }) { 4 const handlePress = () => { 5 haptics.light(); 6 onPress?.(); 7 }; 8 9 return <Pressable onPress={handlePress}>{children}</Pressable>; 10}
Icon System
SF Symbol Icons with Fallbacks
tsx1import { SymbolView, SymbolWeight } from "expo-symbols"; 2import { MaterialIcons } from "@expo/vector-icons"; 3 4// Map SF Symbol names to Material Icons 5const ICON_MAPPING: Record<string, string> = { 6 "house.fill": "home", 7 "gear": "settings", 8 "person.fill": "person", 9 "magnifyingglass": "search", 10 "chevron.right": "chevron_right", 11}; 12 13interface IconProps { 14 name: string; 15 size?: number; 16 color?: string; 17 weight?: SymbolWeight; 18} 19 20export function Icon({ name, size = 24, color, weight }: IconProps) { 21 if (process.env.EXPO_OS === "ios") { 22 return ( 23 <SymbolView 24 name={name} 25 size={size} 26 tintColor={color} 27 weight={weight} 28 /> 29 ); 30 } 31 32 const materialName = ICON_MAPPING[name] || name; 33 return <MaterialIcons name={materialName} size={size} color={color} />; 34}
Component Checklist
When creating a new component, ensure:
- Portable: Self-contained, minimal external dependencies
- Typed: Full TypeScript types for props
- Themed: Uses CSS variables for colors, not hardcoded values
- Accessible: Proper accessibility roles and labels
- Keyboard-aware: Handles keyboard avoidance for inputs
- Safe area-aware: Respects device safe areas
- Platform-adaptive: Uses native primitives where available
- Compound structure: Complex components use compound pattern
- Haptic feedback: Provides tactile feedback on iOS
- Dark mode: Supports light and dark color schemes
- Display name: Set
displayNamein dev for debugging
tsx1if (__DEV__) { 2 MyComponent.displayName = "MyComponent"; 3}
Dependencies Reference
| Package | Purpose |
|---|---|
react-native-css | CSS runtime for React Native |
nativewind | Metro transformer for Tailwind |
tailwindcss | Utility-first CSS |
@tailwindcss/postcss | PostCSS plugin for Tailwind v4 |
tailwind-merge | Merge Tailwind classes safely |
clsx | Conditional class names |
react-native-safe-area-context | Safe area handling |
react-native-keyboard-controller | Keyboard animations |
react-native-reanimated | Gesture animations |
expo-haptics | Haptic feedback |
expo-symbols | SF Symbols |
expo-blur | Blur effects |
expo-glass-effect | iOS 26 liquid glass |
@bacons/apple-colors | Native iOS colors |