Skip to content

UI Styling Guide

This guide covers the UI styling approach used in the Coaching App, including NativeWind setup, best practices, and examples.

Table of Contents

Overview

The Coaching App uses NativeWind for styling across all platforms (web, iOS, and Android). NativeWind is a utility-first CSS framework that brings Tailwind CSS to React Native.

Pixel-Perfect Requirement

Pixel-perfect delivery is mandatory for all UI work. This is a release requirement, not a preference.

  • Implement screens/components to match the approved design reference exactly.
  • Always run a final visual fidelity pass before marking work complete.
  • "Approximate" visual matches are not acceptable.
  • Validate on all relevant platforms for the task (web, iOS, Android).

Why NativeWind?

  • Cross-platform consistency: Same styles work on web, iOS, and Android
  • Utility-first approach: Faster development with pre-defined utility classes
  • TypeScript support: Full type safety for className props
  • Responsive design: Built-in responsive modifiers
  • Easy to learn: If you know Tailwind CSS, you know NativeWind

Typography Tokens

Typography is centralized in the frontend theme layer so web and native can use different fonts for semantic roles while keeping one API.

Source of Truth

  • frontend/src/theme/font-families.js defines base font families (FONT_FAMILY) and per-platform semantic roles (FONT_ROLE).
  • frontend/src/theme/fonts.ts exposes typed tokens for app code and the font loading map used by useFonts.

Semantic Roles

  • FONT_ROLE.web.header: web heading/navigation font
  • FONT_ROLE.web.content: web body/content font
  • FONT_ROLE.native.header: iOS/Android heading/navigation font
  • FONT_ROLE.native.content: iOS/Android body/content font

Use these semantic tokens instead of hardcoding font names in components.

Usage Examples

import { FONT_ROLE } from '../theme/fonts';

const styles = StyleSheet.create({
  title: {
    fontFamily: FONT_ROLE.native.header,
  },
  body: {
    fontFamily: FONT_ROLE.native.content,
  },
});
// tailwind.config.js
const { FONT_ROLE } = require('./src/theme/font-families');

fontFamily: {
  heading: [FONT_ROLE.web.header],
  content: [FONT_ROLE.web.content],
}

Web Base Defaults

  • frontend/global.css sets body to content font and h1..h6 to header font.

NativeWind Setup

Installation

The project already has NativeWind configured. The setup includes:

yarn add nativewind tailwindcss@3.3.2 react-native-worklets-core

Configuration Files

1. tailwind.config.js

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ['./App.{js,jsx,ts,tsx}', './src/**/*.{js,jsx,ts,tsx}'],
  presets: [require('nativewind/preset')],
  theme: {
    extend: {},
  },
  plugins: [],
};

2. babel.config.js

module.exports = function(api) {
  api.cache(true);
  return {
    presets: [
      [
        'babel-preset-expo',
        {
          unstable_transformProfile: 'hermes-stable',
        },
    plugins: [
      'nativewind/babel',
    ],
  };
};

3. global.css

``cssmetro.config.js

const { getDefaultConfig } = require('expo/metro-config');
#### 4. `global.css`

```css
@tailwind base;
#### 5. `App.tsx`

Import the global CSS file:

```typescript
import './global.css';

6olve workspace packages

const projectRoot = __dirname; const workspaceRoot = path.resolve(projectRoot, '..');

config.watchFolders = [workspaceRoot]; config.resolver.nodeModulesPaths = [ path.resolve(projectRoot, 'node_modules'), path.resolve(workspaceRoot, 'node_modules'), ];

// NativeWind configuration const { withNativeWind } = require('nativewind/metro');

module.exports = withNativeWind(config, { input: './global.css' });

#### 4. `
@tailwind base;
@tailwind components;
@tailwind utilities;

4. App.tsx

Import the global CSS file:

import './global.css';

5. nativewind-env.d.ts

Type definitions for NativeWind:

/// <reference types="nativewind/types" />

Using NativeWind Classes

Basic Example

Instead of using StyleSheet.create(), use the className prop:

Before (StyleSheet):

import { View, Text, StyleSheet } from 'react-native';

const MyComponent = () => (
  <View style={styles.container}>
    <Text style={styles.title}>Hello World</Text>
  </View>
);

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f5f5f5',
    padding: 20,
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    color: '#333',
  },
});

