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 :
@import "tailwindcss/theme.css" layer(theme); @import "tailwindcss/preflight.css" layer(base); @import "tailwindcss/utilities.css";
/* Import Apple system colors */ @import "./css/sf.css";
@layer theme { @theme { /* Typography Scale */ --font-sans: system-ui; --font-mono: ui-monospace; --font-rounded: ui-rounded;
/* Component Tokens */
--component-radius: 12px;
--component-radius-lg: 16px;
--component-radius-full: 9999px;
/* Spacing Scale */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 24px;
/* Animation */
--transition-fast: 150ms;
--transition-normal: 200ms;
--transition-slow: 300ms;
} }
/* Platform-specific overrides */ @media ios { :root { --font-sans: system-ui; --font-rounded: ui-rounded; --component-radius: 10px; } }
@media android { :root { --font-sans: normal; --font-rounded: normal; --component-radius: 8px; } }
Apple System Colors
Create platform-adaptive colors in src/css/sf.css :
@layer base { html { color-scheme: light dark; } }
:root { /* Primary Colors */ --sf-blue: light-dark(rgb(0 122 255), rgb(10 132 255)); --sf-green: light-dark(rgb(52 199 89), rgb(48 209 89)); --sf-red: light-dark(rgb(255 59 48), rgb(255 69 58)); --sf-orange: light-dark(rgb(255 149 0), rgb(255 159 10)); --sf-yellow: light-dark(rgb(255 204 0), rgb(255 214 10)); --sf-purple: light-dark(rgb(175 82 222), rgb(191 90 242)); --sf-pink: light-dark(rgb(255 45 85), rgb(255 55 95));
/* Gray Scale */ --sf-gray: light-dark(rgb(142 142 147), rgb(142 142 147)); --sf-gray-2: light-dark(rgb(174 174 178), rgb(99 99 102)); --sf-gray-3: light-dark(rgb(199 199 204), rgb(72 72 74)); --sf-gray-4: light-dark(rgb(209 209 214), rgb(58 58 60)); --sf-gray-5: light-dark(rgb(229 229 234), rgb(44 44 46)); --sf-gray-6: light-dark(rgb(242 242 247), rgb(28 28 30));
/* Text Colors */ --sf-text: light-dark(rgb(0 0 0), rgb(255 255 255)); --sf-text-2: light-dark(rgb(60 60 67 / 0.6), rgb(235 235 245 / 0.6)); --sf-text-3: light-dark(rgb(60 60 67 / 0.3), rgb(235 235 245 / 0.3)); --sf-text-placeholder: light-dark(rgb(60 60 67 / 0.3), rgb(235 235 245 / 0.3));
/* Background Colors */ --sf-bg: light-dark(rgb(255 255 255), rgb(0 0 0)); --sf-bg-2: light-dark(rgb(242 242 247), rgb(28 28 30)); --sf-grouped-bg: light-dark(rgb(242 242 247), rgb(0 0 0)); --sf-grouped-bg-2: light-dark(rgb(255 255 255), rgb(28 28 30));
/* Border & Fill */ --sf-border: light-dark(rgb(60 60 67 / 0.12), rgb(84 84 88 / 0.65)); --sf-fill: light-dark(rgb(120 120 128 / 0.2), rgb(120 120 128 / 0.32));
/* Link Color */ --sf-link: var(--sf-blue); }
/* iOS: Use native platform colors */ @media ios { :root { --sf-blue: platformColor(systemBlue); --sf-green: platformColor(systemGreen); --sf-red: platformColor(systemRed); --sf-orange: platformColor(systemOrange); --sf-yellow: platformColor(systemYellow); --sf-purple: platformColor(systemPurple); --sf-pink: platformColor(systemPink); --sf-gray: platformColor(systemGray); --sf-gray-2: platformColor(systemGray2); --sf-gray-3: platformColor(systemGray3); --sf-gray-4: platformColor(systemGray4); --sf-gray-5: platformColor(systemGray5); --sf-gray-6: platformColor(systemGray6); --sf-text: platformColor(label); --sf-text-2: platformColor(secondaryLabel); --sf-text-3: platformColor(tertiaryLabel); --sf-text-placeholder: platformColor(placeholderText); --sf-bg: platformColor(systemBackground); --sf-bg-2: platformColor(secondarySystemBackground); --sf-grouped-bg: platformColor(systemGroupedBackground); --sf-grouped-bg-2: platformColor(secondarySystemGroupedBackground); --sf-border: platformColor(separator); --sf-fill: platformColor(tertiarySystemFill); --sf-link: platformColor(link); } }
/* Register as Tailwind theme colors */ @layer theme { @theme { --color-sf-blue: var(--sf-blue); --color-sf-green: var(--sf-green); --color-sf-red: var(--sf-red); --color-sf-orange: var(--sf-orange); --color-sf-yellow: var(--sf-yellow); --color-sf-purple: var(--sf-purple); --color-sf-pink: var(--sf-pink); --color-sf-gray: var(--sf-gray); --color-sf-gray-2: var(--sf-gray-2); --color-sf-gray-3: var(--sf-gray-3); --color-sf-gray-4: var(--sf-gray-4); --color-sf-gray-5: var(--sf-gray-5); --color-sf-gray-6: var(--sf-gray-6); --color-sf-text: var(--sf-text); --color-sf-text-2: var(--sf-text-2); --color-sf-text-3: var(--sf-text-3); --color-sf-text-placeholder: var(--sf-text-placeholder); --color-sf-bg: var(--sf-bg); --color-sf-bg-2: var(--sf-bg-2); --color-sf-grouped-bg: var(--sf-grouped-bg); --color-sf-grouped-bg-2: var(--sf-grouped-bg-2); --color-sf-border: var(--sf-border); --color-sf-fill: var(--sf-fill); --color-sf-link: var(--sf-link); } }
Accessing CSS Variables in JavaScript
import { useCSSVariable } from "@/tw";
function MyComponent() { const primaryColor = useCSSVariable("--sf-blue"); const borderColor = useCSSVariable("--sf-border");
return ( <View style={{ borderColor }}> <Text style={{ color: primaryColor }}>Hello</Text> </View> ); }
Compound Component Pattern
Use compound components for complex, multi-element UI. This provides flexibility while maintaining cohesive behavior.
Template Structure
"use client";
import React, { createContext, use } from "react"; import { View, Text, Pressable } from "@/tw"; import { cn } from "@/lib/utils"; import type { ViewProps, TextProps } from "react-native";
// 1. Define Context for shared state interface ComponentContextValue { variant: "default" | "outline" | "ghost"; size: "sm" | "md" | "lg"; disabled?: boolean; }
const ComponentContext = createContext<ComponentContextValue | null>(null);
function useComponentContext() { const context = use(ComponentContext); if (!context) { throw new Error("Component parts must be used within Component.Root"); } return context; }
// 2. Root component provides context interface RootProps extends ViewProps { variant?: ComponentContextValue["variant"]; size?: ComponentContextValue["size"]; disabled?: boolean; }
function Root({ variant = "default", size = "md", disabled, children, className, ...props }: RootProps) { return ( <ComponentContext value={{ variant, size, disabled }}> <View {...props} className={cn( "flex-row items-center", disabled && "opacity-50", className )} > {children} </View> </ComponentContext> ); }
// 3. Sub-components consume context function Label({ className, ...props }: TextProps) { const { size } = useComponentContext();
return ( <Text {...props} className={cn( "text-sf-text", size === "sm" && "text-sm", size === "md" && "text-base", size === "lg" && "text-lg", className )} /> ); }
function Icon({ className, ...props }: ViewProps) { const { size } = useComponentContext();
const sizeClass = { sm: "w-4 h-4", md: "w-5 h-5", lg: "w-6 h-6", }[size];
return ( <View {...props} className={cn(sizeClass, className)} /> ); }
// 4. Export as compound component export const Component = { Root, Label, Icon, };
// 5. Convenience export for simple usage export function SimpleComponent(props: RootProps & { label: string }) { const { label, ...rootProps } = props; return ( <Component.Root {...rootProps}> <Component.Label>{label}</Component.Label> </Component.Root> ); }
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
// Check current platform if (process.env.EXPO_OS === "ios") { // iOS-specific behavior } else if (process.env.EXPO_OS === "android") { // Android-specific behavior } else if (process.env.EXPO_OS === "web") { // Web-specific behavior }
// Check for specific features import { isLiquidGlassAvailable } from "expo-glass-effect"; const GLASS = isLiquidGlassAvailable(); // iOS 26+ liquid glass
Platform-Specific File Example: Switch
switch.tsx (default - re-exports native):
export { Switch, type SwitchProps } from "react-native";
switch.web.tsx (web - iOS-styled custom):
"use client";
import { useState, useRef, useEffect } from "react"; import { View, Animated, PanResponder, StyleSheet, Pressable, } from "react-native";
export type SwitchProps = { value?: boolean; onValueChange?: (value: boolean) => void; disabled?: boolean; thumbColor?: string; trackColor?: { true: string; false: string }; ios_backgroundColor?: string; };
export function Switch({ value = false, onValueChange, disabled = false, thumbColor = "#fff", trackColor = { true: "#34C759", false: "#E9E9EA" }, ios_backgroundColor, }: SwitchProps) { const [isOn, setIsOn] = useState(value); const animatedValue = useRef(new Animated.Value(value ? 1 : 0)).current;
useEffect(() => { setIsOn(value); Animated.spring(animatedValue, { toValue: value ? 1 : 0, useNativeDriver: false, friction: 8, tension: 40, }).start(); }, [value, animatedValue]);
const toggle = () => { if (disabled) return; const newValue = !isOn; setIsOn(newValue); onValueChange?.(newValue); };
const translateX = animatedValue.interpolate({ inputRange: [0, 1], outputRange: [2, 22], });
const bgColor = animatedValue.interpolate({ inputRange: [0, 1], outputRange: [ ios_backgroundColor || trackColor.false, trackColor.true, ], });
return ( <Pressable onPress={toggle} disabled={disabled}> <Animated.View style={[ styles.track, { backgroundColor: bgColor }, disabled && styles.disabled, ]} > <Animated.View style={[ styles.thumb, { backgroundColor: thumbColor, transform: [{ translateX }] }, ]} /> </Animated.View> </Pressable> ); }
const styles = StyleSheet.create({ track: { width: 51, height: 31, borderRadius: 15.5, justifyContent: "center", }, thumb: { width: 27, height: 27, borderRadius: 13.5, shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.2, shadowRadius: 2, elevation: 2, }, disabled: { opacity: 0.5, }, });
Accessibility Patterns
Keyboard Avoidance
For forms with text input, proper keyboard handling is critical:
import { useReanimatedKeyboardAnimation, useKeyboardHandler, } from "react-native-keyboard-controller"; import { useAnimatedStyle } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context";
function KeyboardAwareForm({ children }: { children: React.ReactNode }) { const { bottom } = useSafeAreaInsets(); const { height, progress } = useReanimatedKeyboardAnimation();
const animatedStyle = useAnimatedStyle(() => ({ paddingBottom: Math.max(bottom, Math.abs(height.value)), }));
return ( <Animated.View style={[{ flex: 1 }, animatedStyle]}> {children} </Animated.View> ); }
Safe Area Handling
Always account for safe areas on notched devices:
import { useSafeAreaInsets } from "react-native-safe-area-context";
function SafeContainer({ children }: { children: React.ReactNode }) { const { top, bottom, left, right } = useSafeAreaInsets();
return ( <View style={{ flex: 1, paddingTop: top, paddingBottom: bottom, paddingLeft: left, paddingRight: right, }} > {children} </View> ); }
Form Accessibility Pattern
import { View, Text, TextInput } from "@/tw"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
interface FormFieldProps { label: string; hint?: string; error?: string; children: React.ReactNode; }
function FormField({ label, hint, error, children }: FormFieldProps) { return ( <View className="gap-1"> <Text className="text-sf-text-2 text-sm font-medium" accessibilityRole="text" > {label} </Text> {children} {hint && !error && ( <Text className="text-sf-text-3 text-xs">{hint}</Text> )} {error && ( <Text className="text-sf-red text-xs" accessibilityRole="alert" > {error} </Text> )} </View> ); }
function AccessibleForm() { const { bottom } = useSafeAreaInsets();
return ( <KeyboardAwareScrollView contentContainerStyle={{ padding: 16, paddingBottom: bottom + 16, gap: 16, }} keyboardShouldPersistTaps="handled" > <FormField label="Email" hint="We'll never share your email"> <TextInput className="bg-sf-fill rounded-xl px-4 py-3 text-sf-text" placeholder="you@example.com" keyboardType="email-address" autoCapitalize="none" autoComplete="email" textContentType="emailAddress" accessibilityLabel="Email address" /> </FormField>
<FormField label="Password">
<TextInput
className="bg-sf-fill rounded-xl px-4 py-3 text-sf-text"
placeholder="••••••••"
secureTextEntry
autoComplete="password"
textContentType="password"
accessibilityLabel="Password"
/>
</FormField>
</KeyboardAwareScrollView>
); }
iOS Liquid Glass Styling
Detecting Liquid Glass Support
import { isLiquidGlassAvailable } from "expo-glass-effect";
const GLASS = isLiquidGlassAvailable();
const HEADER_OPTIONS = GLASS ? { headerTransparent: true, headerShadowVisible: false, headerBlurEffect: "none", } : { headerTransparent: true, headerBlurEffect: "systemChromeMaterial", headerShadowVisible: true, };
Tab Bar with Glass Effect
import { BlurView } from "expo-blur";
function GlassTabBarBackground() { return ( <BlurView intensity={100} tint="systemChromeMaterial" style={StyleSheet.absoluteFill} /> ); }
// Usage in Tabs const TAB_OPTIONS = process.env.EXPO_OS === "ios" ? { tabBarBackground: GlassTabBarBackground, tabBarStyle: { position: "absolute" }, } : {};
Glass Card Component
import { BlurView } from "expo-blur"; import { View } from "@/tw"; import { cn } from "@/lib/utils";
interface GlassCardProps extends React.ComponentProps<typeof View> { intensity?: number; }
function GlassCard({ intensity = 50, className, children, ...props }: GlassCardProps) { if (process.env.EXPO_OS !== "ios") { // Fallback for non-iOS return ( <View {...props} className={cn( "bg-sf-bg-2/80 rounded-2xl overflow-hidden", className )} > {children} </View> ); }
return ( <View {...props} className={cn("rounded-2xl overflow-hidden", className)} > <BlurView intensity={intensity} tint="systemChromeMaterial" style={StyleSheet.absoluteFill} /> <View className="relative">{children}</View> </View> ); }
Form Components Pattern
The Form compound component demonstrates all principles together:
"use client";
import React, { createContext, use } from "react"; import { View, Text, TextInput, ScrollView, TouchableHighlight } from "@/tw"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { cn } from "@/lib/utils"; import { useCSSVariable } from "@/tw";
// Context for form styling const FormContext = createContext<{ listStyle: "grouped" | "inset"; sheet?: boolean; }>({ listStyle: "inset" });
// List wrapper with pull-to-refresh function List({ children, listStyle = "inset", sheet, ...props }: React.ComponentProps<typeof ScrollView> & { listStyle?: "grouped" | "inset"; sheet?: boolean; }) { const { bottom } = useSafeAreaInsets();
return ( <FormContext value={{ listStyle, sheet }}> <ScrollView contentContainerStyle={{ paddingVertical: 16, gap: 24 }} contentInsetAdjustmentBehavior="automatic" scrollIndicatorInsets={{ bottom }} className={cn( sheet ? "bg-transparent" : "bg-sf-grouped-bg", props.className )} {...props} > {children} </ScrollView> </FormContext> ); }
// Section groups related items function Section({ children, title, footer, ...props }: React.ComponentProps<typeof View> & { title?: string; footer?: string; }) { const { listStyle, sheet } = use(FormContext); const isInset = listStyle === "inset";
return ( <View style={{ paddingHorizontal: isInset ? 16 : 0 }} {...props}> {title && ( <Text className="uppercase text-sf-text-2 text-sm px-5 pb-2"> {title} </Text> )} <View className={cn( sheet ? "bg-sf-bg-2" : "bg-sf-grouped-bg-2", isInset ? "rounded-xl overflow-hidden" : "border-y border-sf-border" )} > {React.Children.map(children, (child, index) => ( <> {child} {index < React.Children.count(children) - 1 && ( <View className="border-b border-sf-border ml-4" /> )} </> ))} </View> {footer && ( <Text className="text-sf-text-2 text-sm px-5 pt-2">{footer}</Text> )} </View> ); }
// Individual form item with optional navigation function Item({ children, onPress, href, ...props }: React.ComponentProps<typeof View> & { onPress?: () => void; href?: string; }) { const underlayColor = useCSSVariable("--sf-gray-4");
const content = ( <View className="flex-row items-center px-4 py-3 min-h-[44px]" {...props}> {children} </View> );
if (!onPress && !href) return content;
return ( <TouchableHighlight onPress={onPress} underlayColor={underlayColor} className="web:hover:bg-sf-fill web:transition-colors" > {content} </TouchableHighlight> ); }
// Text label function Label({ className, ...props }: React.ComponentProps<typeof Text>) { return ( <Text {...props} className={cn("text-sf-text text-base flex-1", className)} /> ); }
// Hint/value on the right function Hint({ className, ...props }: React.ComponentProps<typeof Text>) { return ( <Text {...props} className={cn("text-sf-text-2 text-base", className)} /> ); }
// Export compound component export const Form = { List, Section, Item, Label, Hint, };
Usage
<Form.List> <Form.Section title="Account" footer="Your account settings"> <Form.Item href="/profile"> <Form.Label>Profile</Form.Label> <Form.Hint>John Doe</Form.Hint> <ChevronRight /> </Form.Item> <Form.Item href="/email"> <Form.Label>Email</Form.Label> <Form.Hint>john@example.com</Form.Hint> <ChevronRight /> </Form.Item> </Form.Section>
<Form.Section title="Preferences"> <Form.Item> <Form.Label>Dark Mode</Form.Label> <Switch value={darkMode} onValueChange={setDarkMode} /> </Form.Item> </Form.Section> </Form.List>
Haptic Feedback
Platform-Safe Haptics
lib/haptics.ts (native):
import * as Haptics from "expo-haptics";
export const haptics = { light: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light), medium: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium), heavy: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy), success: () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success), warning: () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning), error: () => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error), selection: () => Haptics.selectionAsync(), };
lib/haptics.web.ts (web - no-op):
export const haptics = { light: () => {}, medium: () => {}, heavy: () => {}, success: () => {}, warning: () => {}, error: () => {}, selection: () => {}, };
Usage in Components
import { haptics } from "@/lib/haptics";
function HapticButton({ onPress, children }) { const handlePress = () => { haptics.light(); onPress?.(); };
return <Pressable onPress={handlePress}>{children}</Pressable>; }
Icon System
SF Symbol Icons with Fallbacks
import { SymbolView, SymbolWeight } from "expo-symbols"; import { MaterialIcons } from "@expo/vector-icons";
// Map SF Symbol names to Material Icons const ICON_MAPPING: Record<string, string> = { "house.fill": "home", "gear": "settings", "person.fill": "person", "magnifyingglass": "search", "chevron.right": "chevron_right", };
interface IconProps { name: string; size?: number; color?: string; weight?: SymbolWeight; }
export function Icon({ name, size = 24, color, weight }: IconProps) { if (process.env.EXPO_OS === "ios") { return ( <SymbolView name={name} size={size} tintColor={color} weight={weight} /> ); }
const materialName = ICON_MAPPING[name] || name; return <MaterialIcons name={materialName} size={size} color={color} />; }
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 displayName in dev for debugging
if (DEV) { MyComponent.displayName = "MyComponent"; }
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