FormCheck App: Foundation + UI/UX

Build a polished React Native gym app shell with navigation and animated mascot.

Introduction

30 Second Summary

Ever been at the gym wondering if your squat is deep enough or your back is straight during deadlifts? A personal form coach would be amazing, but most of us rely on awkward mirror checks or hoping a stranger will speak up.

In this project, you will build the UI foundation of FormCheck, a mobile app that uses AI to coach your gym form. You will create the navigation, design system, animated mascot, and reusable components that make the app feel polished and professional.

What You'll Build

A production-quality mobile app shell built with React Native, featuring tab navigation, dark-mode styling, animated components, and a reusable design system.

By the end of this project, you'll have:

  • A navigable mobile app running on your iPhone with four tabs (Home, Record, History, Profile) powered by Expo Router file-based navigation.
  • A reusable component library (buttons, cards, score rings, feedback cards) styled with NativeWind utility classes and a custom dark-mode color palette.
  • An animated Chip mascot component that bounces in with spring physics and delivers coaching quips in speech bubbles using Reanimated.
  • Secret Mission: Add haptic feedback and micro-interactions to every interactive element for a premium tactile feel.

Are there any prerequisites?

This project assumes you have JavaScript/TypeScript experience, Node.js installed, VS Code with the Claude Code extension configured, and a physical iPhone with Expo Go installed.

This is Project 1 of 4 in the FormCheck series. Each project builds on top of the previous one, adding camera-based pose detection, a database, and deployment pipelines.

Set Up the Project with Claude Code

Every polished app starts with solid foundations. Before writing any feature code, you need a properly configured project with the right tools and conventions in place.

In this step, you'll scaffold a React Native project using Expo and NativeWind, establish coding standards for Claude Code, and define the dark-mode design system that gives FormCheck its gym aesthetic.

In this step, get ready to:
  • Scaffold an Expo + NativeWind project and run it on your iPhone.
  • Create a CLAUDE.md file with project coding standards.
  • Configure the FormCheck design system with custom colors and tokens.
Scaffold the Expo project

The rn-new CLI scaffolds a complete Expo project with NativeWind pre-configured. This saves you from manually wiring up Metro, Babel, and Tailwind configs.

  • In VS Code, click Terminal in the top menu bar.
  • Click New Terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the FormCheck project by running:
npx rn-new@latest --nativewind

What will this command do?

This launches an interactive CLI that scaffolds a new Expo SDK 54 + NativeWind v4.1 + TypeScript project.

The CLI will prompt you for a project name. Enter formcheck when asked. It auto-configures Metro, Babel, Tailwind, and installs all dependencies for you.

  • When prompted for a project name, enter formcheck.
  • Wait for the CLI to finish installing dependencies. This may take a minute.
  • Move into the new project folder by running:
cd formcheck
  • Start the Expo development server by running:
npx expo start

A QR code appears in your terminal. This is how you connect your iPhone to the dev server.

  • On your iPhone, open the default Camera app.
  • Point it at the QR code in your terminal.
  • Tap the notification banner that appears to open the app in Expo Go.

You should see the default Expo starter screen load on your iPhone.

App not loading on your iPhone?

  • Make sure your iPhone and your computer are on the same Wi-Fi network.
  • If you're on a public or corporate network, try running npx expo start --tunnel instead.
  • Confirm Expo Go is installed from the App Store on your iPhone.

Help me get Expo Go connected to my dev server

  • Press Ctrl+C in the terminal to stop the dev server for now.
  • Open the formcheck folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Select the formcheck folder on your Desktop, then click Open.
Create the CLAUDE.md file

Claude Code reads a CLAUDE.md file at the start of every session. This is where you define your project's coding conventions so Claude Code follows them consistently.

  • In the VS Code Explorer sidebar, right-click in the empty space below the file list.
  • Click New File.
  • Name the file CLAUDE.md and press Enter.
  • Paste the following content into CLAUDE.md:
# FormCheck Project Standards

## Styling
- Use NativeWind (Tailwind CSS) for all styling
- No inline StyleSheet unless animating with Reanimated
- Custom colors defined in tailwind.config.js

## Navigation
- Use Expo Router for all navigation
- File-based routing in src/app/

## TypeScript
- Strict mode enabled
- All components fully typed

## File Structure
- Components: src/components/
- Screens: src/app/
- Constants: src/constants/

What does CLAUDE.md do?

Claude Code loads this file automatically at the start of every conversation. It acts as persistent context so Claude always knows your project's rules.

By specifying NativeWind for styling and Expo Router for navigation, Claude Code will generate code that matches your conventions instead of defaulting to generic patterns.

  • Save CLAUDE.md.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

# FormCheck Project Standards

## Styling
- Use NativeWind (Tailwind CSS) for all styling
- No inline StyleSheet unless animating with Reanimated
- Custom colors defined in tailwind.config.js

## Navigation
- Use Expo Router for all navigation
- File-based routing in src/app/

## TypeScript
- Strict mode enabled
- All components fully typed

## File Structure
- Components: src/components/
- Screens: src/app/
- Constants: src/constants/
Configure the design system

Design tokens are the single source of truth for your app's visual language. By defining colors, typography, and spacing in one file, every component stays consistent.

  • In the VS Code terminal, create the constants directory by running:
mkdir -p src/constants
  • In the Explorer sidebar, expand the src/constants folder.
  • Right-click on constants and click New File.
  • Name the file theme.ts and press Enter.
  • Paste the following color palette into theme.ts:
export const colors = {
  background: {
    primary: '#0A0A0A',
    secondary: '#141414',
    card: '#1C1C1E',
    elevated: '#2C2C2E',
  },
  accent: {
    green: '#30D158',
    blue: '#0A84FF',
    greenDim: '#1A7A35',
  },
  score: {
    good: '#30D158',
    warning: '#FFD60A',
    danger: '#FF453A',
  },
  text: {
    primary: '#FFFFFF',
    secondary: '#EBEBF5',
    tertiary: '#8E8E93',
  },
};

export const typography = {
  xs: 12,
  sm: 14,
  base: 16,
  lg: 18,
  xl: 20,
  '2xl': 24,
  '3xl': 30,
  '4xl': 36,
};

export const spacing = {
  xs: 4,
  sm: 8,
  md: 16,
  lg: 24,
  xl: 32,
  '2xl': 48,
};

What do these design tokens do?

  • The colors object defines dark backgrounds for a gym aesthetic, accent greens and blues for scores, and warning/danger colors for form feedback.
  • The typography object maps size names to pixel values, keeping font sizes consistent across every screen.
  • The spacing object provides a scale from 4px to 48px so padding and margins always feel proportional.
  • Save theme.ts.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

export const colors = {
  background: {
    primary: '#0A0A0A',
    secondary: '#141414',
    card: '#1C1C1E',
    elevated: '#2C2C2E',
  },
  accent: {
    green: '#30D158',
    blue: '#0A84FF',
    greenDim: '#1A7A35',
  },
  score: {
    good: '#30D158',
    warning: '#FFD60A',
    danger: '#FF453A',
  },
  text: {
    primary: '#FFFFFF',
    secondary: '#EBEBF5',
    tertiary: '#8E8E93',
  },
};

export const typography = {
  xs: 12,
  sm: 14,
  base: 16,
  lg: 18,
  xl: 20,
  '2xl': 24,
  '3xl': 30,
  '4xl': 36,
};

export const spacing = {
  xs: 4,
  sm: 8,
  md: 16,
  lg: 24,
  xl: 32,
  '2xl': 48,
};

