Caracal Starter
UI & Theming

Custom Components

Documentation for custom UI components built specifically for Caracal starter.

While most UI components come from HeroUI Native, the Caracal starter includes a small set of custom components designed for specific needs not covered by the UI library.

Design Philosophy: We keep custom components minimal and focused. For general UI needs (buttons, inputs, cards, etc.), use HeroUI Native components. Only use these custom components for their specific purposes.

Custom Components Overview

Located in src/components/ui/, the starter provides 6 custom components:

  1. Image - Expo Image wrapper with Uniwind support
  2. Text - i18n-enabled text component with RTL support
  3. ControlledInput - Form input with react-hook-form integration
  4. List - FlashList wrapper with empty state support
  5. Modal - Bottom sheet modal wrapper
  6. FocusAwareStatusBar - Status bar that updates on screen focus

Image Component

A wrapper around expo-image with Uniwind (TailwindCSS) styling support via the className prop.

Why a custom Image?

  • Uniwind Integration: Native expo-image doesn't support className — this wrapper adds it
  • Performance: Uses expo-image which is faster and more efficient than React Native's Image
  • Consistent API: Provides the same styling approach as other components

Props:

  • All expo-image props are supported
  • className - Uniwind class names for styling

Example:

import { Image } from '@/components/ui';

export function ProfilePicture() {
  return (
    <Image
      className="h-24 w-24 rounded-full"
      source={{ uri: 'https://example.com/avatar.jpg' }}
      contentFit="cover"
      placeholder={require('@assets/images/placeholder.png')}
    />
  );
}

Common Use Cases:

// Avatar image
<Image className="h-16 w-16 rounded-full" source={avatarUri} />

// Full width banner
<Image className="h-48 w-full" source={bannerUri} contentFit="cover" />

// Card thumbnail
<Image className="h-32 w-32 rounded-lg" source={thumbUri} />

// Icon image
<Image className="h-6 w-6" source={iconUri} contentFit="contain" />

Text Component

A custom Text component with built-in internationalization (i18n) support and right-to-left (RTL) language handling.

Why a custom Text?

  • i18n Integration: Use translation keys directly with the tx prop
  • RTL Support: Automatically handles text direction for Arabic, Hebrew, etc.
  • Consistent Styling: Supports Uniwind className prop

Props:

  • All React Native Text props are supported
  • className - Uniwind class names
  • tx - Translation key for i18next

Example with Translation:

import { Text, View } from '@/components/ui';

export function WelcomeScreen() {
  return (
    <View className="flex-1 items-center justify-center">
      <Text className="text-2xl font-bold" tx="welcome.title" />
      <Text className="text-base text-gray-600" tx="welcome.subtitle" />
    </View>
  );
}

Example with Regular Text:

import { Text } from '@/components/ui';

export function Greeting({ name }: { name: string }) {
  return (
    <Text className="text-lg">
      Hello, {name}!
    </Text>
  );
}

RTL Support:

The component automatically detects the current locale and applies proper text direction:

// English: left-to-right
<Text tx="welcome" /> // "Welcome" →

// Arabic: right-to-left
<Text tx="welcome" /> // ← "مرحبا"

Use the t snippet in VSCode to quickly create a Text component with a default className.


ControlledInput Component

A wrapper around HeroUI Native's TextField integrated with react-hook-form for seamless form handling.

Why a custom ControlledInput?

  • Form Integration: Pre-wired with react-hook-form's useController
  • Validation Support: Automatically displays validation errors
  • Type Safety: Full TypeScript support with form schemas
  • Consistent API: Same props as TextField but controlled

Props:

  • All HeroUI Native TextField props
  • control - react-hook-form control object (required)
  • name - Field name in form (required)
  • Additional react-hook-form controller props

Example:

import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
import * as z from 'zod';
import { Button } from 'heroui-native';
import { ControlledInput, View } from '@/components/ui';

const schema = z.object({
  email: z.string().email('Invalid email format'),
  password: z.string().min(6, 'Password must be at least 6 characters'),
});

type FormData = z.infer<typeof schema>;

