# Progress

> A progress bar component to show completion status with optional interactivity.

**BNA UI** — a React Native / Expo component library.
These components render through `react-native`, not the DOM: there are no HTML
elements, no Tailwind classes and no Radix primitives. Source is copied into your
project and imported through `@/components/ui/*`, `@/components/charts/*`,
`@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than
hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`)
come from `@/theme/globals`.

- Docs: https://ui.ahmedbna.com/docs/components/progress
- Markdown: https://ui.ahmedbna.com/docs/components/progress.md
- Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/progress.json
- Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/progress.json
- Install: `npx bna-ui add progress`
- npm dependencies: `react-native-gesture-handler`, `react-native-reanimated`, `react-native-worklets`
- Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `view`
- Preview recording: https://demo.ahmedbna.com/0226-progress-demo.PNG

---

**Example:** A basic progress bar showing completion status

```tsx
// components/demo/progress/progress-demo.tsx
import { Progress } from '@/components/ui/progress';
import React from 'react';

export function ProgressDemo() {
  return <Progress value={65} />;
}
```

## Installation

### CLI

```bash
npx bna-ui add progress
```

### Manual

**1.** Install the following dependencies:

```bash
npx expo install react-native-gesture-handler react-native-reanimated
```

**2.** Copy and paste the following code into your project.

```tsx
// components/ui/progress.tsx
import { View } from '@/components/ui/view';
import { useColor } from '@/hooks/useColor';
import { HEIGHT } from '@/theme/globals';
import React, { useEffect } from 'react';
import { ViewStyle } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  runOnJS,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';

interface ProgressProps {
  value: number; // 0-100
  style?: ViewStyle;
  height?: number;
  onValueChange?: (value: number) => void;
  onSeekStart?: () => void;
  onSeekEnd?: () => void;
  interactive?: boolean;
  /** Amount to adjust by per accessibility increment/decrement action */
  step?: number;
}

export function Progress({
  value,
  style,
  height = HEIGHT,
  onValueChange,
  onSeekStart,
  onSeekEnd,
  interactive = false,
  step = 10,
}: ProgressProps) {
  const primaryColor = useColor('primary');
  const mutedColor = useColor('muted');

  const clampedValue = Math.max(0, Math.min(100, value));
  const progressWidth = useSharedValue(clampedValue);
  const containerWidth = useSharedValue(200); // Default width, will be updated
  const isDragging = useSharedValue(false);

  // Update animation when value prop changes (only if not dragging)
  useEffect(() => {
    if (!isDragging.value) {
      progressWidth.value = withTiming(clampedValue, { duration: 300 });
    }
  }, [clampedValue]);

  const updateValue = (newValue: number) => {
    const clamped = Math.max(0, Math.min(100, newValue));
    onValueChange?.(clamped);
  };

  const handleAccessibilityAction = (event: {
    nativeEvent: { actionName: string };
  }) => {
    if (!interactive) return;
    switch (event.nativeEvent.actionName) {
      case 'increment':
        updateValue(clampedValue + step);
        break;
      case 'decrement':
        updateValue(clampedValue - step);
        break;
    }
  };

  const handleSeekStart = () => {
    isDragging.value = true;
    onSeekStart?.();
  };

  const handleSeekEnd = () => {
    isDragging.value = false;
    onSeekEnd?.();
  };

  // Create pan gesture using the new Gesture API
  const panGesture = Gesture.Pan()
    .onStart(() => {
      if (!interactive) return;
      runOnJS(handleSeekStart)();
    })
    .onUpdate((event) => {
      if (!interactive) return;

      // Calculate new progress based on gesture position
      const newProgress = (event.x / containerWidth.value) * 100;
      const clampedProgress = Math.max(0, Math.min(100, newProgress));

      progressWidth.value = clampedProgress;
      runOnJS(updateValue)(clampedProgress);
    })
    .onEnd(() => {
      if (!interactive) return;
      runOnJS(handleSeekEnd)();
    });

  // Create tap gesture for direct seeking
  const tapGesture = Gesture.Tap().onStart((event) => {
    if (!interactive) return;

    runOnJS(handleSeekStart)();

    // Calculate progress based on tap position
    const newProgress = (event.x / containerWidth.value) * 100;
    const clampedProgress = Math.max(0, Math.min(100, newProgress));

    progressWidth.value = withTiming(clampedProgress, { duration: 200 });
    runOnJS(updateValue)(clampedProgress);

    setTimeout(() => {
      runOnJS(handleSeekEnd)();
    }, 200);
  });

  // Combine gestures
  const combinedGesture = Gesture.Race(panGesture, tapGesture);

  const animatedProgressStyle = useAnimatedStyle(() => {
    return {
      width: `${progressWidth.value}%`,
    };
  });

  const containerStyle: ViewStyle[] = [
    {
      height: height,
      width: '100%' as const,
      backgroundColor: mutedColor,
      borderRadius: height / 2,
      overflow: 'hidden' as const,
    },
    ...(style ? [style] : []),
  ];

  const onLayout = (event: any) => {
    containerWidth.value = event.nativeEvent.layout.width;
  };

  if (interactive) {
    return (
      <GestureDetector gesture={combinedGesture}>
        <Animated.View
          style={containerStyle}
          onLayout={onLayout}
          accessible
          accessibilityRole='adjustable'
          accessibilityValue={{ min: 0, max: 100, now: clampedValue }}
          accessibilityActions={[
            { name: 'increment', label: 'increment' },
            { name: 'decrement', label: 'decrement' },
          ]}
          onAccessibilityAction={handleAccessibilityAction}
        >
          <Animated.View
            style={[
              {
                height: '100%' as const,
                backgroundColor: primaryColor,
                borderRadius: height / 2,
              },
              animatedProgressStyle,
            ]}
          />
        </Animated.View>
      </GestureDetector>
    );
  }

  return (
    <View
      style={containerStyle}
      onLayout={onLayout}
      accessible
      accessibilityRole='progressbar'
      accessibilityValue={{ min: 0, max: 100, now: clampedValue }}
    >
      <Animated.View
        style={[
          {
            height: '100%' as const,
            backgroundColor: primaryColor,
            borderRadius: height / 2,
          },
          animatedProgressStyle,
        ]}
      />
    </View>
  );
}
```