Now you need to make these colors available to NativeWind's Tailwind classes. The scaffold created a tailwind.config.js in your project root. You'll extend it with the FormCheck palette.

  • In the Explorer sidebar, click tailwind.config.js in the project root to open it.
  • Replace the entire file content with:
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ['./src/**/*.{js,jsx,ts,tsx}'],
  presets: [require('nativewind/preset')],
  theme: {
    extend: {
      colors: {
        background: {
          primary: '#0A0A0A',
          secondary: '#141414',
          card: '#1C1C1E',
          elevated: '#2C2C2E',
        },
        accent: {
          green: '#30D158',
          blue: '#0A84FF',
        },
        score: {
          good: '#30D158',
          warning: '#FFD60A',
          danger: '#FF453A',
        },
      },
    },
  },
  plugins: [],
};

What does this config do?

The content array tells Tailwind to scan all files in src/ for class names. The presets key loads NativeWind's bridge between Tailwind CSS and React Native's style system.

The extend.colors block registers your custom palette. Now you can use classes like bg-background-primary or text-accent-green anywhere in your components.

  • Save tailwind.config.js.

Clear the cache after config changes

If styles don't apply after updating tailwind.config.js, stop the dev server and restart with npx expo start -c to clear the Metro cache.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ['./src/**/*.{js,jsx,ts,tsx}'],
  presets: [require('nativewind/preset')],
  theme: {
    extend: {
      colors: {
        background: {
          primary: '#0A0A0A',
          secondary: '#141414',
          card: '#1C1C1E',
          elevated: '#2C2C2E',
        },
        accent: {
          green: '#30D158',
          blue: '#0A84FF',
        },
        score: {
          good: '#30D158',
          warning: '#FFD60A',
          danger: '#FF453A',
        },
      },
    },
  },
  plugins: [],
};

Your project is scaffolded, your AI assistant knows the rules, and your design system is locked in. Next up, you'll build the tab navigation that ties all of FormCheck's screens together.

Build the Navigation Architecture

Your project is scaffolded, your design system is configured, and FormCheck runs on your iPhone. Now you need a way for users to move between screens.

Every mobile app needs navigation. In this step, you will build a tab-based navigation structure using Expo Router's file-based routing. Each file you create inside the (tabs) directory automatically becomes a screen in your tab bar.

In this step, get ready to:
  • Create the tab navigation file structure with placeholder screens.
  • Configure the tab layout with dark styling and Ionicons.
  • Build the Home screen with a greeting, Chip mascot area, and a call-to-action button.
Create the tab navigation files