export function LoginForm() {
  const { control, handleSubmit } = useForm<FormData>({
    resolver: zodResolver(schema),
  });

  const onSubmit = (data: FormData) => {
    console.log(data);
  };

  return (
    <KeyboardAvoidingView behavior="padding" className="flex-1 p-4">
      <View className="gap-4">
        <ControlledInput
          control={control}
          name="email"
          label="Email"
          placeholder="Enter your email"
          keyboardType="email-address"
          autoCapitalize="none"
        />

        <ControlledInput
          control={control}
          name="password"
          label="Password"
          placeholder="Enter your password"
          secureTextEntry
        />

        <Button onPress={handleSubmit(onSubmit)}>
          Sign In
        </Button>
      </View>
    </KeyboardAvoidingView>
  );
}

See Also: Forms documentation for complete form handling patterns.


List Component

A wrapper around @shopify/flash-list with an integrated empty state component.

Why a custom List?

  • Performance: FlashList is significantly faster than FlatList for large lists
  • Empty State: Built-in EmptyList component for better UX
  • Consistent API: Familiar FlatList-like API

Props:

  • All @shopify/flash-list props are supported

Example:

import { List, EmptyList, Text, View } from '@/components/ui';

type Post = {
  id: string;
  title: string;
  body: string;
};

export function PostsList({ posts }: { posts: Post[] }) {
  return (
    <List
      data={posts}
      renderItem={({ item }) => (
        <View className="border-b border-gray-200 p-4">
          <Text className="text-lg font-bold">{item.title}</Text>
          <Text className="text-gray-600">{item.body}</Text>
        </View>
      )}
      estimatedItemSize={100}
      ListEmptyComponent={<EmptyList message="No posts yet" />}
    />
  );
}

EmptyList Component:

import { EmptyList } from '@/components/ui';

<EmptyList message="No items found" />

Performance Tips:

  • Always provide estimatedItemSize for best performance
  • Use keyExtractor for stable item keys
  • Memoize renderItem if items contain complex components

A bottom sheet modal implementation using @gorhom/bottom-sheet.

Why a custom Modal?

  • Mobile-First: Bottom sheets provide better mobile UX than center modals
  • Flexible: Supports dynamic content height with snap points
  • Gesture-Driven: Swipe to dismiss with smooth animations
  • Full Control: Complete control over UI and behavior

Props:

  • All @gorhom/bottom-sheet props are supported
  • title (string) - Modal title displayed at the top
  • children - Modal content

Example:

import { Button } from 'heroui-native';
import { Modal, useModal, View, Text } from '@/components/ui';

export function SettingsScreen() {
  const modal = useModal();

  return (
    <View className="flex-1 p-4">
      <Button onPress={modal.present}>
        Open Settings
      </Button>

      <Modal
        ref={modal.ref}
        title="Settings"
        snapPoints={['60%', '90%']}
      >
        <View className="p-4">
          <Text className="text-lg">Settings content here</Text>
          <Button onPress={modal.dismiss}>
            Close
          </Button>
        </View>
      </Modal>
    </View>
  );
}

useModal Hook:

const modal = useModal();

modal.present();  // Open the modal
modal.dismiss();  // Close the modal
modal.ref;        // Ref for the Modal component

Snap Points:

// Percentage-based
<Modal snapPoints={['25%', '50%', '90%']} />

// Pixel-based
<Modal snapPoints={[200, 400, 600]} />

FocusAwareStatusBar Component

A StatusBar wrapper that updates based on the currently focused screen in Expo Router.

Why FocusAwareStatusBar?

  • Route-Aware: Different screens can have different status bar styles
  • Automatic: Updates when navigating between screens
  • Expo Router Integration: Works seamlessly with file-based routing

Props:

  • All React Native StatusBar props are supported

Example:

import { FocusAwareStatusBar, View, Text } from '@/components/ui';

export default function HomeScreen() {
  return (
    <View className="flex-1 bg-white">
      <FocusAwareStatusBar barStyle="dark-content" />
      <Text className="text-2xl">Home Screen</Text>
    </View>
  );
}

Different Styles per Screen:

// Light content on dark background
export default function DarkScreen() {
  return (
    <View className="flex-1 bg-black">
      <FocusAwareStatusBar barStyle="light-content" />
      <Text className="text-white">Dark Screen</Text>
    </View>
  );
}

// Dark content on light background
export default function LightScreen() {
  return (
    <View className="flex-1 bg-white">
      <FocusAwareStatusBar barStyle="dark-content" />
      <Text className="text-black">Light Screen</Text>
    </View>
  );
}

Re-exported React Native Components

src/components/ui/index.tsx also re-exports common React Native components with Uniwind support:

import {
  View,              // React Native View
  ScrollView,        // React Native ScrollView
  ActivityIndicator, // React Native ActivityIndicator
  Pressable,         // React Native Pressable
  TouchableOpacity,  // React Native TouchableOpacity
  SafeAreaView,      // react-native-safe-area-context
} from '@/components/ui';

Usage:

import { View, ScrollView, SafeAreaView } from '@/components/ui';

export function MyScreen() {
  return (
    <SafeAreaView className="flex-1">
      <ScrollView className="flex-1">
        <View className="p-4">
          {/* Content */}
        </View>
      </ScrollView>
    </SafeAreaView>
  );
}

When to Create Custom Components

Create a custom component when:

  1. HeroUI Native doesn't provide it — like the i18n-enabled Text component
  2. Specific integration needed — like ControlledInput for react-hook-form
  3. Performance optimization required — like List wrapping FlashList
  4. Platform-specific behavior — like FocusAwareStatusBar for Expo Router

Don't create custom components for:

  • Buttons, inputs, cards, dialogs → use HeroUI Native
  • Standard layouts → use View, ScrollView from @/components/ui
  • Icons → create SVG components in src/components/ui/icons/

Import Patterns

Correct imports:

// Custom components
import {
  Image,
  Text,
  View,
  ControlledInput,
  List,
  Modal,
  FocusAwareStatusBar,
} from '@/components/ui';

// HeroUI Native components
import {
  Button,
  TextField,
  Card,
  Dialog,
} from 'heroui-native';

Incorrect imports:

// Don't import HeroUI Native components from @/components/ui
import { Button } from '@/components/ui';

// Don't import custom components from heroui-native
import { Text } from 'heroui-native';

Adding New Custom Components

If you need to add a custom component:

  1. Check HeroUI Native first — does it already provide what you need?
  2. Create in src/components/ui/ — follow existing file structure
  3. Export from index.tsx — add to the main export file
  4. Support className — use withUniwind() if needed
  5. Keep it simple — avoid over-engineering

Example structure:

// src/components/ui/my-component.tsx
import * as React from 'react';
import { View } from 'react-native';
import { withUniwind } from 'uniwind';

type MyComponentProps = {
  children: React.ReactNode;
  className?: string;
};

const MyComponentBase = ({ children, ...props }: MyComponentProps) => {
  return <View {...props}>{children}</View>;
};

export const MyComponent = withUniwind(MyComponentBase);
// src/components/ui/index.tsx
export * from './my-component';

Styling Custom Components

All custom components support the className prop for Uniwind styling:

import { Text, View, Image } from '@/components/ui';

<View className="flex-1 bg-white p-4">
  <Image className="h-32 w-32 rounded-full mb-4" source={avatar} />
  <Text className="text-2xl font-bold text-gray-900">Title</Text>
  <Text className="text-base text-gray-600">Subtitle</Text>
</View>

Exception: Components using Animated.View from react-native-reanimated must use the style prop:

import Animated from 'react-native-reanimated';

// Won't work
<Animated.View className="flex-1" />

// Correct
<Animated.View style={{ flex: 1 }} />

Resources


Summary

Use HeroUI Native for:

  • Buttons, TextField, Card, Dialog, Tabs, Checkbox, Switch, Select, etc.
  • Any standard UI component
  • Form controls

Use Custom Components for:

  • Image (Uniwind support)
  • Text (i18n support)
  • ControlledInput (form integration)
  • List (FlashList wrapper)
  • Modal (bottom sheet)
  • FocusAwareStatusBar (Expo Router integration)

On this page