| name | reusable-ui-components |
| description | Guidelines for creating reusable, portable UI components with native-first design, compound patterns, and accessibility |
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 "./css/sf.css";
@layer theme {
@theme {
--font-sans: system-ui;
--font-mono: ui-monospace;
--font-rounded: ui-rounded;
--component-radius: 12px;
--component-radius-lg: 16px;
--component-radius-full: 9999px;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 24px;
--transition-fast: 150ms;
--transition-normal: 200ms;
--transition-slow: 300ms;
}
}
@media ios {
:root {
--font-sans: system-ui;
--font-rounded: ui-rounded;
--component-radius: 10px;
}
}
@media android {
:root {
: normal;
: normal;
: ;
}
}
Apple System Colors
Create platform-adaptive colors in src/css/sf.css:
@layer base {
html {
color-scheme: light dark;
}
}
:root {
--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( ), ( ));
: (( ), ( ));
: (( ), ( ));
: (( ), ( ));
: (( ), ( ));
: (( ), ( ));
: (( ), ( ));
: (( ), ( ));
: (( ), ( ));
: (( / ), ( / ));
: (( / ), ( / ));
: (( / ), ( / ));
: (( ), ( ));
: (( ), ( ));
: (( ), ( ));
: (( ), ( ));
: (( / ), ( / ));
: (( / ), ( / ));
: (--sf-blue);
}
ios {
{
: (systemBlue);
: (systemGreen);
: (systemRed);
: (systemOrange);
: (systemYellow);
: (systemPurple);
: (systemPink);
: (systemGray);
: (systemGray2);
: (systemGray3);
: (systemGray4);
: (systemGray5);
: (systemGray6);
: (label);
: (secondaryLabel);
: (tertiaryLabel);
: (placeholderText);
: (systemBackground);
: (secondarySystemBackground);
: (systemGroupedBackground);
: (secondarySystemGroupedBackground);
: (separator);
: (tertiarySystemFill);
: (link);
}
}
theme {
{
: (--sf-blue);
: (--sf-green);
: (--sf-red);
: (--sf-orange);
: (--sf-yellow);
: (--sf-purple);
: (--sf-pink);
: (--sf-gray);
: (--sf-gray-);
: (--sf-gray-);
: (--sf-gray-);
: (--sf-gray-);
: (--sf-gray-);
: (--sf-text);
: (--sf-text-);
: (--sf-text-);
: (--sf-text-placeholder);
: (--sf-bg);
: (--sf-bg-);
: (--sf-grouped-bg);
: (--sf-grouped-bg-);
: (--sf-border);
: (--sf-fill);
: (--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";
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;
}
{
?: [];
?: [];
?: ;
}
() {
(
);
}
() {
{ size } = ();
(
);
}
() {
{ size } = ();
sizeClass = {
: ,
: ,
: ,
}[size];
(
);
}
= {
,
,
,
};
() {
{ label, ...rootProps } = props;
(
);
}
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
if (process.env.EXPO_OS === "ios") {
} else if (process.env.EXPO_OS === "android") {
} else if (process.env.EXPO_OS === "web") {
}
import { isLiquidGlassAvailable } from "expo-glass-effect";
const GLASS = isLiquidGlassAvailable();
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);
.(animatedValue, {
: value ? : ,
: ,
: ,
: ,
}).();
}, [value, animatedValue]);
= () => {
(disabled) ;
newValue = !isOn;
(newValue);
onValueChange?.(newValue);
};
translateX = animatedValue.({
: [, ],
: [, ],
});
bgColor = animatedValue.({
: [, ],
: [
ios_backgroundColor || trackColor.,
trackColor.,
],
});
(
);
}
styles = .({
: {
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
: { : , : },
: ,
: ,
: ,
},
: {
: ,
},
});
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=
>
{error}
)}
);
}
() {
{ bottom } = ();
(
);
}
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}
/>
);
}
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") {
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)}
>
<
=
=
=
/>
{children}
);
}
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";
const FormContext = createContext<{
listStyle: "grouped" | "inset";
sheet?: boolean;
}>({ listStyle: "inset" });
function List({
children,
listStyle = "inset",
sheet,
...props
}: React.ComponentProps<typeof ScrollView> & {
listStyle?: "grouped" | "inset";
sheet?: boolean;
}) {
const { bottom } = useSafeAreaInsets();
return (
<FormContext value= , }}>
{children}
);
}
() {
{ listStyle, sheet } = ();
isInset = listStyle === ;
(
{footer && (
)}
</>
);
}
() {
underlayColor = ();
content = (
);
(!onPress && !href) content;
(
);
}
() {
(
);
}
() {
(
);
}
= {
,
,
,
,
,
};
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} />
</>
</.>
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.(),
};
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";
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=
=
/>
);
}
materialName = [name] || name;
;
}
Component Checklist
When creating a new component, ensure:
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 |