Free
Open source • Copy & paste

Fit Track

Workout dashboard kit

Activity rings, exercise timers, and progress cards for fitness apps.

2 screens
Expo Router + StyleSheet + Reanimated + SVG
Motion-ready
workoutstatstimer
Browse more kits

Source code

Explore the component structure. Copy directly into any Expo app.

import { Ionicons } from '@expo/vector-icons';
import { Stack, useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react';
import {
  ScrollView,
  StatusBar,
  TouchableOpacity,
  View,
  Dimensions,
  Pressable,
} from 'react-native';
import Animated, {
  FadeInDown,
  useAnimatedProps,
  useSharedValue,
  withTiming,
  withRepeat,
  withSequence,
  useAnimatedStyle,
  withSpring,
  Easing,
  interpolate,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Svg, { Circle, G } from 'react-native-svg';
import { Text } from '@/components/ui';
import WorkoutCard from '../components/workout-card';
import { FITNESS_COLORS, MOCK_WORKOUTS, WEEKLY_STATS } from '../constants';

const AnimatedCircle = Animated.createAnimatedComponent(Circle);
const { width } = Dimensions.get('window');

interface ActivityRingProps {
  radius: number;
  stroke: number;
  color: string;
  progress: number; // 0 to 1
  delay?: number;
}

const ActivityRing = ({
  radius,
  stroke,
  color,
  progress,
  delay = 0,
}: ActivityRingProps) => {
  const circumference = 2 * Math.PI * radius;
  const progressValue = useSharedValue(0);

  useEffect(() => {
    progressValue.value = withTiming(progress, {
      duration: 1500,
      easing: Easing.out(Easing.exp),
    });
  }, [progress]);

  const animatedProps = useAnimatedProps(() => {
    const strokeDashoffset = interpolate(
      progressValue.value,
      [0, 1],
      [circumference, 0],
    );
    return {
      strokeDashoffset,
    };
  });

  return (
    <View className="absolute items-center justify-center">
      <Svg height={radius * 2 + stroke} width={radius * 2 + stroke}>
        <G
          rotation="-90"
          origin={`${radius + stroke / 2}, ${radius + stroke / 2}`}
        >
          <Circle
            cx="50%"
            cy="50%"
            r={radius}
            stroke={color}
            strokeWidth={stroke}
            strokeOpacity={0.2}
            fill="transparent"
          />
          <AnimatedCircle
            cx="50%"
            cy="50%"
            r={radius}
            stroke={color}
            strokeWidth={stroke}
            fill="transparent"
            strokeDasharray={circumference}
            animatedProps={animatedProps}
            strokeLinecap="round"
          />
        </G>
      </Svg>
    </View>
  );
};

const StatCard = ({
  icon,
  label,
  value,
  color,
  details,
}: {
  icon: keyof typeof Ionicons.glyphMap;
  label: string;
  value: string;
  color: string;
  details: string;
}) => {
  const [expanded, setExpanded] = useState(false);
  const scale = useSharedValue(1);

  const toggleExpand = () => {
    setExpanded(!expanded);
    scale.value = withSequence(
      withTiming(0.95, { duration: 100 }),
      withTiming(1, { duration: 100 }),
    );
  };

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

  return (
    <Pressable onPress={toggleExpand} className="flex-1">
      <Animated.View
        style={[animatedStyle]}
        className={`bg-zinc-900 p-4 rounded-2xl border border-zinc-800 ${expanded ? 'border-primary/50' : ''}`}
      >
        <View className="flex-row items-center gap-2 mb-2">
          <Ionicons name={icon} size={20} color={color} />
          <Text className="text-zinc-400 text-xs font-bold uppercase">
            {label}
          </Text>
        </View>
        <Text className="text-white text-xl font-black">{value}</Text>
        {expanded && (
          <Animated.View entering={FadeInDown.duration(200)} className="mt-2">
            <Text className="text-zinc-500 text-xs leading-4">{details}</Text>
          </Animated.View>
        )}
      </Animated.View>
    </Pressable>
  );
};

const HeartRate = () => {
  const scale = useSharedValue(1);

  useEffect(() => {
    scale.value = withRepeat(
      withSequence(
        withTiming(1.2, { duration: 100 }),
        withTiming(1, { duration: 100 }),
        withTiming(1.2, { duration: 100 }),
        withTiming(1, { duration: 400 }), // Pause between beats
      ),
      -1,
      true,
    );
  }, []);

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

  return (
    <Animated.View style={animatedStyle}>
      <Ionicons name="heart" size={24} color={FITNESS_COLORS.danger} />
    </Animated.View>
  );
};

export default function FitnessDashboard() {
  const router = useRouter();
  const insets = useSafeAreaInsets();

  return (
    <View className="flex-1 bg-zinc-950">
      <Stack.Screen options={{ headerShown: false }} />
      <StatusBar barStyle="light-content" />

      <ScrollView
        className="flex-1"
        contentContainerStyle={{
          paddingTop: insets.top + 20,
          paddingBottom: 100,
          paddingHorizontal: 20,
        }}
        showsVerticalScrollIndicator={false}
      >
        {/* Header */}
        <Animated.View
          entering={FadeInDown.delay(100)}
          className="flex-row justify-between items-center mb-8"
        >
          <View>
            <Text className="text-zinc-400 text-sm font-medium uppercase tracking-widest mb-1">
              Thursday, 12 Oct
            </Text>
            <Text className="text-white text-3xl font-black">
              Hello, Runner{' '}
              <Text style={{ color: FITNESS_COLORS.primary }}>⚡️</Text>
            </Text>
          </View>
          <TouchableOpacity className="w-12 h-12 rounded-full border border-zinc-800 items-center justify-center bg-zinc-900">
            <Ionicons name="notifications-outline" size={24} color="white" />
          </TouchableOpacity>
        </Animated.View>

        {/* Activity Rings Section */}
        <Animated.View
          entering={FadeInDown.delay(200)}
          className="w-full bg-zinc-900 p-6 rounded-[32px] mb-8 border border-zinc-800"
        >
          <View className="flex-row items-center justify-between mb-6">
            <View>
              <Text className="text-white text-4xl font-black mb-1">
                Active
              </Text>
              <Text className="text-zinc-500 font-medium tracking-wide">
                Today's Progress
              </Text>
            </View>
            {/* Rings Container */}
            <View className="w-24 h-24 items-center justify-center relative">
              <ActivityRing
                radius={40}
                stroke={8}
                color={FITNESS_COLORS.ring1}
                progress={0.75}
              />
              <ActivityRing
                radius={28}
                stroke={8}
                color={FITNESS_COLORS.ring3}
                progress={0.85}
              />
            </View>
          </View>

          {/* Stats Grid */}
          <View className="flex-row gap-3">
            <StatCard
              icon="flame"
              label="Calories"
              value="840"
              color={FITNESS_COLORS.ring1}
              details="240 Active, 600 Resting"
            />
            <StatCard
              icon="time"
              label="Time"
              value="45m"
              color={FITNESS_COLORS.ring3}
              details="15m Remaining"
            />
          </View>
        </Animated.View>

        {/* Heart Rate Section */}
        <Animated.View
          entering={FadeInDown.delay(300)}
          className="mb-8 flex-row items-center justify-between bg-zinc-900/50 p-4 rounded-2xl border border-zinc-800"
        >
          <View className="flex-row items-center gap-3">
            <View className="w-10 h-10 rounded-full bg-red-500/10 items-center justify-center">
              <HeartRate />
            </View>
            <View>
              <Text className="text-white font-bold text-lg">Heart Rate</Text>
              <Text className="text-zinc-500 text-xs">Avg. 128 bpm</Text>
            </View>
          </View>
          <View className="flex-row items-end gap-1">
            <Text className="text-white text-2xl font-black">112</Text>
            <Text className="text-zinc-500 text-sm font-medium mb-1">bpm</Text>
          </View>
        </Animated.View>

        {/* Weekly Activity Chart */}
        <Animated.View entering={FadeInDown.delay(400)} className="mb-8">
          <View className="flex-row justify-between items-end mb-4">
            <Text className="text-white text-xl font-bold">Activity</Text>
            <Text
              style={{ color: FITNESS_COLORS.primary }}
              className="font-bold"
            >
              Weekly Goal
            </Text>
          </View>
          <View className="bg-zinc-900 rounded-3xl p-5 flex-row justify-between items-end h-48 border border-zinc-800">
            {WEEKLY_STATS.map((stat, i) => (
              <View key={i} className="items-center gap-2">
                <View className="w-8 bg-zinc-800 rounded-full overflow-hidden relative justify-end h-32">
                  <Animated.View
                    entering={FadeInDown.delay(i * 50 + 500)}
                    style={{
                      height: `${(stat.value / 1000) * 100}%`,
                      width: '100%',
                      backgroundColor: stat.active
                        ? FITNESS_COLORS.primary
                        : '#27272a',
                      borderRadius: 100,
                    }}
                  />
                </View>
                <Text
                  className={`text-xs font-bold ${stat.active ? 'text-white' : 'text-zinc-600'}`}
                >
                  {stat.day}
                </Text>
              </View>
            ))}
          </View>
        </Animated.View>

        {/* Popular Workouts */}
        <Animated.View entering={FadeInDown.delay(500)}>
          <View className="flex-row justify-between items-center mb-4">
            <Text className="text-white text-xl font-bold">
              Popular Workouts
            </Text>
            <Text className="text-zinc-500 font-bold">See All</Text>
          </View>
          <ScrollView
            horizontal
            showsHorizontalScrollIndicator={false}
            style={{ marginHorizontal: -20, paddingHorizontal: 20 }}
          >
            {MOCK_WORKOUTS.map((item, index) => (
              <WorkoutCard
                key={item.id}
                item={item}
                index={index}
                onPress={() => {}}
              />
            ))}
          </ScrollView>
        </Animated.View>
      </ScrollView>
    </View>
  );
}

Building a full app?

VibeFast Pro is a complete Expo + Next.js starter kit with authentication, payments (RevenueCat), AI features, backend (Convex/Supabase), and more. Ship your app in days, not months.

Learn more