Steps Bar
Steps towards a goal: the bar fills from olive to green as a small walker strides along its front.
import { useEffect, useState } from 'react';
import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withRepeat,
withSpring,
withTiming,
} from 'react-native-reanimated';
import Svg, { Circle, Path } from 'react-native-svg';
import { useCountUp } from '../../lib/kinetik/hooks/use-count-up';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { easings, spring } from '../../lib/kinetik/motion/motion';
import { gradient } from '../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type StepsBarProps = {
steps: number;
/** Daily goal. Defaults to 10000. */
goal?: number;
/** Distance line, e.g. "4.2 KM". */
distance?: string;
/** Climb line, e.g. "9 FLOORS". */
climb?: string;
/** Label over the count. Defaults to "STEPS". */
label?: string;
style?: StyleProp<ViewStyle>;
};
const TRACK_H = 26;
const KNOB = 24;
/** 10000 as "10K", 2500 as "2.5K". */
export function compactCount(value: number): string {
if (value < 1000) return String(value);
const k = value / 1000;
return `${Number.isInteger(k) ? k : k.toFixed(1)}K`;
}
/**
* Steps towards a goal: the bar fills from olive to bright green as a small walker
* strides along its front and the count climbs. The walker stops once the goal is
* reached.
*/
export function StepsBar({
steps,
goal = 10000,
distance,
climb,
label = 'STEPS',
style,
}: StepsBarProps) {
const { colors, font } = useKinetikTheme();
const reduced = useReduceMotion();
const [width, setWidth] = useState(0);
const shown = Math.round(useCountUp(steps, { instant: reduced }));
const share = Math.max(0, Math.min(1, steps / goal));
const walking = share < 1 && !reduced;
const at = useSharedValue(reduced ? share : 0);
const stride = useSharedValue(0);
useEffect(() => {
at.set(reduced ? share : withSpring(share, spring('smooth')));
}, [share, reduced, at]);
useEffect(() => {
if (!walking) {
stride.set(0);
return;
}
stride.set(withRepeat(withTiming(1, { duration: 420, easing: easings.inOut }), -1, true));
}, [walking, stride]);
const fill = useAnimatedStyle(() => ({
width: KNOB + at.get() * Math.max(0, width - KNOB),
}));
const knob = useAnimatedStyle(() => ({
transform: [{ translateX: at.get() * Math.max(0, width - KNOB) }],
}));
const walker = useAnimatedStyle(() => ({
transform: [{ translateY: -stride.get() * 1.5 }, { rotate: `${(stride.get() - 0.5) * 12}deg` }],
}));
const small = { color: MUTED, fontSize: font.size.xs, fontWeight: font.weight.medium } as const;
return (
<View
accessible
accessibilityRole="progressbar"
accessibilityLabel={[`${steps.toLocaleString('en-US')} ${label}`, distance, climb]
.filter(Boolean)
.join(', ')}
accessibilityValue={{ min: 0, max: goal, now: Math.min(steps, goal) }}
style={[styles.card, style]}
>
<View style={styles.head}>
<View>
<Text style={{ color: GREEN, fontSize: font.size.xs, fontWeight: font.weight.bold }}>
{label}
</Text>
<Text
style={{
color: colors.highlight,
fontSize: font.size.xl,
fontWeight: font.weight.semibold,
fontVariant: ['tabular-nums'],
}}
>
{shown.toLocaleString('en-US')}
</Text>
</View>
<View style={styles.side}>
{distance ? <Text style={small}>↔ {distance}</Text> : null}
{climb ? <Text style={small}>↑ {climb}</Text> : null}
</View>
</View>
<View
testID="steps-bar-track"
style={styles.track}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
>
<Animated.View style={[styles.fill, gradient(FILL), fill]} />
<Animated.View style={[styles.knob, knob]}>
<Animated.View style={walker}>
<Svg width={16} height={16} viewBox="0 0 24 24">
<Circle cx={13} cy={4} r={2.2} fill={KNOB_INK} />
<Path
d="M12 8 8 10.5V14M12 8l1.5 5.5L17 16M13.5 13.5 11 17l-2.5 4M12 8l4 2.5 2.5-.5"
stroke={KNOB_INK}
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
</Animated.View>
</Animated.View>
</View>
<View style={styles.scale}>
{[0, goal / 2, goal].map((tick) => (
<Text key={tick} style={{ color: MUTED, fontSize: font.size.xs - 2 }}>
{compactCount(tick)}
</Text>
))}
</View>
</View>
);
}
const MUTED = 'hsl(80, 4%, 66%)';
const GREEN = 'hsl(92, 76%, 56%)';
const KNOB_INK = 'hsl(90, 70%, 10%)';
const FILL =
'linear-gradient(90deg, hsl(70, 56%, 18%) 0%, hsl(84, 70%, 38%) 62%, hsl(96, 84%, 52%) 100%)';
const styles = StyleSheet.create({
card: {
width: '100%',
maxWidth: 320,
alignSelf: 'center',
borderRadius: 24,
borderWidth: 1,
borderColor: 'hsla(0, 0%, 100%, 0.08)',
backgroundColor: 'hsl(0, 0%, 5%)',
padding: 16,
gap: 8,
},
head: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end' },
side: { alignItems: 'flex-end', gap: 2 },
track: {
height: TRACK_H,
borderRadius: TRACK_H / 2,
justifyContent: 'center',
backgroundColor: 'hsl(0, 0%, 15%)',
},
fill: {
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
borderRadius: TRACK_H / 2,
},
knob: {
position: 'absolute',
left: 0,
width: KNOB,
height: KNOB,
borderRadius: KNOB / 2,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'hsl(96, 92%, 66%)',
boxShadow: '0px 0px 12px hsla(96, 92%, 60%, 0.7)',
},
scale: { flexDirection: 'row', justifyContent: 'space-between' },
});Installation
npx kinetik-ui add steps-barpnpm dlx kinetik-ui add steps-barbunx kinetik-ui add steps-baryarn dlx kinetik-ui add steps-barThe 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/steps-bar.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-svg react-native-worklets - Copy the source files
components/kinetik/steps-bar.tsx
Usage
import { StepsBar } from '@/components/kinetik/steps-bar';
<StepsBar steps={6317} distance="4.2 KM" climb="9 FLOORS" />;When to use
- Daily step or distance goals.
- Any single count towards a target with a bit of character.
When not to use
- Several goals at once. Use Activity Rings.
- Counts with no target.
Notes
- The walker stops once the goal is reached.
- The scale marks 0, half and the goal.
Props
StepsBarProps
| Prop | Type | Default | Description |
|---|---|---|---|
stepsrequired | number | — | |
goal | number | 10000 | Daily goal. Defaults to 10000. |
distance | string | — | Distance line, e.g. "4.2 KM". |
climb | string | — | Climb line, e.g. "9 FLOORS". |
label | string | 'STEPS' | Label over the count. Defaults to "STEPS". |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- The bar moves on a UI-thread spring on change. The walker is one view on a UI-thread loop until the goal, so it animates at rest.
- Measured
Huawei P20 Lite (ANE-LX1), Android 9, release build · 12 ms median frame, 15 ms p90 · keeps drawing at rest · 123 MB app memory
Accessibility
- Reduced motion
- The bar jumps to its place, the walker holds still and the count does not climb.
- Screen readers
- One progress bar ("6,317 STEPS, 4.2 KM, 9 FLOORS").
- Touch target
- Not interactive.
Source files
components/kinetik/steps-bar.tsx is in the Code tab of the preview above.