After (NativeWind):

import { View, Text } from 'react-native';

const MyComponent = () => (
  <View className="flex-1 bg-gray-100 p-5">
    <Text className="text-2xl font-bold text-gray-800">Hello World</Text>
  </View>
);

Conditional Classes

Use template literals for conditional styling:

<TouchableOpacity className={`px-4 py-2 rounded-lg ${isActive ? 'bg-blue-500' : 'bg-gray-300'}`}>
  <Text className={`text-base ${isActive ? 'text-white' : 'text-gray-600'}`}>Button</Text>
</TouchableOpacity>

Dynamic Classes

const [isPressed, setIsPressed] = useState(false);

<TouchableOpacity
  className={`h-12 rounded-lg justify-center items-center ${
    isPressed ? 'bg-blue-600' : 'bg-blue-500'
  } ${disabled ? 'opacity-50' : 'opacity-100'}`}
  onPressIn={() => setIsPressed(true)}
  onPressOut={() => setIsPressed(false)}
>
  <Text className="text-white font-semibold">Press Me</Text>
</TouchableOpacity>;

Common Patterns

Container Layouts

{
  /* Full screen container */
}
<View className="flex-1 bg-gray-100 p-5">{/* Content */}</View>;

{
  /* Centered content */
}
<View className="flex-1 justify-center items-center">{/* Content */}</View>;

{
  /* Card container */
}
<View className="bg-white rounded-xl p-6 shadow-lg">{/* Content */}</View>;

Typography

{
  /* Heading */
}
<Text className="text-2xl font-bold text-gray-800 mb-2">Title</Text>;

{
  /* Subtitle */
}
<Text className="text-base text-gray-600 mb-4">Subtitle</Text>;

{
  /* Body text */
}
<Text className="text-sm text-gray-700">Body text</Text>;

Buttons

{
  /* Primary button */
}
<TouchableOpacity className="px-6 py-3 bg-blue-500 rounded-lg">
  <Text className="text-white font-semibold text-center">Primary</Text>
</TouchableOpacity>;

{
  /* Secondary button */
}
<TouchableOpacity className="px-6 py-3 bg-gray-200 rounded-lg">
  <Text className="text-gray-800 font-semibold text-center">Secondary</Text>
</TouchableOpacity>;

{
  /* Danger button */
}
<TouchableOpacity className="px-6 py-3 bg-red-600 rounded-lg">
  <Text className="text-white font-semibold text-center">Delete</Text>
</TouchableOpacity>;

Text Inputs

<TextInput
  className="h-12 border border-gray-300 rounded-lg px-4 text-base bg-white"
  placeholder="Enter text"
/>;

{
  /* With error state */
}
<TextInput
  className={`h-12 rounded-lg px-4 text-base ${
    hasError ? 'border-red-500 border-2' : 'border border-gray-300'
  }`}
  placeholder="Enter text"
/>;

Modals and Overlays

<Modal visible={isOpen} transparent animationType="fade">
  <TouchableOpacity
    className="flex-1 bg-black/50 justify-center items-center"
    activeOpacity={1}
    onPress={onClose}
  >
    <View className="w-11/12 max-w-md bg-white rounded-xl p-6 shadow-xl">
      <Text className="text-lg font-bold mb-4">Modal Title</Text>
      {/* Modal content */}
    </View>
  </TouchableOpacity>
</Modal>

Lists and Tables

{
  /* Table row */
}
<View className="flex-row border-b border-gray-200 py-3 px-2">
  <View className="flex-1">
    <Text className="text-sm text-gray-800">Cell 1</Text>
  </View>
  <View className="flex-1">
    <Text className="text-sm text-gray-600">Cell 2</Text>
  </View>
</View>;

Badges and Status Indicators

{
  /* Success badge */
}
<View className="px-3 py-1.5 bg-green-500 rounded-xl">
  <Text className="text-white text-xs font-semibold">Active</Text>
</View>;

{
  /* Warning badge */
}
<View className="px-3 py-1.5 bg-yellow-500 rounded-xl">
  <Text className="text-white text-xs font-semibold">Pending</Text>
</View>;

{
  /* Error badge */
}
<View className="px-3 py-1.5 bg-red-500 rounded-xl">
  <Text className="text-white text-xs font-semibold">Suspended</Text>
