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
- NativeWind Setup
- Using NativeWind Classes
- Common Patterns
- Migration from StyleSheet
- Best Practices
- Troubleshooting
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.jsdefines base font families (FONT_FAMILY) and per-platform semantic roles (FONT_ROLE).frontend/src/theme/fonts.tsexposes typed tokens for app code and the font loading map used byuseFonts.
Semantic Roles¶
FONT_ROLE.web.header: web heading/navigation fontFONT_ROLE.web.content: web body/content fontFONT_ROLE.native.header: iOS/Android heading/navigation fontFONT_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.csssetsbodyto content font andh1..h6to header font.
NativeWind Setup¶
Installation¶
The project already has NativeWind configured. The setup includes:
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. App.tsx¶
Import the global CSS file:
5. nativewind-env.d.ts¶
Type definitions for NativeWind:
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¶
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= 4pxp-2= 8pxp-3= 12pxp-4= 16pxp-5= 20pxp-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:
This enables IntelliSense for className props.
Troubleshooting¶
Styles Not Applying¶
- Check Babel Configuration: Ensure
nativewind/babelis in your babel plugins - Restart Metro Bundler: Clear cache with
yarn start --clear - Check Import: Make sure
global.cssis imported inApp.tsx
TypeScript Errors¶
If you see TypeScript errors about className prop:
- Ensure
nativewind-env.d.tsexists in the frontend directory - Restart your TypeScript server in VS Code
Hot Reload Issues¶
After making Tailwind config changes, restart the dev server:
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 aflex-row— total column widths exceed the screen width on mobile. - Use
flex-1 mr-2on the info block so it shrinks gracefully when theRowActionsMenuis present. - Use
numberOfLines={1}on text that could overflow to prevent line-wrapping that pushes elements around. RowActionsMenumust always be visible — do not wrap the list in a horizontalScrollViewas 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:
- LoginScreen.tsx - Form inputs and buttons
- PlatformAdminOrganizationsScreen.tsx - Full admin list with stats, header, card-row layout
- PlatformAdminUsersScreen.tsx - Admin list with role/status badges
- OrgAdminUsersScreen.tsx - Org-scoped admin list
- screens/index.tsx - AdminUsersScreen, dashboard screens