**3.** Update the import paths to match your project setup.

## Usage

```tsx
import { Progress } from '@/components/ui/progress';
```

```tsx
<Progress value={65} />
```

## Examples

#### Default

**Example:** A basic progress bar showing completion status

```tsx
// components/demo/progress/progress-demo.tsx
import { Progress } from '@/components/ui/progress';
import React from 'react';

export function ProgressDemo() {
  return <Progress value={65} />;
}
```

#### Interactive

**Example:** An interactive progress bar that can be dragged or tapped

```tsx
// components/demo/progress/progress-interactive.tsx
import { Progress } from '@/components/ui/progress';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import React, { useState } from 'react';

export function ProgressInteractive() {
  const [value, setValue] = useState(45);
  const [isSeking, setIsSeeking] = useState(false);

  return (
    <View style={{ gap: 12 }}>
      <Text variant='body' style={{ color: '#666' }}>
        {isSeking ? 'Seeking...' : `Progress: ${Math.round(value)}%`}
      </Text>
      <Progress
        value={value}
        interactive
        height={18}
        onValueChange={setValue}
        onSeekStart={() => setIsSeeking(true)}
        onSeekEnd={() => setIsSeeking(false)}
      />
      <Text variant='caption' style={{ color: '#999' }}>
        Tap or drag to adjust the progress
      </Text>
    </View>
  );
}
```

#### Custom Heights

**Example:** Progress bars with different heights

```tsx
// components/demo/progress/progress-heights.tsx
import { Progress } from '@/components/ui/progress';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import React from 'react';

export function ProgressHeights() {
  return (
    <View style={{ gap: 16 }}>
      <View style={{ gap: 6 }}>
        <Text variant='caption'>Small (2px)</Text>
        <Progress value={75} height={2} />
      </View>

      <View style={{ gap: 6 }}>
        <Text variant='caption'>Default (4px)</Text>
        <Progress value={60} />
      </View>

      <View style={{ gap: 6 }}>
        <Text variant='caption'>Medium (8px)</Text>
        <Progress value={45} height={8} />
      </View>

      <View style={{ gap: 6 }}>
        <Text variant='caption'>Large (12px)</Text>
        <Progress value={30} height={12} />
      </View>

      <View style={{ gap: 6 }}>
        <Text variant='caption'>Extra Large (20px)</Text>
        <Progress value={85} height={20} />
      </View>
    </View>
  );
}
```