</View>;

Migration from StyleSheet

When migrating existing components from StyleSheet to NativeWind:

Step 1: Remove StyleSheet Import

- import { View, Text, StyleSheet } from 'react-native';
+ import { View, Text } from 'react-native';

Step 2: Convert Styles to Classes

Use this mapping table:

StyleSheet Property NativeWind Class
flex: 1 flex-1
flexDirection: 'row' flex-row
justifyContent: 'center' justify-center
alignItems: 'center' items-center
padding: 20 p-5 (20px = 5 * 4px)
paddingHorizontal: 16 px-4
paddingVertical: 12 py-3
margin: 8 m-2
backgroundColor: '#fff' bg-white
color: '#333' text-gray-800
fontSize: 24 text-2xl
fontWeight: 'bold' font-bold
borderRadius: 8 rounded-lg
borderWidth: 1 border
borderColor: '#ddd' border-gray-300
shadowColor, shadowOffset, etc. shadow-lg

Step 3: Replace style Props with className

- <View style={styles.container}>
+ <View className="flex-1 bg-gray-100 p-5">

Step 4: Remove StyleSheet.create()

- const styles = StyleSheet.create({
-   container: {
-     flex: 1,
-     backgroundColor: '#f5f5f5',
-     padding: 20,
-   },
- });

Best Practices

1. Use Semantic Color Names

// ✅ Good - semantic colors
<View className="bg-blue-500">

// ❌ Avoid - hex colors
<View style={{ backgroundColor: '#3b82f6' }}>

2. Prefer Utility Classes Over Custom Styles

// ✅ Good - utility classes
<View className="mt-4 px-6 py-3 bg-white rounded-lg shadow-md">

// ❌ Avoid - mixing with inline styles
<View className="mt-4" style={{ padding: 24, backgroundColor: 'white' }}>

3. Use Consistent Spacing

Follow the spacing scale (4px base unit):

  • p-1 = 4px
  • p-2 = 8px
  • p-3 = 12px
  • p-4 = 16px
  • p-5 = 20px
  • p-6 = 24px

4. Extract Reusable Components

For complex styling patterns, create reusable components:

// components/Button.tsx
export const Button: React.FC<ButtonProps> = ({ variant = 'primary', children, ...props }) => {
  const variantClasses = {
    primary: 'bg-blue-500 text-white',
    secondary: 'bg-gray-200 text-gray-800',
    danger: 'bg-red-600 text-white',
  };

  return (
    <TouchableOpacity className={`px-6 py-3 rounded-lg ${variantClasses[variant]}`} {...props}>
      <Text className="font-semibold text-center">{children}</Text>
    </TouchableOpacity>
  );
};

5. Use TypeScript for Type Safety

Ensure you have the NativeWind type definitions:

/// <reference types="nativewind/types" />

This enables IntelliSense for className props.

Troubleshooting

Styles Not Applying

  1. Check Babel Configuration: Ensure nativewind/babel is in your babel plugins
  2. Restart Metro Bundler: Clear cache with yarn start --clear
  3. Check Import: Make sure global.css is imported in App.tsx

TypeScript Errors

If you see TypeScript errors about className prop:

  1. Ensure nativewind-env.d.ts exists in the frontend directory
  2. Restart your TypeScript server in VS Code

Hot Reload Issues

After making Tailwind config changes, restart the dev server:

yarn start --clear

Platform-Specific Styling

For platform-specific adjustments, use Platform.select() with inline styles:

<View
  className="p-4 bg-white rounded-lg"
  style={Platform.select({
    web: { boxShadow: '0 2px 8px rgba(0,0,0,0.1)' },
    default: {},
  })}
>

Admin List Screen Patterns

All admin list screens (Platform Admin, Org Admin, etc.) follow a standard set of patterns using shared components. Use these patterns when building any new admin section.

Stats Grid

Stats are displayed in a 2-per-row grid using StatCard:

import { StatCard } from '../components';

<View className="flex-row flex-wrap gap-3 mb-6">
  <StatCard
    label={t('admin.totalUsers')}
    value={stats.total}
    icon={<Users size={20} color="#3B82F6" />}
  />
  <StatCard
    label={t('admin.active')}
    value={stats.active}
    icon={<UserCheck size={20} color="#10B981" />}
  />
  <StatCard
    label={t('admin.inactive')}
    value={stats.inactive}
    icon={<UserX size={20} color="#EF4444" />}
  />
