Kinetik UI

Progress Bar

.md

A bar that springs to each new amount with a sheen along the fill, a counting percentage and a green finish.

v1.0.0Live on webreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add progress-bar

The CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/progress-bar.json

Install manually

  1. Install dependencies
    npx expo install react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/progress-bar.tsx
    View source files

Usage

import { ProgressBar } from '@/components/kinetik/progress-bar';

<ProgressBar label="Uploading 12 photos" value={sent / total} />;

// Unknown length:
<ProgressBar label="Preparing the export" value={null} />;

When to use

  • Work that takes more than a second or two and whose amount is known: uploads, downloads, installs, imports.
  • With value={null} for work whose length is unknown yet, switching to a value once it is.

When not to use

  • Short waits under a second. Show nothing, or a spinner on the button (Status Button).
  • Quotas or limits that are not work in progress. Use Usage Meter.

Notes

  • The fill springs to each new value and the percentage counts up; a sheen travels along the fill while work is under way and stops at rest.
  • At 100% the fill turns green and doneLabel replaces the number.
  • It is a progressbar with a 0–100 value for screen readers, busy until done; indeterminate bars have no value.
  • With reduced motion the fill jumps, the number changes at once and there is no sheen or sweep.

Props

ProgressBarProps

PropTypeDefaultDescription
valuerequirednumber | null—From 0 to 1, or null while the amount of work is unknown.
labelstring—What is in progress, e.g. "Uploading 3 photos". Shown above the bar.
showValuebooleantrueThe percentage on the right. Defaults to true.
size'sm' | 'md''md'Bar thickness. Defaults to "md".
doneLabelstring'Done'Replaces the percentage once the bar is full. Defaults to "Done".
testIDstring—
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
Plain views and four shared values. The sheen loops on the UI thread only while work is under way; a finished bar is idle.
Measured

Pixel 9 emulator, API 35, release build · interaction median 1.17× the system Settings app on the same emulator (indicative only) · keeps drawing at rest · 146 MB app memory. The demo advances the bar every 0.9 s, so the sheen was running at rest; a finished bar draws nothing.

Real-device measurement pending.

Accessibility

Reduced motion
The fill jumps to each value with no sheen; the indeterminate bar holds still.
Screen readers
A progress bar named by its label with a 0 to 100 value, marked busy until it is done.
Touch target
Not interactive.

Try it on a device

Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.

Source files

components/kinetik/progress-bar.tsx
import { useEffect, useState } from 'react';
import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
  cancelAnimation,
  interpolateColor,
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withSequence,
  withSpring,
  withTiming,
} from 'react-native-reanimated';

import { useCountUp } from '../../lib/kinetik/hooks/use-count-up';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { easings, spring, timing } from '../../lib/kinetik/motion/motion';
import { gradient } from '../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';