Expo Router uses a special directory naming convention. A directory wrapped in parentheses groups routes without affecting the URL. The (tabs) directory tells Expo Router that all files inside it should appear as tabs in a bottom tab bar.

  • Open a new terminal in VS Code by pressing Ctrl+` (macOS and Windows).
  • Create the (tabs) directory inside src/app/ by running this command:
mkdir -p "src/app/(tabs)"

What is the (tabs) directory?

In Expo Router, parentheses around a directory name create a "route group." The group organizes files into a shared navigation layout without adding a segment to the URL. Files inside src/app/(tabs)/ will all share the same bottom tab navigator.

Now you need to create the screen files for each tab. You will build the Home screen content later in this step. For now, the Record, History, and Profile screens get simple placeholder content.

  • Create src/app/(tabs)/record.tsx with the following code:
import { View, Text } from 'react-native';

export default function RecordScreen() {
  return (
    <View className="flex-1 bg-background-primary items-center justify-center">
      <Text className="text-white text-xl">Record</Text>
    </View>
  );
}
  • Create src/app/(tabs)/history.tsx with the following code:
import { View, Text } from 'react-native';

export default function HistoryScreen() {
  return (
    <View className="flex-1 bg-background-primary items-center justify-center">
      <Text className="text-white text-xl">History</Text>
    </View>
  );
}
  • Create src/app/(tabs)/profile.tsx with the following code:
import { View, Text } from 'react-native';

export default function ProfileScreen() {
  return (
    <View className="flex-1 bg-background-primary items-center justify-center">
      <Text className="text-white text-xl">Profile</Text>
    </View>
  );
}

What do these placeholder screens do?

Each placeholder screen is a minimal React Native component that renders centered text on a dark background. The className prop works because NativeWind translates Tailwind utility classes into React Native styles.

The bg-background-primary class maps to the #0A0A0A color you defined in tailwind.config.js in Step 1.

  • Confirm the files exist by running:
ls src/app/\(tabs\)/

You should see record.tsx, history.tsx, and profile.tsx listed in the output.

Don't see the files?

Make sure you created the directory with quotes around the path: mkdir -p "src/app/(tabs)". Parentheses are special characters in the shell and need to be quoted or escaped.

help me create the (tabs) directory for Expo Router

Configure the tab layout with dark styling

The _layout.tsx file inside a directory tells Expo Router how to arrange the screens in that directory. For tabs, it defines which screens appear in the tab bar, their icons, and the visual styling.

  • Create src/app/(tabs)/_layout.tsx with the following code:
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';

export default function TabLayout() {
  return (
    <Tabs
      screenOptions={{
        headerShown: false,
        tabBarStyle: {
          backgroundColor: 'rgba(20,20,20,0.95)',
          borderTopColor: '#2C2C2E',
        },
        tabBarActiveTintColor: '#30D158',
        tabBarInactiveTintColor: '#8E8E93',
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: 'Home',
          tabBarIcon: ({ color, size }) => <Ionicons name="home" size={size} color={color} />,
        }}
      />
      <Tabs.Screen
        name="record"
        options={{
          title: 'Record',
          tabBarIcon: ({ color, size }) => <Ionicons name="videocam" size={size} color={color} />,
        }}
      />
      <Tabs.Screen
        name="history"
        options={{
          title: 'History',
          tabBarIcon: ({ color, size }) => <Ionicons name="time" size={size} color={color} />,
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: 'Profile',
          tabBarIcon: ({ color, size }) => <Ionicons name="person" size={size} color={color} />,
        }}
      />
    </Tabs>
  );
}

What does this layout do?

The Tabs component from expo-router creates a bottom tab navigator. Each Tabs.Screen entry maps to a file in the same directory. The name prop matches the filename without the extension.

The screenOptions object controls the look and feel of the entire tab bar. headerShown: false removes the navigation header for a full-bleed design. The tabBarStyle gives the bar a dark, semi-transparent background that matches the FormCheck aesthetic.

The tabBarActiveTintColor uses the accent green (#30D158) from your design system. The inactive tint uses the tertiary text color (#8E8E93) for a subtle, muted appearance on unselected tabs.

You also need a placeholder index.tsx for the Home tab so the app can render. You will build the full Home screen in the next substep.

  • Create a temporary src/app/(tabs)/index.tsx file with this minimal content:
import { View, Text } from 'react-native';

export default function HomeScreen() {
  return (
    <View className="flex-1 bg-background-primary items-center justify-center">
      <Text className="text-white text-xl">Home</Text>
    </View>
  );
}
  • Save all files.
  • Check your iPhone. The app should reload and show a dark tab bar at the bottom with four tabs: Home, Record, History, and Profile. Each tab has an icon and tapping between them switches the screen.

Tabs not showing up?

If the app shows an error or the tabs don't appear, try clearing the Expo cache by stopping the server and running:

npx expo start -c

This clears the Metro bundler cache. Also verify that your (tabs) directory is inside src/app/ and that the filenames match exactly (including _layout.tsx with the underscore prefix).

help me fix my Expo Router tab navigation

Build the Home screen layout

The Home screen is the first thing users see when they open FormCheck. It needs to feel welcoming and immediately communicate the app's purpose. You will build a scrollable layout with a greeting header, a placeholder for the Chip mascot, a last-workout summary card, and a prominent button to start a form check.

  • Replace the entire contents of src/app/(tabs)/index.tsx with the following code:
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
import { useRouter } from 'expo-router';

export default function HomeScreen() {
  const router = useRouter();
  return (
    <ScrollView className="flex-1 bg-background-primary">
      <View className="px-6 pt-16 pb-4">
        <Text className="text-text-tertiary text-sm">Good morning,</Text>
        <Text className="text-white text-3xl font-bold">Ready to lift? 💪</Text>
      </View>
      {/* Chip mascot area placeholder */}
      <View className="mx-6 my-4 bg-background-card rounded-2xl p-4 items-center">
        <Text className="text-4xl">🤖</Text>
        <Text className="text-white mt-2">Chip will appear here</Text>
      </View>
      {/* Last workout summary card */}
      <View className="mx-6 my-2 bg-background-card rounded-2xl p-4">
        <Text className="text-text-tertiary text-xs uppercase tracking-wider">Last Workout</Text>
        <Text className="text-white text-lg font-semibold mt-1">Back Squat</Text>
        <Text className="text-text-secondary text-sm">Score: 78 · Yesterday</Text>
      </View>
      {/* CTA Button */}
      <TouchableOpacity
        className="mx-6 mt-6 bg-accent-green rounded-2xl py-4 items-center"
        onPress={() => router.push('/(tabs)/record')}
      >
        <Text className="text-black font-bold text-lg">Start Form Check</Text>
      </TouchableOpacity>
    </ScrollView>
  );
}

What does the Home screen code do?

The ScrollView wrapper allows the content to scroll if it extends beyond the screen. The greeting section uses pt-16 for padding that clears the iPhone status bar.

The Chip mascot area is a placeholder card. You will replace this with an animated component in a later step. The last-workout card shows mock data in the style of the final app.

The TouchableOpacity CTA button uses useRouter() from expo-router to navigate to the Record tab when pressed. The bg-accent-green class applies the #30D158 green from your design system.

  • Save src/app/(tabs)/index.tsx.
  • Check your iPhone. The Home tab should now display a greeting ("Good morning," with "Ready to lift?" in large bold text), a robot emoji placeholder for Chip, a "Last Workout" card showing "Back Squat," and a bright green "Start Form Check" button.
  • Tap the Start Form Check button. It should switch you to the Record tab.

Styles not applying or screen is blank?

If you see unstyled white text or a blank screen, make sure your tailwind.config.js content paths include './src/**/*.{js,jsx,ts,tsx}'. NativeWind needs to know which files contain Tailwind classes.

If tapping the button does nothing, check that the useRouter import is from expo-router and that the path in router.push matches '/(tabs)/record' exactly.

help me fix my FormCheck Home screen styling

✔️ Awesome, I've got everything!

Great. Double check you have saved all files in the src/app/(tabs)/ directory.

ⓧ I'd like to double check the full code

Here is the complete code for each file in the src/app/(tabs)/ directory.

import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';

export default function TabLayout() {
  return (
    <Tabs
      screenOptions={{
        headerShown: false,
        tabBarStyle: {
          backgroundColor: 'rgba(20,20,20,0.95)',
          borderTopColor: '#2C2C2E',
        },
        tabBarActiveTintColor: '#30D158',
        tabBarInactiveTintColor: '#8E8E93',
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: 'Home',
          tabBarIcon: ({ color, size }) => <Ionicons name="home" size={size} color={color} />,
        }}
      />
      <Tabs.Screen
        name="record"
        options={{
          title: 'Record',
          tabBarIcon: ({ color, size }) => <Ionicons name="videocam" size={size} color={color} />,
        }}
      />
      <Tabs.Screen
        name="history"
        options={{
          title: 'History',
          tabBarIcon: ({ color, size }) => <Ionicons name="time" size={size} color={color} />,
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: 'Profile',
          tabBarIcon: ({ color, size }) => <Ionicons name="person" size={size} color={color} />,
        }}
      />
    </Tabs>
  );
}
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
import { useRouter } from 'expo-router';

export default function HomeScreen() {
  const router = useRouter();
  return (
    <ScrollView className="flex-1 bg-background-primary">
      <View className="px-6 pt-16 pb-4">
        <Text className="text-text-tertiary text-sm">Good morning,</Text>
        <Text className="text-white text-3xl font-bold">Ready to lift? 💪</Text>
      </View>
      {/* Chip mascot area placeholder */}
      <View className="mx-6 my-4 bg-background-card rounded-2xl p-4 items-center">
        <Text className="text-4xl">🤖</Text>
        <Text className="text-white mt-2">Chip will appear here</Text>
      </View>
      {/* Last workout summary card */}
      <View className="mx-6 my-2 bg-background-card rounded-2xl p-4">
        <Text className="text-text-tertiary text-xs uppercase tracking-wider">Last Workout</Text>
        <Text className="text-white text-lg font-semibold mt-1">Back Squat</Text>
        <Text className="text-text-secondary text-sm">Score: 78 · Yesterday</Text>
      </View>
      {/* CTA Button */}
      <TouchableOpacity
        className="mx-6 mt-6 bg-accent-green rounded-2xl py-4 items-center"
        onPress={() => router.push('/(tabs)/record')}
      >
        <Text className="text-black font-bold text-lg">Start Form Check</Text>
      </TouchableOpacity>
    </ScrollView>
  );
}
import { View, Text } from 'react-native';

export default function RecordScreen() {
  return (
    <View className="flex-1 bg-background-primary items-center justify-center">
      <Text className="text-white text-xl">Record</Text>
    </View>
  );
}
import { View, Text } from 'react-native';

export default function HistoryScreen() {
  return (
    <View className="flex-1 bg-background-primary items-center justify-center">
      <Text className="text-white text-xl">History</Text>
    </View>
  );
}
import { View, Text } from 'react-native';

export default function ProfileScreen() {
  return (
    <View className="flex-1 bg-background-primary items-center justify-center">
      <Text className="text-white text-xl">Profile</Text>
    </View>
  );
}

Your FormCheck app now has a fully working navigation system. Users can tap between all four tabs, and the Home screen already looks like a real fitness app. Next up, you will build the reusable design system components that give the app its polished, professional feel.

Build the Design System Components

Your navigation is working and the tab bar looks sharp. Now the app needs a visual vocabulary.

Reusable components let you define a look once and use it everywhere. In this step, you'll build buttons, cards, score indicators, and feedback displays that give FormCheck its polished gym aesthetic. You'll also complete the three remaining screen layouts so every tab has real content.

In this step, get ready to:
  • Build base components (Button, Card, ScoreRing).
  • Build form-feedback components (FeedbackCard, StatCard, ChipBubble).
  • Complete the screen layouts for History, Profile, and Record.
Build the base components

Base components are the building blocks that every screen shares. A consistent Button, Card, and ScoreRing mean you never have to restyle the same pattern twice.

The ScoreRing component needs SVG rendering to draw circular progress arcs. You'll install that dependency first.

  • Install the SVG library by running this command in your terminal:
npx expo install react-native-svg

Why npx expo install instead of npm install?

The npx expo install command picks the version of the package that is compatible with your current Expo SDK version. Running npm install directly can pull an incompatible version that causes build failures.

  • Create the file src/components/Button.tsx and add the following code:
import { TouchableOpacity, Text } from 'react-native';

type ButtonVariant = 'primary' | 'secondary' | 'ghost';

interface ButtonProps {
  label: string;
  onPress: () => void;
  variant?: ButtonVariant;
  disabled?: boolean;
}

export function Button({ label, onPress, variant = 'primary', disabled }: ButtonProps) {
  const variantClasses = {
    primary: 'bg-accent-green',
    secondary: 'bg-background-elevated border border-accent-green',
    ghost: 'bg-transparent',
  };
  const textClasses = {
    primary: 'text-black font-bold',
    secondary: 'text-accent-green font-semibold',
    ghost: 'text-accent-green font-semibold',
  };
  return (
    <TouchableOpacity
      onPress={onPress}
      disabled={disabled}
      className={`rounded-2xl py-4 px-6 items-center ${variantClasses[variant]} ${disabled ? 'opacity-50' : ''}`}
    >
      <Text className={`text-base ${textClasses[variant]}`}>{label}</Text>
    </TouchableOpacity>
  );
}

What does this code do?

The Button component accepts a variant prop that switches between three visual styles. Primary uses the green accent fill, secondary has a green border on a dark surface, and ghost is transparent with green text.

The disabled prop drops the opacity to 50% and prevents press events. NativeWind class maps keep the styling logic readable without a separate StyleSheet.

  • Save Button.tsx.
  • Create the file src/components/Card.tsx and add the following code:
import { View } from 'react-native';
import type { ReactNode } from 'react';

interface CardProps {
  children: ReactNode;
  className?: string;
}

export function Card({ children, className = '' }: CardProps) {
  return (
    <View className={`bg-background-card rounded-2xl p-4 ${className}`}>
      {children}
    </View>
  );
}

What does this code do?

Card is a simple wrapper component. It provides the dark card surface with rounded corners and padding. The optional className prop lets you add extra utility classes when you need to customize spacing or width on individual cards.

  • Save Card.tsx.
  • Create the file src/components/ScoreRing.tsx and add the following code:
import { View, Text } from 'react-native';
import Svg, { Circle } from 'react-native-svg';

interface ScoreRingProps {
  score: number;
  size?: number;
}

export function ScoreRing({ score, size = 80 }: ScoreRingProps) {
  const strokeWidth = 6;
  const radius = (size - strokeWidth) / 2;
  const circumference = 2 * Math.PI * radius;
  const progress = (score / 100) * circumference;
  const color = score >= 80 ? '#30D158' : score >= 50 ? '#FFD60A' : '#FF453A';
  return (
    <View className="items-center justify-center">
      <Svg width={size} height={size}>
        <Circle cx={size/2} cy={size/2} r={radius} stroke="#2C2C2E" strokeWidth={strokeWidth} fill="none" />
        <Circle cx={size/2} cy={size/2} r={radius} stroke={color} strokeWidth={strokeWidth} fill="none"
          strokeDasharray={`${progress} ${circumference}`}
          strokeLinecap="round"
          transform={`rotate(-90 ${size/2} ${size/2})`}
        />
      </Svg>
      <View className="absolute items-center">
        <Text style={{ color, fontSize: size * 0.25, fontWeight: 'bold' }}>{score}</Text>
      </View>
    </View>
  );
}

What does this code do?

ScoreRing draws two SVG circles. The first is a grey background track. The second is the colored progress arc whose length is calculated from the score prop using strokeDasharray.

The color automatically switches based on the score value: green for 80+, yellow for 50-79, and red below 50. The transform rotation starts the arc from the top (12 o'clock position) instead of the default 3 o'clock.

  • Save ScoreRing.tsx.

Seeing an import error for react-native-svg?

Make sure you ran npx expo install react-native-svg earlier in this step. If you added it with npm install instead, delete node_modules and reinstall with npm install, then restart the dev server with npx expo start -c to clear the cache.

help me fix my react-native-svg import error in ScoreRing.tsx

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files in src/components/.

ⓧ I'd like to double check the full code

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

type ButtonVariant = 'primary' | 'secondary' | 'ghost';

interface ButtonProps {
  label: string;
  onPress: () => void;
  variant?: ButtonVariant;
  disabled?: boolean;
}

export function Button({ label, onPress, variant = 'primary', disabled }: ButtonProps) {
  const variantClasses = {
    primary: 'bg-accent-green',
    secondary: 'bg-background-elevated border border-accent-green',
    ghost: 'bg-transparent',
  };
  const textClasses = {
    primary: 'text-black font-bold',
    secondary: 'text-accent-green font-semibold',
    ghost: 'text-accent-green font-semibold',
  };
  return (
    <TouchableOpacity
      onPress={onPress}
      disabled={disabled}
      className={`rounded-2xl py-4 px-6 items-center ${variantClasses[variant]} ${disabled ? 'opacity-50' : ''}`}
    >
      <Text className={`text-base ${textClasses[variant]}`}>{label}</Text>
    </TouchableOpacity>
  );
}
import { View } from 'react-native';
import type { ReactNode } from 'react';

interface CardProps {
  children: ReactNode;
  className?: string;
}

export function Card({ children, className = '' }: CardProps) {
  return (
    <View className={`bg-background-card rounded-2xl p-4 ${className}`}>
      {children}
    </View>
  );
}
import { View, Text } from 'react-native';
import Svg, { Circle } from 'react-native-svg';

interface ScoreRingProps {
  score: number;
  size?: number;
}

export function ScoreRing({ score, size = 80 }: ScoreRingProps) {
  const strokeWidth = 6;
  const radius = (size - strokeWidth) / 2;
  const circumference = 2 * Math.PI * radius;
  const progress = (score / 100) * circumference;
  const color = score >= 80 ? '#30D158' : score >= 50 ? '#FFD60A' : '#FF453A';
  return (
    <View className="items-center justify-center">
      <Svg width={size} height={size}>
        <Circle cx={size/2} cy={size/2} r={radius} stroke="#2C2C2E" strokeWidth={strokeWidth} fill="none" />
        <Circle cx={size/2} cy={size/2} r={radius} stroke={color} strokeWidth={strokeWidth} fill="none"
          strokeDasharray={`${progress} ${circumference}`}
          strokeLinecap="round"
          transform={`rotate(-90 ${size/2} ${size/2})`}
        />
      </Svg>
      <View className="absolute items-center">
        <Text style={{ color, fontSize: size * 0.25, fontWeight: 'bold' }}>{score}</Text>
      </View>
    </View>
  );
}
Build the form-feedback components

Form feedback is the core of what makes FormCheck useful. These components display the AI coaching results: what went well, what needs work, and how the learner is trending over time.

  • Create the file src/components/FeedbackCard.tsx and add the following code:
import { View, Text } from 'react-native';

type Severity = 'positive' | 'warning' | 'danger';

interface FeedbackCardProps {
  icon: string;
  title: string;
  description: string;
  severity: Severity;
}

export function FeedbackCard({ icon, title, description, severity }: FeedbackCardProps) {
  const severityColor = { positive: '#30D158', warning: '#FFD60A', danger: '#FF453A' }[severity];
  return (
    <View className="bg-background-card rounded-2xl p-4 mb-3 flex-row items-start">
      <View className="w-1 rounded-full mr-3 self-stretch" style={{ backgroundColor: severityColor }} />
      <View className="flex-1">
        <View className="flex-row items-center mb-1">
          <Text className="text-lg mr-2">{icon}</Text>
          <Text className="text-white font-semibold">{title}</Text>
        </View>
        <Text className="text-text-secondary text-sm">{description}</Text>
      </View>
    </View>
  );
}

What does this code do?

FeedbackCard displays a single piece of form coaching feedback. The colored vertical bar on the left gives an instant visual signal: green for positive, yellow for warning, red for danger.

The severity prop drives the bar color. The icon, title, and description fill in the content. This component will show results like "Great depth!" (positive) or "Knees caving inward" (danger) after the AI analyzes a lift.

  • Save FeedbackCard.tsx.
  • Create the file src/components/StatCard.tsx and add the following code:
import { View, Text } from 'react-native';

interface StatCardProps {
  label: string;
  value: string;
  trend?: 'up' | 'down' | 'neutral';
}

export function StatCard({ label, value, trend = 'neutral' }: StatCardProps) {
  const trendIcon = { up: '↑', down: '↓', neutral: '→' }[trend];
  const trendColor = { up: '#30D158', down: '#FF453A', neutral: '#8E8E93' }[trend];
  return (
    <View className="bg-background-card rounded-xl p-3 items-center">
      <Text className="text-text-tertiary text-xs mb-1">{label}</Text>
      <Text className="text-white text-xl font-bold">{value}</Text>
      <Text style={{ color: trendColor }} className="text-sm">{trendIcon}</Text>
    </View>
  );
}

What does this code do?

StatCard is a compact metric tile. It shows a label (like "Workouts"), a bold value (like "12"), and a trend arrow indicating whether the metric is going up, down, or staying flat. The arrow color matches: green for up, red for down, grey for neutral.

  • Save StatCard.tsx.
  • Create the file src/components/ChipBubble.tsx and add the following code:
import { useEffect, useRef } from 'react';
import { View, Text, Animated } from 'react-native';

interface ChipBubbleProps {
  message: string;
}

export function ChipBubble({ message }: ChipBubbleProps) {
  const opacity = useRef(new Animated.Value(0)).current;
  const translateY = useRef(new Animated.Value(8)).current;

  useEffect(() => {
    Animated.parallel([
      Animated.timing(opacity, { toValue: 1, duration: 300, useNativeDriver: true }),
      Animated.timing(translateY, { toValue: 0, duration: 300, useNativeDriver: true }),
    ]).start();
  }, [message]);

  return (
    <Animated.View style={{ opacity, transform: [{ translateY }] }} className="bg-background-elevated rounded-2xl px-4 py-3 max-w-xs">
      <Text className="text-white text-sm">{message}</Text>
      {/* Bubble tail */}
      <View className="absolute -bottom-2 left-6 w-0 h-0" style={{ borderLeftWidth: 8, borderRightWidth: 8, borderTopWidth: 8, borderLeftColor: 'transparent', borderRightColor: 'transparent', borderTopColor: '#2C2C2E' }} />
    </Animated.View>
  );
}

What does this code do?

ChipBubble is a speech bubble that fades in and slides up when it appears. The useEffect triggers the animation whenever the message prop changes, so each new coaching quip gets a smooth entrance.

The triangle "tail" at the bottom is drawn using CSS border tricks. It points downward toward where the Chip mascot will sit, creating the illusion of a chat bubble.

  • Save ChipBubble.tsx.

Component not rendering when imported?

Check that you used a named export (export function ChipBubble) and a named import (import { ChipBubble } from '../components/ChipBubble') when referencing it. Mismatching default and named exports is a common source of blank renders.

help me debug why my component is not showing up after importing it

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files in src/components/.

ⓧ I'd like to double check the full code

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

type Severity = 'positive' | 'warning' | 'danger';

interface FeedbackCardProps {
  icon: string;
  title: string;
  description: string;
  severity: Severity;
}

export function FeedbackCard({ icon, title, description, severity }: FeedbackCardProps) {
  const severityColor = { positive: '#30D158', warning: '#FFD60A', danger: '#FF453A' }[severity];
  return (
    <View className="bg-background-card rounded-2xl p-4 mb-3 flex-row items-start">
      <View className="w-1 rounded-full mr-3 self-stretch" style={{ backgroundColor: severityColor }} />
      <View className="flex-1">
        <View className="flex-row items-center mb-1">
          <Text className="text-lg mr-2">{icon}</Text>
          <Text className="text-white font-semibold">{title}</Text>
        </View>
        <Text className="text-text-secondary text-sm">{description}</Text>
      </View>
    </View>
  );
}
import { View, Text } from 'react-native';

interface StatCardProps {
  label: string;
  value: string;
  trend?: 'up' | 'down' | 'neutral';
}

export function StatCard({ label, value, trend = 'neutral' }: StatCardProps) {
  const trendIcon = { up: '↑', down: '↓', neutral: '→' }[trend];
  const trendColor = { up: '#30D158', down: '#FF453A', neutral: '#8E8E93' }[trend];
  return (
    <View className="bg-background-card rounded-xl p-3 items-center">
      <Text className="text-text-tertiary text-xs mb-1">{label}</Text>
      <Text className="text-white text-xl font-bold">{value}</Text>
      <Text style={{ color: trendColor }} className="text-sm">{trendIcon}</Text>
    </View>
  );
}
import { useEffect, useRef } from 'react';
import { View, Text, Animated } from 'react-native';

interface ChipBubbleProps {
  message: string;
}

export function ChipBubble({ message }: ChipBubbleProps) {
  const opacity = useRef(new Animated.Value(0)).current;
  const translateY = useRef(new Animated.Value(8)).current;

  useEffect(() => {
    Animated.parallel([
      Animated.timing(opacity, { toValue: 1, duration: 300, useNativeDriver: true }),
      Animated.timing(translateY, { toValue: 0, duration: 300, useNativeDriver: true }),
    ]).start();
  }, [message]);

  return (
    <Animated.View style={{ opacity, transform: [{ translateY }] }} className="bg-background-elevated rounded-2xl px-4 py-3 max-w-xs">
      <Text className="text-white text-sm">{message}</Text>
      {/* Bubble tail */}
      <View className="absolute -bottom-2 left-6 w-0 h-0" style={{ borderLeftWidth: 8, borderRightWidth: 8, borderTopWidth: 8, borderLeftColor: 'transparent', borderRightColor: 'transparent', borderTopColor: '#2C2C2E' }} />
    </Animated.View>
  );
}
Build the screen layouts

The History, Profile, and Record tabs are still showing placeholder text. Each screen needs a layout that feels real, even before the backend and camera are wired up in later projects.

  • Open src/app/(tabs)/history.tsx and replace its contents with this code:
import { View, Text, ScrollView } from 'react-native';

const mockHistory = [
  { id: '1', exercise: 'Back Squat', score: 82, date: 'Today' },
  { id: '2', exercise: 'Deadlift', score: 67, date: 'Yesterday' },
  { id: '3', exercise: 'Bench Press', score: 91, date: '2 days ago' },
];

export default function HistoryScreen() {
  return (
    <ScrollView className="flex-1 bg-background-primary">
      <View className="px-6 pt-16 pb-4">
        <Text className="text-white text-3xl font-bold">History</Text>
      </View>
      {mockHistory.map((item) => (
        <View key={item.id} className="mx-6 mb-3 bg-background-card rounded-2xl p-4 flex-row items-center justify-between">
          <View>
            <Text className="text-white font-semibold">{item.exercise}</Text>
            <Text className="text-text-tertiary text-sm">{item.date}</Text>
          </View>
          <View className="items-center">
            <Text className={`text-2xl font-bold ${item.score >= 80 ? 'text-score-good' : item.score >= 50 ? 'text-score-warning' : 'text-score-danger'}`}>{item.score}</Text>
            <Text className="text-text-tertiary text-xs">score</Text>
          </View>
        </View>
      ))}
    </ScrollView>
  );
}

What does this code do?

The History screen maps over a mockHistory array to render a scrollable list of past form checks. Each row shows the exercise name, date, and a color-coded score using the same green/yellow/red logic from ScoreRing.

In a later project, this mock data will be replaced with real entries from Supabase. For now, the static data lets you verify the layout and color logic are working correctly.

  • Save history.tsx and check your iPhone.
  • Tap the History tab. You should see three workout entries with color-coded scores (green 82, yellow 67, green 91).
  • Open src/app/(tabs)/profile.tsx and replace its contents with this code:
import { View, Text, TouchableOpacity } from 'react-native';

export default function ProfileScreen() {
  return (
    <View className="flex-1 bg-background-primary">
      <View className="px-6 pt-16 pb-4">
        <Text className="text-white text-3xl font-bold">Profile</Text>
      </View>
      <View className="mx-6 bg-background-card rounded-2xl p-6 items-center mb-6">
        <View className="w-20 h-20 bg-accent-green rounded-full items-center justify-center mb-3">
          <Text className="text-black text-3xl font-bold">A</Text>
        </View>
        <Text className="text-white text-xl font-bold">Athlete</Text>
        <View className="bg-accent-blue rounded-full px-3 py-1 mt-2">
          <Text className="text-white text-xs font-semibold">PRO</Text>
        </View>
      </View>
      {['Settings', 'Notifications', 'Privacy', 'Help'].map((item) => (
        <TouchableOpacity key={item} className="mx-6 mb-2 bg-background-card rounded-xl px-4 py-4 flex-row justify-between items-center">
          <Text className="text-white">{item}</Text>
          <Text className="text-text-tertiary">›</Text>
        </TouchableOpacity>
      ))}
    </View>
  );
}

What does this code do?

The Profile screen shows a user avatar (a green circle with an initial), a display name, and a blue PRO badge. Below the profile card, a list of touchable settings rows gives the screen a complete feel.

The .map() over the settings array keeps the code DRY. Each row is a TouchableOpacity so it will be ready for navigation when settings screens are added later.

  • Save profile.tsx and tap the Profile tab on your iPhone.
  • You should see a green avatar circle with "A", the name "Athlete", a blue PRO badge, and four settings rows below.
  • Open src/app/(tabs)/record.tsx and replace its contents with this code:
import { View, Text } from 'react-native';

export default function RecordScreen() {
  return (
    <View className="flex-1 bg-black">
      {/* Camera viewfinder placeholder */}
      <View className="flex-1 items-center justify-center">
        <View className="w-72 h-96 border-2 border-white/30 rounded-3xl items-center justify-center">
          <Text className="text-white/60 text-base text-center px-8">Position yourself in frame</Text>
        </View>
      </View>
      {/* Overlay UI */}
      <View className="absolute bottom-12 left-0 right-0 items-center">
        <View className="bg-white/10 rounded-full px-6 py-3">
          <Text className="text-white text-sm">Tap to start recording</Text>
        </View>
      </View>
    </View>
  );
}

What does this code do?

The Record screen is a camera viewfinder placeholder. The black background simulates what the camera feed will look like. The rounded rectangle with the semi-transparent border represents where the user should position themselves.

The overlay UI at the bottom floats above the viewfinder using absolute positioning. In Project 2, this placeholder will be replaced with a live VisionCamera feed.

  • Save record.tsx and tap the Record tab.
  • You should see a black screen with a large rounded rectangle outline in the center and "Tap to start recording" text near the bottom.

Styles not appearing on the new screens?

NativeWind needs to see the file paths in its content array to generate styles. Open tailwind.config.js and confirm the content field includes './src/**/*.{js,jsx,ts,tsx}'. If styles still don't load, restart the dev server with npx expo start -c to clear the Metro cache.

help me fix NativeWind styles not loading on my screen components

✔️ Awesome, I've got everything!

Great. All three screens should now have polished layouts visible on your iPhone.

ⓧ I'd like to double check the full code

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

const mockHistory = [
  { id: '1', exercise: 'Back Squat', score: 82, date: 'Today' },
  { id: '2', exercise: 'Deadlift', score: 67, date: 'Yesterday' },
  { id: '3', exercise: 'Bench Press', score: 91, date: '2 days ago' },
];

export default function HistoryScreen() {
  return (
    <ScrollView className="flex-1 bg-background-primary">
      <View className="px-6 pt-16 pb-4">
        <Text className="text-white text-3xl font-bold">History</Text>
      </View>
      {mockHistory.map((item) => (
        <View key={item.id} className="mx-6 mb-3 bg-background-card rounded-2xl p-4 flex-row items-center justify-between">
          <View>
            <Text className="text-white font-semibold">{item.exercise}</Text>
            <Text className="text-text-tertiary text-sm">{item.date}</Text>
          </View>
          <View className="items-center">
            <Text className={`text-2xl font-bold ${item.score >= 80 ? 'text-score-good' : item.score >= 50 ? 'text-score-warning' : 'text-score-danger'}`}>{item.score}</Text>
            <Text className="text-text-tertiary text-xs">score</Text>
          </View>
        </View>
      ))}
    </ScrollView>
  );
}
import { View, Text, TouchableOpacity } from 'react-native';

export default function ProfileScreen() {
  return (
    <View className="flex-1 bg-background-primary">
      <View className="px-6 pt-16 pb-4">
        <Text className="text-white text-3xl font-bold">Profile</Text>
      </View>
      <View className="mx-6 bg-background-card rounded-2xl p-6 items-center mb-6">
        <View className="w-20 h-20 bg-accent-green rounded-full items-center justify-center mb-3">
          <Text className="text-black text-3xl font-bold">A</Text>
        </View>
        <Text className="text-white text-xl font-bold">Athlete</Text>
        <View className="bg-accent-blue rounded-full px-3 py-1 mt-2">
          <Text className="text-white text-xs font-semibold">PRO</Text>
        </View>
      </View>
      {['Settings', 'Notifications', 'Privacy', 'Help'].map((item) => (
        <TouchableOpacity key={item} className="mx-6 mb-2 bg-background-card rounded-xl px-4 py-4 flex-row justify-between items-center">
          <Text className="text-white">{item}</Text>
          <Text className="text-text-tertiary">›</Text>
        </TouchableOpacity>
      ))}
    </View>
  );
}
import { View, Text } from 'react-native';

export default function RecordScreen() {
  return (
    <View className="flex-1 bg-black">
      {/* Camera viewfinder placeholder */}
      <View className="flex-1 items-center justify-center">
        <View className="w-72 h-96 border-2 border-white/30 rounded-3xl items-center justify-center">
          <Text className="text-white/60 text-base text-center px-8">Position yourself in frame</Text>
        </View>
      </View>
      {/* Overlay UI */}
      <View className="absolute bottom-12 left-0 right-0 items-center">
        <View className="bg-white/10 rounded-full px-6 py-3">
          <Text className="text-white text-sm">Tap to start recording</Text>
        </View>
      </View>
    </View>
  );
}

Your component library is ready and every tab screen has a polished layout. Next up, you'll bring the app to life with an animated Chip mascot that delivers coaching messages with personality.

Animate the Chip Mascot

Your component library and screen layouts are complete. The app looks polished, but one key element is still missing: personality.

Every great fitness app has a hook that keeps you coming back. For FormCheck, that hook is Chip, your animated AI mascot who delivers coaching quips with a bouncy entrance. In this step, you will build Chip using React Native Reanimated spring animations and wire it into the Home screen with rotating motivational messages.

In this step, get ready to:
  • Create the Chip mascot component with a spring entrance animation.
  • Build the ChipCoach compound component that sequences Chip and speech bubble animations.
  • Integrate ChipCoach into the Home screen with rotating motivational quips.
Create the Chip component with a spring entrance animation

Chip is a friendly circular mascot that greets users on the Home screen. It uses Reanimated's spring physics to scale from invisible (0) to full size (1) when it first appears, giving it a bouncy, alive feeling.

  • Create a new file src/components/Chip.tsx and add this code:
import { useEffect } from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';

type Mood = 'happy' | 'thinking' | 'warning';

interface ChipProps {
  mood?: Mood;
  onPress?: () => void;
}

// Map each mood to an emoji and accent color
const MOOD_CONFIG: Record<Mood, { emoji: string; color: string }> = {
  happy: { emoji: '🤖', color: '#30D158' },
  thinking: { emoji: '🤔', color: '#0A84FF' },
  warning: { emoji: '⚠️', color: '#FFD60A' },
};

export function Chip({ mood = 'happy', onPress }: ChipProps) {
  // Start at scale 0 (invisible)
  const scale = useSharedValue(0);
  const config = MOOD_CONFIG[mood];

  // Animate to full size on mount with a bouncy spring
  useEffect(() => {
    scale.value = withSpring(1, { damping: 10, stiffness: 150 });
  }, []);

  const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));

  return (
    <TouchableOpacity onPress={onPress} activeOpacity={0.8}>
      <Animated.View style={animatedStyle} className="w-16 h-16 rounded-full items-center justify-center" style={[animatedStyle, { backgroundColor: config.color + '33' }]}>
        <Text className="text-3xl">{config.emoji}</Text>
      </Animated.View>
    </TouchableOpacity>
  );
}

What does this code do?

  • useSharedValue(0) creates an animated value starting at zero (invisible).
  • withSpring(1, { damping: 10, stiffness: 150 }) animates to full size using spring physics. Lower damping means more bounce. Higher stiffness means snappier motion.
  • The MOOD_CONFIG object maps each mood to an emoji and background color, so Chip's appearance changes with its emotional state.
  • The '33' appended to the color hex creates a 20% opacity background, giving Chip a subtle glow effect.
  • Save src/components/Chip.tsx.

You will not see a change on screen yet. The next chunk builds ChipCoach, which uses Chip on the Home screen.

Seeing a TypeScript error?

Make sure the import from react-native-reanimated includes all four names: useSharedValue, useAnimatedStyle, and withSpring. Missing any one of them causes a red squiggly on the line that uses it.

help me fix a TypeScript error in my Chip.tsx component

Create the ChipCoach compound component

ChipCoach orchestrates a two-part animation sequence. Chip bounces in first, then the speech bubble fades in with an upward slide after a 400ms delay. This sequencing makes the interaction feel conversational, like Chip is thinking before speaking.

  • Create a new file src/components/ChipCoach.tsx and add this code:
import { useEffect } from 'react';
import { View } from 'react-native';
import Animated, { useSharedValue, useAnimatedStyle, withSpring, withDelay, withTiming } from 'react-native-reanimated';
import { Chip } from './Chip';
import { ChipBubble } from './ChipBubble';

type Mood = 'happy' | 'thinking' | 'warning';

interface ChipCoachProps {
  message: string;
  mood?: Mood;
}

export function ChipCoach({ message, mood = 'happy' }: ChipCoachProps) {
  // Chip entrance animation values
  const chipScale = useSharedValue(0);
  // Bubble animation values (delayed)
  const bubbleOpacity = useSharedValue(0);
  const bubbleTranslateY = useSharedValue(10);

  useEffect(() => {
    // Chip bounces in immediately
    chipScale.value = withSpring(1, { damping: 10, stiffness: 150 });
    // Bubble fades in after 400ms delay
    bubbleOpacity.value = withDelay(400, withTiming(1, { duration: 300 }));
    bubbleTranslateY.value = withDelay(400, withSpring(0));
  }, []);

  const chipStyle = useAnimatedStyle(() => ({ transform: [{ scale: chipScale.value }] }));
  const bubbleStyle = useAnimatedStyle(() => ({ opacity: bubbleOpacity.value, transform: [{ translateY: bubbleTranslateY.value }] }));

  return (
    <View className="flex-row items-end gap-3 p-4">
      <Animated.View style={chipStyle}>
        <Chip mood={mood} />
      </Animated.View>
      <Animated.View style={bubbleStyle} className="flex-1">
        <ChipBubble message={message} />
      </Animated.View>
    </View>
  );
}

What does this code do?

  • Three shared values control the animation: chipScale for Chip's entrance, bubbleOpacity and bubbleTranslateY for the speech bubble.
  • withDelay(400, ...) makes the bubble wait 400 milliseconds before animating. This creates the illusion that Chip appears first, then "speaks."
  • The bubble uses withTiming for opacity (linear fade) and withSpring for the vertical slide (natural deceleration).
  • The mood prop passes through to Chip, controlling its emoji and color.
  • Save src/components/ChipCoach.tsx.

You will not see a change on screen yet. The next chunk wires ChipCoach into the Home screen where you will see it in action.

Module not found error for Chip or ChipBubble?

The imports use relative paths (./Chip and ./ChipBubble). Both files must be in the same src/components/ directory as ChipCoach.tsx. Double-check that Chip.tsx and ChipBubble.tsx both exist there.

help me fix import errors in ChipCoach

Integrate ChipCoach into the Home screen with rotating quips

The Home screen currently shows a static placeholder where Chip should be. You will replace it with ChipCoach and add a rotating array of motivational messages that cycle every 4 seconds using setInterval.

  • Open src/app/(tabs)/index.tsx and replace the entire file contents with this code:
import { View, Text, ScrollView } from 'react-native';
import { useRouter } from 'expo-router';
import { useState, useEffect } from 'react';
import { ChipCoach } from '../../components/ChipCoach';
import { Button } from '../../components/Button';

// Rotating motivational messages from Chip
const QUIPS = [
  "Let's nail that squat form today!",
  "Your bench depth has improved 12% this week!",
  "Ready when you are, champ.",
  "Consistency is the key to gains! 💪",
];

export default function HomeScreen() {
  const router = useRouter();
  const [quipIndex, setQuipIndex] = useState(0);

  // Cycle through quips every 4 seconds
  useEffect(() => {
    const interval = setInterval(() => {
      setQuipIndex((i) => (i + 1) % QUIPS.length);
    }, 4000);
    return () => clearInterval(interval);
  }, []);

  return (
    <ScrollView className="flex-1 bg-background-primary">
      <View className="px-6 pt-16 pb-4">
        <Text className="text-text-tertiary text-sm">Good morning,</Text>
        <Text className="text-white text-3xl font-bold">Ready to lift? 💪</Text>
      </View>
      <View className="mx-6 my-4">
        <ChipCoach message={QUIPS[quipIndex]} mood="happy" />
      </View>
      <View className="mx-6 my-2 bg-background-card rounded-2xl p-4">
        <Text className="text-text-tertiary text-xs uppercase tracking-wider">Last Workout</Text>
        <Text className="text-white text-lg font-semibold mt-1">Back Squat</Text>
        <Text className="text-text-secondary text-sm">Score: 78 · Yesterday</Text>
      </View>
      <View className="mx-6 mt-6">
        <Button label="Start Form Check" onPress={() => router.push('/(tabs)/record')} variant="primary" />
      </View>
    </ScrollView>
  );
}

What's happening here?

  • The QUIPS array holds four motivational messages that Chip cycles through.
  • useState(0) tracks which quip is currently showing.
  • setInterval fires every 4000ms, advancing the index and wrapping around with the modulo operator (%).
  • The cleanup function clearInterval prevents memory leaks when the component unmounts.
  • The placeholder TouchableOpacity button is replaced with your reusable Button component from the design system.
  • Save src/app/(tabs)/index.tsx.
  • Check your iPhone. You should see Chip (a robot emoji in a green circle) bounce into view on the Home screen, followed by a dark speech bubble sliding up with a motivational quip.
  • Wait about 4 seconds. The speech bubble text should change to a new message.

Chip not appearing on the Home screen?

Make sure the import path is ../../components/ChipCoach (two levels up from the (tabs) directory). A single ../ only goes up one level and will not reach the components folder.

If you see a blank space where Chip should be, try clearing the cache by stopping the dev server and running:

npx expo start -c

help me debug why ChipCoach isn't showing on my Home screen

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here are all three files you created or modified in this step.

import { useEffect } from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';

type Mood = 'happy' | 'thinking' | 'warning';

interface ChipProps {
  mood?: Mood;
  onPress?: () => void;
}

const MOOD_CONFIG: Record<Mood, { emoji: string; color: string }> = {
  happy: { emoji: '🤖', color: '#30D158' },
  thinking: { emoji: '🤔', color: '#0A84FF' },
  warning: { emoji: '⚠️', color: '#FFD60A' },
};

export function Chip({ mood = 'happy', onPress }: ChipProps) {
  const scale = useSharedValue(0);
  const config = MOOD_CONFIG[mood];

  useEffect(() => {
    scale.value = withSpring(1, { damping: 10, stiffness: 150 });
  }, []);

  const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));

  return (
    <TouchableOpacity onPress={onPress} activeOpacity={0.8}>
      <Animated.View style={animatedStyle} className="w-16 h-16 rounded-full items-center justify-center" style={[animatedStyle, { backgroundColor: config.color + '33' }]}>
        <Text className="text-3xl">{config.emoji}</Text>
      </Animated.View>
    </TouchableOpacity>
  );
}
import { useEffect } from 'react';
import { View } from 'react-native';
import Animated, { useSharedValue, useAnimatedStyle, withSpring, withDelay, withTiming } from 'react-native-reanimated';
import { Chip } from './Chip';
import { ChipBubble } from './ChipBubble';

type Mood = 'happy' | 'thinking' | 'warning';

interface ChipCoachProps {
  message: string;
  mood?: Mood;
}

export function ChipCoach({ message, mood = 'happy' }: ChipCoachProps) {
  const chipScale = useSharedValue(0);
  const bubbleOpacity = useSharedValue(0);
  const bubbleTranslateY = useSharedValue(10);

  useEffect(() => {
    chipScale.value = withSpring(1, { damping: 10, stiffness: 150 });
    bubbleOpacity.value = withDelay(400, withTiming(1, { duration: 300 }));
    bubbleTranslateY.value = withDelay(400, withSpring(0));
  }, []);

  const chipStyle = useAnimatedStyle(() => ({ transform: [{ scale: chipScale.value }] }));
  const bubbleStyle = useAnimatedStyle(() => ({ opacity: bubbleOpacity.value, transform: [{ translateY: bubbleTranslateY.value }] }));

  return (
    <View className="flex-row items-end gap-3 p-4">
      <Animated.View style={chipStyle}>
        <Chip mood={mood} />
      </Animated.View>
      <Animated.View style={bubbleStyle} className="flex-1">
        <ChipBubble message={message} />
      </Animated.View>
    </View>
  );
}
import { View, Text, ScrollView } from 'react-native';
import { useRouter } from 'expo-router';
import { useState, useEffect } from 'react';
import { ChipCoach } from '../../components/ChipCoach';
import { Button } from '../../components/Button';

const QUIPS = [
  "Let's nail that squat form today!",
  "Your bench depth has improved 12% this week!",
  "Ready when you are, champ.",
  "Consistency is the key to gains! 💪",
];

export default function HomeScreen() {
  const router = useRouter();
  const [quipIndex, setQuipIndex] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setQuipIndex((i) => (i + 1) % QUIPS.length);
    }, 4000);
    return () => clearInterval(interval);
  }, []);

  return (
    <ScrollView className="flex-1 bg-background-primary">
      <View className="px-6 pt-16 pb-4">
        <Text className="text-text-tertiary text-sm">Good morning,</Text>
        <Text className="text-white text-3xl font-bold">Ready to lift? 💪</Text>
      </View>
      <View className="mx-6 my-4">
        <ChipCoach message={QUIPS[quipIndex]} mood="happy" />
      </View>
      <View className="mx-6 my-2 bg-background-card rounded-2xl p-4">
        <Text className="text-text-tertiary text-xs uppercase tracking-wider">Last Workout</Text>
        <Text className="text-white text-lg font-semibold mt-1">Back Squat</Text>
        <Text className="text-text-secondary text-sm">Score: 78 · Yesterday</Text>
      </View>
      <View className="mx-6 mt-6">
        <Button label="Start Form Check" onPress={() => router.push('/(tabs)/record')} variant="primary" />
      </View>
    </ScrollView>
  );
}

Your FormCheck app now has a personality. Chip bounces in with coaching quips, the dark-mode design system ties everything together, and the navigation feels smooth. You have built a complete, polished mobile app shell ready for real AI-powered features in the next project.

Secret mission

Haptic Micro-Interactions

Your FormCheck app looks great, but it doesn't feel great yet. Real fitness apps reward every tap with satisfying tactile feedback. In this bonus challenge, you'll wire up haptic feedback and micro-animations to every interactive element so the app feels premium in your hand.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine with no cloud services or paid APIs. There are no ongoing costs to worry about.

Decide whether to keep your project files and dev server running, pause to free up resources, or delete everything entirely.

Resources you used:

  • FormCheck project folder on your Desktop (all source code, components, and config files).
  • Expo development server (running process in your terminal).
  • Node modules installed inside the project folder.

Keep everything running

No action needed. Choose this if you are still actively building or plan to continue with Project 2 (Camera + Pose + AI Pipeline) soon.

  • Your Expo dev server stays running in the terminal. You can reconnect your iPhone anytime by scanning the QR code.
  • Your project files remain on your Desktop, ready for the next session.

Pause - I'll come back to this later

Shut down the running dev server to free up memory and your terminal. Your project files stay intact for when you return.

  • Switch to the terminal where the Expo dev server is running.
  • Press Ctrl+C to stop the dev server.
  • Your project folder remains on your Desktop. When you are ready to resume, navigate to the project folder and run npx expo start again.

Delete - I don't want to use this again

Remove all project files and stop the dev server. This frees up disk space but means you would need to start fresh if you want to build this again.

  • Switch to the terminal where the Expo dev server is running.
  • Press Ctrl+C to stop the dev server.
  • Delete the project folder by running the command for your operating system:

macOS

cd ~/Desktop
rm -rf FormCheck

Windows

cd ~\Desktop
Remove-Item -Recurse -Force FormCheck
  • Confirm the folder is gone by running:
ls ~/Desktop

You should no longer see the FormCheck project folder in the output.

Nice Work!

Nice Work!

You just built a production-quality mobile app foundation from scratch. FormCheck is running on your iPhone with smooth animations, a polished dark-mode design, and an AI mascot ready to coach.

You've learned how to:

  • Build a complete Expo SDK 54 + TypeScript + NativeWind project with file-based tab navigation using Expo Router.
  • Design a dark-mode design system with custom color tokens and a reusable component library (ScoreRing, FeedbackCard, Button, StatCard).
  • Create an animated ChipCoach mascot with spring-based entrance animations using React Native Reanimated and set up CLAUDE.md for AI-assisted development throughout the series.
  • Secret Mission: Added haptic feedback and micro-interactions to every interactive element for a premium tactile feel.

Ready to quiz yourself?