Step Tracker
Progress through a multi-step flow: the line fills on a spring, steps take a check and the new one sends a ring.
import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withDelay,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type StepTrackerProps = {
/** One short label per step, e.g. ['Cart', 'Address', 'Payment', 'Review']. */
steps: readonly string[];
/** Index of the current step. `steps.length` means every step is done. */
current: number;
/** Lets people go back to a finished step. Only finished steps respond. */
onStepPress?: (index: number) => void;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const NODE = 28;
const LINE = 2;
/**
* Progress through a multi-step flow. The line fills toward the next step on
* a spring, finished steps fill and take a check as the line reaches them,
* and the step you arrive at sends out one soft ring.
*/
export function StepTracker({ steps, current, onStepPress, testID, style }: StepTrackerProps) {
const { colors } = useKinetikTheme();
const reduced = useReduceMotion();
const [width, setWidth] = useState(0);
const count = steps.length;
const clamped = Math.max(0, Math.min(count, current));
const p = useSharedValue(Math.min(clamped, count - 1));
useEffect(() => {
p.set(withSpring(Math.min(clamped, count - 1), spring('smooth', reduced)));
}, [clamped, count, reduced, p]);
const column = count > 0 ? width / count : 0;
const span = Math.max(0, width - column);
const fill = useAnimatedStyle(() => ({
width: count > 1 ? (span * p.get()) / (count - 1) : 0,
}));
return (
<View testID={testID} style={style} onLayout={(e) => setWidth(e.nativeEvent.layout.width)}>
<View
style={[
styles.track,
{ left: column / 2, right: column / 2, backgroundColor: colors.track },
]}
/>
<Animated.View
style={[styles.track, { left: column / 2, backgroundColor: colors.azure }, fill]}
/>
<View style={styles.row}>
{steps.map((label, i) => (
<Step
key={`${i}-${label}`}
index={i}
count={count}
label={label}
state={i < clamped ? 'done' : i === clamped ? 'current' : 'upcoming'}
reduced={reduced}
onPress={onStepPress && i < clamped ? () => onStepPress(i) : undefined}
/>
))}
</View>
</View>
);
}
type StepState = 'done' | 'current' | 'upcoming';
type StepProps = {
index: number;
count: number;
label: string;
state: StepState;
reduced: boolean;
onPress?: () => void;
};
function Step({ index, count, label, state, reduced, onPress }: StepProps) {
const { colors, font } = useKinetikTheme();
const done = useSharedValue(state === 'done' ? 1 : 0);
const active = useSharedValue(state === 'current' ? 1 : 0);
const halo = useSharedValue(0);
useEffect(() => {
// Wait for the line to arrive before a step changes, so the eye follows the fill.
const wait = reduced ? 0 : durations.quick;
done.set(withDelay(wait, withSpring(state === 'done' ? 1 : 0, spring('bouncy', reduced))));
active.set(withDelay(wait, withSpring(state === 'current' ? 1 : 0, spring('smooth', reduced))));
if (state === 'current' && !reduced) {
halo.set(
withDelay(
wait,
withSequence(
withTiming(0, { duration: 0 }),
withTiming(1, { duration: durations.slow * 1.5, easing: easings.standard }),
),
),
);
}
}, [state, reduced, done, active, halo]);
const node = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(done.get(), [0, 1], [colors.surface, colors.azure]),
borderColor: interpolateColor(
Math.max(done.get(), active.get()),
[0, 1],
[colors.borderStrong, colors.azure],
),
transform: [{ scale: 1 + Math.sin(Math.min(1, Math.max(0, done.get())) * Math.PI) * 0.12 }],
}));
const ring = useAnimatedStyle(() => ({
opacity: halo.get() > 0 && halo.get() < 1 ? (1 - halo.get()) * 0.5 : 0,
transform: [{ scale: 1 + halo.get() * 0.9 }],
}));
const dot = useAnimatedStyle(() => ({
opacity: active.get() * (1 - done.get()),
transform: [{ scale: active.get() }],
}));
const check = useAnimatedStyle(() => ({
opacity: done.get(),
transform: [{ rotate: '45deg' }, { scale: 0.5 + Math.max(0, done.get()) * 0.5 }],
}));
const number = useAnimatedStyle(() => ({ opacity: 1 - Math.max(done.get(), active.get()) }));
const status = state === 'done' ? 'done' : state === 'current' ? 'current step' : 'not started';
return (
<Pressable
disabled={!onPress}
onPress={onPress}
accessibilityRole={onPress ? 'button' : 'text'}
accessibilityLabel={`${label}, step ${index + 1} of ${count}, ${status}`}
accessibilityHint={onPress ? 'Goes back to this step' : undefined}
style={styles.step}
>
<View style={styles.nodeBox}>
<Animated.View style={[styles.halo, { backgroundColor: colors.azure }, ring]} />
<Animated.View style={[styles.node, node]}>
<Animated.Text
style={[
styles.number,
{ color: colors.textMuted, fontSize: font.size.xs, fontWeight: font.weight.semibold },
number,
]}
>
{index + 1}
</Animated.Text>
<Animated.View style={[styles.dot, { backgroundColor: colors.azure }, dot]} />
<Animated.View style={[styles.check, { borderColor: colors.onAzure }, check]} />
</Animated.View>
</View>
<Text
numberOfLines={2}
style={[
styles.label,
{
color: { done: colors.textMuted, current: colors.text, upcoming: colors.textSubtle }[
state
],
fontSize: font.size.xs,
fontWeight: state === 'current' ? font.weight.semibold : font.weight.medium,
},
]}
>
{label}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
track: { position: 'absolute', top: NODE / 2 - LINE / 2 + 8, height: LINE, borderRadius: LINE },
row: { flexDirection: 'row' },
step: { flex: 1, alignItems: 'center', gap: 8, paddingTop: 8, minHeight: 44 },
nodeBox: { width: NODE, height: NODE, alignItems: 'center', justifyContent: 'center' },
halo: { position: 'absolute', width: NODE, height: NODE, borderRadius: NODE / 2 },
node: {
width: NODE,
height: NODE,
borderRadius: NODE / 2,
borderWidth: 2,
alignItems: 'center',
justifyContent: 'center',
},
number: { position: 'absolute' },
dot: { position: 'absolute', width: 10, height: 10, borderRadius: 5 },
check: {
position: 'absolute',
width: 6,
height: 11,
marginTop: -3,
borderBottomWidth: 2,
borderRightWidth: 2,
},
label: { textAlign: 'center', paddingHorizontal: 2 },
});Installation
npx kinetik-ui add step-trackerpnpm dlx kinetik-ui add step-trackerbunx kinetik-ui add step-trackeryarn dlx kinetik-ui add step-trackerThe 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/step-tracker.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/step-tracker.tsx
Usage
import { StepTracker } from '@/components/kinetik/step-tracker';
<StepTracker
steps={['Cart', 'Address', 'Payment', 'Review']}
current={step}
onStepPress={setStep}
/>;current is the index of the step in progress. Set it to steps.length when the flow is finished and every step shows a check.
When to use
- Checkout, sign-up and setup flows with three to five steps.
- Any flow where knowing how much is left keeps people going.
When not to use
- More than five steps, or long labels. Show "Step 3 of 8" with a progress bar instead.
- Flows whose steps can be done in any order. Use a checklist.
Notes
- Steps share the width equally, so keep labels to a word or two.
- With
onStepPress, finished steps become buttons that go back. Steps ahead never respond. - A step changes a moment after the line starts to move, so the eye follows the fill to it.
- With reduced motion everything changes at once.
Props
StepTrackerProps
| Prop | Type | Default | Description |
|---|---|---|---|
stepsrequired | readonly string[] | — | One short label per step, e.g. ['Cart', 'Address', 'Payment', 'Review']. |
currentrequired | number | — | Index of the current step. `steps.length` means every step is done. |
onStepPress | (index: number) => void | — | Lets people go back to a finished step. Only finished steps respond. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- Plain views: one shared value for the line and three per step, changed only when the step changes. Nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 0.85× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 146 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- The line, the checks and the current step change at once, without the ring.
- Screen readers
- Each step reads its label, its place and its status ("Payment, step 3 of 4, current step"). Finished steps are buttons when onStepPress is set.
- Touch target
- Each step's column is the target, at least 44 pt tall and a share of the width.
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/step-tracker.tsx
import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withDelay,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type StepTrackerProps = {
/** One short label per step, e.g. ['Cart', 'Address', 'Payment', 'Review']. */
steps: readonly string[];
/** Index of the current step. `steps.length` means every step is done. */
current: number;
/** Lets people go back to a finished step. Only finished steps respond. */
onStepPress?: (index: number) => void;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const NODE = 28;
const LINE = 2;
/**
* Progress through a multi-step flow. The line fills toward the next step on
* a spring, finished steps fill and take a check as the line reaches them,
* and the step you arrive at sends out one soft ring.
*/
export function StepTracker({ steps, current, onStepPress, testID, style }: StepTrackerProps) {
const { colors } = useKinetikTheme();
const reduced = useReduceMotion();
const [width, setWidth] = useState(0);
const count = steps.length;
const clamped = Math.max(0, Math.min(count, current));
const p = useSharedValue(Math.min(clamped, count - 1));
useEffect(() => {
p.set(withSpring(Math.min(clamped, count - 1), spring('smooth', reduced)));
}, [clamped, count, reduced, p]);
const column = count > 0 ? width / count : 0;
const span = Math.max(0, width - column);
const fill = useAnimatedStyle(() => ({
width: count > 1 ? (span * p.get()) / (count - 1) : 0,
}));
return (
<View testID={testID} style={style} onLayout={(e) => setWidth(e.nativeEvent.layout.width)}>
<View
style={[
styles.track,
{ left: column / 2, right: column / 2, backgroundColor: colors.track },
]}
/>
<Animated.View
style={[styles.track, { left: column / 2, backgroundColor: colors.azure }, fill]}
/>
<View style={styles.row}>
{steps.map((label, i) => (
<Step
key={`${i}-${label}`}
index={i}
count={count}
label={label}
state={i < clamped ? 'done' : i === clamped ? 'current' : 'upcoming'}
reduced={reduced}
onPress={onStepPress && i < clamped ? () => onStepPress(i) : undefined}
/>
))}
</View>
</View>
);
}
type StepState = 'done' | 'current' | 'upcoming';
type StepProps = {
index: number;
count: number;
label: string;
state: StepState;
reduced: boolean;
onPress?: () => void;
};
function Step({ index, count, label, state, reduced, onPress }: StepProps) {
const { colors, font } = useKinetikTheme();
const done = useSharedValue(state === 'done' ? 1 : 0);
const active = useSharedValue(state === 'current' ? 1 : 0);
const halo = useSharedValue(0);
useEffect(() => {
// Wait for the line to arrive before a step changes, so the eye follows the fill.
const wait = reduced ? 0 : durations.quick;
done.set(withDelay(wait, withSpring(state === 'done' ? 1 : 0, spring('bouncy', reduced))));
active.set(withDelay(wait, withSpring(state === 'current' ? 1 : 0, spring('smooth', reduced))));
if (state === 'current' && !reduced) {
halo.set(
withDelay(
wait,
withSequence(
withTiming(0, { duration: 0 }),
withTiming(1, { duration: durations.slow * 1.5, easing: easings.standard }),
),
),
);
}
}, [state, reduced, done, active, halo]);
const node = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(done.get(), [0, 1], [colors.surface, colors.azure]),
borderColor: interpolateColor(
Math.max(done.get(), active.get()),
[0, 1],
[colors.borderStrong, colors.azure],
),
transform: [{ scale: 1 + Math.sin(Math.min(1, Math.max(0, done.get())) * Math.PI) * 0.12 }],
}));
const ring = useAnimatedStyle(() => ({
opacity: halo.get() > 0 && halo.get() < 1 ? (1 - halo.get()) * 0.5 : 0,
transform: [{ scale: 1 + halo.get() * 0.9 }],
}));
const dot = useAnimatedStyle(() => ({
opacity: active.get() * (1 - done.get()),
transform: [{ scale: active.get() }],
}));
const check = useAnimatedStyle(() => ({
opacity: done.get(),
transform: [{ rotate: '45deg' }, { scale: 0.5 + Math.max(0, done.get()) * 0.5 }],
}));
const number = useAnimatedStyle(() => ({ opacity: 1 - Math.max(done.get(), active.get()) }));
const status = state === 'done' ? 'done' : state === 'current' ? 'current step' : 'not started';
return (
<Pressable
disabled={!onPress}
onPress={onPress}
accessibilityRole={onPress ? 'button' : 'text'}
accessibilityLabel={`${label}, step ${index + 1} of ${count}, ${status}`}
accessibilityHint={onPress ? 'Goes back to this step' : undefined}
style={styles.step}
>
<View style={styles.nodeBox}>
<Animated.View style={[styles.halo, { backgroundColor: colors.azure }, ring]} />
<Animated.View style={[styles.node, node]}>
<Animated.Text
style={[
styles.number,
{ color: colors.textMuted, fontSize: font.size.xs, fontWeight: font.weight.semibold },
number,
]}
>
{index + 1}
</Animated.Text>
<Animated.View style={[styles.dot, { backgroundColor: colors.azure }, dot]} />
<Animated.View style={[styles.check, { borderColor: colors.onAzure }, check]} />
</Animated.View>
</View>
<Text
numberOfLines={2}
style={[
styles.label,
{
color: { done: colors.textMuted, current: colors.text, upcoming: colors.textSubtle }[
state
],
fontSize: font.size.xs,
fontWeight: state === 'current' ? font.weight.semibold : font.weight.medium,
},
]}
>
{label}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
track: { position: 'absolute', top: NODE / 2 - LINE / 2 + 8, height: LINE, borderRadius: LINE },
row: { flexDirection: 'row' },
step: { flex: 1, alignItems: 'center', gap: 8, paddingTop: 8, minHeight: 44 },
nodeBox: { width: NODE, height: NODE, alignItems: 'center', justifyContent: 'center' },
halo: { position: 'absolute', width: NODE, height: NODE, borderRadius: NODE / 2 },
node: {
width: NODE,
height: NODE,
borderRadius: NODE / 2,
borderWidth: 2,
alignItems: 'center',
justifyContent: 'center',
},
number: { position: 'absolute' },
dot: { position: 'absolute', width: 10, height: 10, borderRadius: 5 },
check: {
position: 'absolute',
width: 6,
height: 11,
marginTop: -3,
borderBottomWidth: 2,
borderRightWidth: 2,
},
label: { textAlign: 'center', paddingHorizontal: 2 },
});