#### With Labels

**Example:** Progress bars with percentage labels and descriptions

```tsx
// components/demo/progress/progress-labels.tsx
import { Progress } from '@/components/ui/progress';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import React from 'react';

export function ProgressLabels() {
  const tasks = [
    { label: 'Installing dependencies', progress: 100 },
    { label: 'Building application', progress: 75 },
    { label: 'Running tests', progress: 45 },
    { label: 'Deploying to production', progress: 0 },
  ];

  return (
    <View style={{ gap: 20 }}>
      {tasks.map((task, index) => (
        <View key={index} style={{ gap: 8 }}>
          <View
            style={{
              flexDirection: 'row',
              justifyContent: 'space-between',
              alignItems: 'center',
            }}
          >
            <Text variant='body' style={{ fontWeight: '500' }}>
              {task.label}
            </Text>
            <Text variant='caption' style={{ color: '#666' }}>
              {task.progress}%
            </Text>
          </View>
          <Progress value={task.progress} height={6} />
        </View>
      ))}

      <View style={{ gap: 8, marginTop: 12 }}>
        <View
          style={{
            flexDirection: 'row',
            justifyContent: 'space-between',
            alignItems: 'center',
          }}
        >
          <Text variant='title' style={{ fontWeight: '600' }}>
            Overall Progress
          </Text>
          <Text variant='body' style={{ fontWeight: '500' }}>
            55%
          </Text>
        </View>
        <Progress value={55} height={10} />
        <Text variant='caption' style={{ color: '#666' }}>
          2 of 4 tasks completed
        </Text>
      </View>
    </View>
  );
}
```

#### Animated

**Example:** Progress bars with smooth animations and transitions

```tsx
// components/demo/progress/progress-animated.tsx
import { Progress } from '@/components/ui/progress';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import React, { useEffect, useState } from 'react';

export function ProgressAnimated() {
  const [progress1, setProgress1] = useState(0);
  const [progress2, setProgress2] = useState(0);
  const [progress3, setProgress3] = useState(0);

  useEffect(() => {
    // Animate first progress bar
    const timer1 = setTimeout(() => setProgress1(75), 500);

    // Animate second progress bar
    const timer2 = setTimeout(() => setProgress2(60), 1000);

    // Animate third progress bar
    const timer3 = setTimeout(() => setProgress3(85), 1500);

    return () => {
      clearTimeout(timer1);
      clearTimeout(timer2);
      clearTimeout(timer3);
    };
  }, []);

  const [cycleProgress, setCycleProgress] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setCycleProgress((prev) => {
        const newValue = prev + 10;
        return newValue > 100 ? 0 : newValue;
      });
    }, 300);

    return () => clearInterval(interval);
  }, []);

  return (
    <View style={{ gap: 20 }}>
      <View style={{ gap: 12 }}>
        <Text variant='title'>Staggered Animation</Text>

        <View style={{ gap: 8 }}>
          <Text variant='caption'>File Upload: {progress1}%</Text>
          <Progress value={progress1} height={6} />
        </View>

        <View style={{ gap: 8 }}>
          <Text variant='caption'>Processing: {progress2}%</Text>
          <Progress value={progress2} height={6} />
        </View>

        <View style={{ gap: 8 }}>
          <Text variant='caption'>Optimization: {progress3}%</Text>
          <Progress value={progress3} height={6} />
        </View>
      </View>

      <View style={{ gap: 12 }}>
        <Text variant='title'>Continuous Animation</Text>
        <View style={{ gap: 8 }}>
          <Text variant='caption'>Loading: {cycleProgress}%</Text>
          <Progress value={cycleProgress} height={8} />
        </View>
      </View>
    </View>
  );
}
```

#### Media Player Style

**Example:** Progress bars styled for media player controls

```tsx
// components/demo/progress/progress-media.tsx
import { Progress } from '@/components/ui/progress';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import React, { useState } from 'react';
import { TouchableOpacity } from 'react-native';

export function ProgressMedia() {
  const [progress, setProgress] = useState(35);
  const [isPlaying, setIsPlaying] = useState(false);
  const [volume, setVolume] = useState(75);

  const formatTime = (percent: number) => {
    const totalSeconds = Math.floor((percent / 100) * 180); // 3 minute song
    const minutes = Math.floor(totalSeconds / 60);
    const seconds = totalSeconds % 60;
    return `${minutes}:${seconds.toString().padStart(2, '0')}`;
  };

  return (
    <View style={{ gap: 20 }}>
      {/* Media Player */}
      <View
        style={{
          backgroundColor: '#1a1a1a',
          borderRadius: 12,
          padding: 16,
          gap: 12,
        }}
      >
        <View style={{ gap: 8 }}>
          <Text variant='body' style={{ color: '#fff', fontWeight: '600' }}>
            Song Title
          </Text>
          <Text variant='caption' style={{ color: '#999' }}>
            Artist Name
          </Text>
        </View>

        <View style={{ gap: 8 }}>
          <Progress
            value={progress}
            interactive
            height={4}
            onValueChange={setProgress}
            style={{ backgroundColor: '#333' }}
          />
          <View
            style={{
              flexDirection: 'row',
              justifyContent: 'space-between',
            }}
          >
            <Text variant='caption' style={{ color: '#999' }}>
              {formatTime(progress)}
            </Text>
            <Text variant='caption' style={{ color: '#999' }}>
              3:00
            </Text>
          </View>
        </View>

        <View
          style={{
            flexDirection: 'row',
            alignItems: 'center',
            justifyContent: 'center',
            gap: 20,
          }}
        >
          <TouchableOpacity
            style={{
              width: 48,
              height: 48,
              borderRadius: 24,
              backgroundColor: '#007AFF',
              justifyContent: 'center',
              alignItems: 'center',
            }}
            onPress={() => setIsPlaying(!isPlaying)}
          >
            <Text style={{ color: '#fff', fontSize: 20 }}>
              {isPlaying ? '⏸️' : '▶️'}
            </Text>
          </TouchableOpacity>
        </View>
      </View>

      {/* Volume Control */}
      <View
        style={{
          backgroundColor: '#f8f9fa',
          borderRadius: 8,
          padding: 12,
          gap: 8,
        }}
      >
        <View
          style={{
            flexDirection: 'row',
            alignItems: 'center',
            gap: 12,
          }}
        >
          <Text>🔊</Text>
          <View style={{ flex: 1 }}>
            <Progress
              value={volume}
              interactive
              height={6}
              onValueChange={setVolume}
            />
          </View>
          <Text variant='caption' style={{ color: '#666' }}>
            {Math.round(volume)}%
          </Text>
        </View>
      </View>
    </View>
  );
}
```

#### Step Progress

**Example:** Multi-step progress indicators