export type ProgressBarProps = {
  /** From 0 to 1, or null while the amount of work is unknown. */
  value: number | null;
  /** What is in progress, e.g. "Uploading 3 photos". Shown above the bar. */
  label?: string;
  /** The percentage on the right. Defaults to true. */
  showValue?: boolean;
  /** Bar thickness. Defaults to "md". */
  size?: 'sm' | 'md';
  /** Replaces the percentage once the bar is full. Defaults to "Done". */
  doneLabel?: string;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

/**
 * A progress bar that springs to each new amount while a soft sheen travels
 * along the fill. The percentage counts up beside the label; at 100% the fill
 * turns green and "Done" takes the number's place. With no value it shows an
 * indeterminate segment that sweeps the track.
 */
export function ProgressBar({
  value,
  label,
  showValue = true,
  size = 'md',
  doneLabel = 'Done',
  testID,
  style,
}: ProgressBarProps) {
  const { colors, font, radius, space } = useKinetikTheme();
  const reduced = useReduceMotion();
  const known = value !== null;
  const v = known ? Math.min(1, Math.max(0, value)) : 0;
  const done = known && v >= 1;
  const percent = Math.round(v * 100);
  const shown = useCountUp(percent, { duration: 600, instant: reduced });
  const h = size === 'sm' ? 6 : 10;
  const [width, setWidth] = useState(0);

  const fill = useSharedValue(v);
  const finish = useSharedValue(done ? 1 : 0);
  const sweep = useSharedValue(0);
  const pop = useSharedValue(done ? 1 : 0);

  useEffect(() => {
    fill.set(withSpring(v, spring('smooth', reduced)));
  }, [v, reduced, fill]);
  useEffect(() => {
    finish.set(withTiming(done ? 1 : 0, timing('base', reduced)));
    pop.set(
      done
        ? withSequence(withTiming(0, { duration: 0 }), withSpring(1, spring('bouncy', reduced)))
        : 0,
    );
  }, [done, reduced, finish, pop]);

  // The sheen (or the indeterminate segment) loops only while work is under way.
  const moving = !done && !reduced && (!known || v > 0);
  useEffect(() => {
    if (!moving) {
      cancelAnimation(sweep);
      sweep.set(0);
      return;
    }
    sweep.set(0);
    sweep.set(
      withRepeat(
        withTiming(1, {
          duration: known ? 1600 : 1300,
          easing: known ? easings.linear : easings.inOut,
        }),
        -1,
      ),
    );
    return () => cancelAnimation(sweep);
  }, [moving, known, sweep]);

  const bar = useAnimatedStyle(() => {
    if (!known) {
      const t = sweep.get();
      const w = 0.28 + Math.sin(t * Math.PI) * 0.14;
      return {
        width: width * w,
        transform: [{ translateX: -width * w + t * (width + width * w) }],
        backgroundColor: colors.azure,
      };
    }
    return {
      width: Math.max(0, fill.get()) * width,
      backgroundColor: interpolateColor(finish.get(), [0, 1], [colors.azure, colors.success]),
    };
  });
  const sheen = useAnimatedStyle(() => ({
    opacity: known && moving ? 1 : 0,
    transform: [{ translateX: -80 + sweep.get() * (width + 80) }],
  }));
  const check = useAnimatedStyle(() => ({
    opacity: pop.get(),
    transform: [{ scale: 0.5 + pop.get() * 0.5 }],
  }));

  const light = withAlpha(colors.highlight, 0.45);
  const clear = withAlpha(colors.highlight, 0);

  return (
    <View
      testID={testID}
      accessible
      accessibilityRole="progressbar"
      accessibilityLabel={label ?? 'Progress'}
      accessibilityValue={known ? { min: 0, max: 100, now: percent } : undefined}
      accessibilityState={{ busy: !done }}
      style={[{ gap: space[2] }, style]}
    >
      {label || showValue ? (
        <View style={styles.header}>
          <Text
            numberOfLines={1}
            style={[
              styles.label,
              { color: colors.text, fontSize: font.size.sm, fontWeight: font.weight.medium },
            ]}
          >
            {label}
          </Text>
          {showValue && known ? (
            done ? (
              <Animated.View style={[styles.done, check]}>
                <View style={[styles.tick, { borderColor: colors.success }]} />
                <Text
                  style={{
                    color: colors.success,
                    fontSize: font.size.sm,
                    fontWeight: font.weight.semibold,
                  }}
                >
                  {doneLabel}
                </Text>
              </Animated.View>
            ) : (
              <Text
                style={{
                  color: colors.textMuted,
                  fontSize: font.size.sm,
                  fontVariant: ['tabular-nums'],
                }}
              >
                {Math.round(shown)}%
              </Text>
            )
          ) : null}
        </View>
      ) : null}
      <View
        onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
        style={[
          styles.track,
          { height: h, backgroundColor: colors.track, borderRadius: radius.pill },
        ]}
      >
        <Animated.View style={[styles.fill, { borderRadius: radius.pill }, bar]}>
          <Animated.View
            style={[
              styles.sheen,
              gradient(`linear-gradient(to right, ${clear}, ${light}, ${clear})`),
              sheen,
            ]}
          />
        </Animated.View>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 },
  label: { flex: 1 },
  done: { flexDirection: 'row', alignItems: 'center', gap: 6 },
  tick: {
    width: 10,
    height: 6,
    borderLeftWidth: 2,
    borderBottomWidth: 2,
    transform: [{ rotate: '-45deg' }],
    marginTop: -3,
  },
  track: { overflow: 'hidden' },
  fill: { position: 'absolute', left: 0, top: 0, bottom: 0, overflow: 'hidden' },
  sheen: { position: 'absolute', top: 0, bottom: 0, width: 80 },
});