reusable-ui-components

Creating Reusable UI Components for Expo Router

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "reusable-ui-components" with this command: npx skills add evanbacon/crispy/evanbacon-crispy-reusable-ui-components

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>

  &#x3C;FormField label="Password">
    &#x3C;TextInput
      className="bg-sf-fill rounded-xl px-4 py-3 text-sf-text"
      placeholder="••••••••"
      secureTextEntry
      autoComplete="password"
      textContentType="password"
      accessibilityLabel="Password"
    />
  &#x3C;/FormField>
&#x3C;/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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

expo-modules

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

healthkit-cli

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

expo-chat-ui

No summary provided by upstream source.

Repository SourceNeeds Review