```tsx
// components/demo/progress/progress-steps.tsx
import { Progress } from '@/components/ui/progress';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import React, { useState } from 'react';
import { TouchableOpacity } from 'react-native';

export function ProgressSteps() {
  const [currentStep, setCurrentStep] = useState(2);

  const steps = ['Account Setup', 'Personal Info', 'Verification', 'Complete'];

  const progress = (currentStep / (steps.length - 1)) * 100;

  return (
    <View style={{ gap: 20 }}>
      {/* Step Progress */}
      <View style={{ gap: 16 }}>
        <View
          style={{
            flexDirection: 'row',
            justifyContent: 'space-between',
            alignItems: 'center',
          }}
        >
          <Text variant='title'>Setup Progress</Text>
          <Text variant='caption' style={{ color: '#666' }}>
            Step {currentStep + 1} of {steps.length}
          </Text>
        </View>

        <Progress value={progress} height={8} />

        <View
          style={{
            flexDirection: 'row',
            justifyContent: 'space-between',
            marginTop: 8,
          }}
        >
          {steps.map((step, index) => (
            <View
              key={index}
              style={{
                alignItems: 'center',
                flex: 1,
              }}
            >
              <View
                style={{
                  width: 24,
                  height: 24,
                  borderRadius: 12,
                  backgroundColor: index <= currentStep ? '#007AFF' : '#e5e7eb',
                  justifyContent: 'center',
                  alignItems: 'center',
                  marginBottom: 8,
                }}
              >
                <Text
                  variant='caption'
                  style={{
                    color: index <= currentStep ? '#fff' : '#666',
                    fontWeight: '600',
                  }}
                >
                  {index < currentStep ? '✓' : index + 1}
                </Text>
              </View>
              <Text
                variant='caption'
                style={{
                  color: index <= currentStep ? '#000' : '#999',
                  fontWeight: index === currentStep ? '600' : '400',
                  textAlign: 'center',
                }}
              >
                {step}
              </Text>
            </View>
          ))}
        </View>
      </View>

      {/* Controls */}
      <View
        style={{
          flexDirection: 'row',
          gap: 12,
          justifyContent: 'center',
        }}
      >
        <TouchableOpacity
          style={{
            paddingHorizontal: 16,
            paddingVertical: 8,
            backgroundColor: currentStep > 0 ? '#007AFF' : '#e5e7eb',
            borderRadius: 6,
          }}
          onPress={() => setCurrentStep(Math.max(0, currentStep - 1))}
          disabled={currentStep === 0}
        >
          <Text
            style={{
              color: currentStep > 0 ? '#fff' : '#999',
              fontWeight: '500',
            }}
          >
            Previous
          </Text>
        </TouchableOpacity>

        <TouchableOpacity
          style={{
            paddingHorizontal: 16,
            paddingVertical: 8,
            backgroundColor:
              currentStep < steps.length - 1 ? '#007AFF' : '#e5e7eb',
            borderRadius: 6,
          }}
          onPress={() =>
            setCurrentStep(Math.min(steps.length - 1, currentStep + 1))
          }
          disabled={currentStep === steps.length - 1}
        >
          <Text
            style={{
              color: currentStep < steps.length - 1 ? '#fff' : '#999',
              fontWeight: '500',
            }}
          >
            Next
          </Text>
        </TouchableOpacity>
      </View>
    </View>
  );
}
```

## API Reference

### Progress

The main progress bar component.

| Prop            | Type                      | Default         | Description                                                                                   |
| --------------- | ------------------------- | --------------- | --------------------------------------------------------------------------------------------- |
| `value`         | `number`                  | -               | The progress value between 0-100.                                                             |
| `style`         | `ViewStyle`               | -               | Additional styles to apply to the progress container.                                         |
| `height`        | `number`                  | `HEIGHT` (`48`) | The height of the progress bar in pixels.                                                     |
| `onValueChange` | `(value: number) => void` | -               | Callback fired when the progress value changes (interactive).                                 |
| `onSeekStart`   | `() => void`              | -               | Callback fired when seeking starts (interactive).                                             |
| `onSeekEnd`     | `() => void`              | -               | Callback fired when seeking ends (interactive).                                               |
| `interactive`   | `boolean`                 | `false`         | Whether the progress bar can be interacted with (tap/drag).                                   |
| `step`          | `number`                  | `10`            | Amount to adjust `value` by per accessibility increment/decrement action, when `interactive`. |

## Accessibility

The Progress component is built with accessibility in mind:

- Exposes `accessibilityRole="progressbar"` (or `"adjustable"` when `interactive`) with `accessibilityValue={{ min: 0, max: 100, now: value }}`
- When `interactive`, `accessibilityActions` (increment/decrement) let screen-reader users adjust the value without the drag gesture

## Interactive Features

When `interactive` is set to `true`, the Progress component supports:

- **Tap to seek**: Tap anywhere on the progress bar to jump to that position
- **Drag to scrub**: Drag the progress indicator to scrub through values
- **Smooth animations**: Animated transitions between values
- **Callbacks**: Get notified when seeking starts, changes, or ends

This makes it perfect for media players, volume controls, or any scenario where users need to adjust a value by interacting with the progress bar.