</View>;

StatCard uses w-[48%] so two cards always fit side-by-side on mobile. Avoid flex-1 min-w-[200px] — that forces one card per row on narrow screens.

Screen Header

Use ScreenHeader for the page title, subtitle, and optional primary CTA. It stacks vertically so the button never clips over the title on mobile:

import { ScreenHeader } from '../components';

// Without action button
<ScreenHeader title={t('admin.users')} subtitle={t('admin.viewAndManageUsers')} />

// With action button
<ScreenHeader
  title={t('admin.users')}
  subtitle={t('admin.viewAndManageUsers')}
  action={{
    label: t('admin.createUser'),
    icon: <Plus size={18} color="white" />,
    onPress: handleCreateUser,
  }}
/>

Never use flex-row justify-between for a title + button header on mobile — the button label wraps or clips when both are too wide.

Search & Filters

Use TableFilters for the search input and filter pills:

import { TableFilters } from '../components';

<TableFilters
  searchValue={searchQuery}
  onSearchChange={setSearchQuery}
  searchPlaceholder={t('admin.searchUsers')}
  filterGroups={[
    {
      value: statusFilter,
      onChange: (v) => {
        setStatusFilter(v);
        setPage(1);
      },
      allLabel: t('admin.allStatuses'),
      options: [
        { value: 'active', label: t('common.active') },
        { value: 'inactive', label: t('common.inactive') },
      ],
    },
  ]}
/>;

Always call setPage(1) inside onChange to reset to the first page when a filter changes.

Card-Row List Layout

On mobile, tables with fixed-width columns overflow the screen. Use a card-row pattern instead — each row is a horizontal flex container with an info block that takes all available space and a pinned actions menu:

import { RowActionsMenu } from '../components';

<View className="bg-white rounded-lg mb-6 border border-gray-200">
  {items.map((item) => (
    <View key={item.id} className="flex-row border-b border-gray-100 py-3 px-3 items-center">
      {/* Info block — takes all remaining space */}
      <View className="flex-1 mr-2">
        <Text className="text-sm font-semibold text-gray-800" numberOfLines={1}>
          {item.name}
        </Text>
        <Text className="text-xs text-gray-500 mt-0.5" numberOfLines={1}>
          {item.email}
        </Text>
        {/* Optional inline badges */}
        <View className="flex-row flex-wrap gap-1 mt-1">
          <View className="bg-green-100 px-2 py-0.5 rounded-full">
            <Text className="text-xs text-green-800 font-medium capitalize">{item.status}</Text>
          </View>
        </View>
      </View>

      {/* Actions — always visible, pinned to the right */}
      <RowActionsMenu
        testID={`item-menu-trigger-${item.id}`}
        items={[
          {
            label: t('common.edit'),
            icon: <Edit size={16} color="#3B82F6" />,
            onPress: () => handleEdit(item),
            variant: 'primary',
          },
          {
            label: t('common.delete'),
            icon: <Trash2 size={16} color="#EF4444" />,
            onPress: () => handleDelete(item.id),
            variant: 'danger',
            separator: true,
          },
        ]}
      />
    </View>
  ))}
</View>;

Key rules:

  • Never use min-w-[*] columns inside a flex-row — total column widths exceed the screen width on mobile.
  • Use flex-1 mr-2 on the info block so it shrinks gracefully when the RowActionsMenu is present.
  • Use numberOfLines={1} on text that could overflow to prevent line-wrapping that pushes elements around.
  • RowActionsMenu must always be visible — do not wrap the list in a horizontal ScrollView as this hides the actions menu off-screen.

Pagination

Use TablePagination at the bottom of every paginated list:

import { TablePagination } from '../components';

<TablePagination
  page={page}
  totalPages={totalPages}
  totalCount={totalCount}
  onPrev={() => setPage(Math.max(1, page - 1))}
  onNext={() => setPage(Math.min(totalPages, page + 1))}
/>;

TablePagination renders nothing when totalPages <= 1, so no conditional rendering needed.


Reference

Examples in the Codebase

See these files for real-world examples: