Step Slider
A slider with a few fixed stops marked as dots, a snapping knob and a spinning action button.
import { useEffect, type ReactNode } from 'react';
import {
Pressable,
StyleSheet,
View,
type AccessibilityActionEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
interpolate,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring, timing } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';
export type StepSliderAction = {
icon: ReactNode;
/** Accessible name, e.g. "Generate again". */
label: string;
onPress: () => void;
/** Turn the icon a full circle on each press, for refresh-style actions. Defaults to true. */
spin?: boolean;
};
export type StepSliderProps = {
/** The selected step, from 0. */
value: number;
onValueChange: (value: number) => void;
/** Number of stops. Defaults to 4. */
steps?: number;
/** Names for the stops, read by screen readers, e.g. ["Low", "Medium", "High", "Max"]. */
labels?: readonly string[];
/** Accessible name. */
label: string;
/** A round button at the end, e.g. regenerate. */
action?: StepSliderAction;
disabled?: boolean;
/** A tick on every stop. Defaults to true. */
haptics?: boolean;
style?: StyleProp<ViewStyle>;
};
const THUMB = 22;
/**
* A slider with a few fixed stops marked as dots. The knob follows the finger
* and snaps to the nearest stop with a small overshoot; dots it has passed
* fade back, and the optional button at the end spins when pressed.
*/
export function StepSlider({
value,
onValueChange,
steps = 4,
labels,
label,
action,
disabled = false,
haptics = true,
style,
}: StepSliderProps) {
const { colors, radius, space, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const last = Math.max(1, steps - 1);
const width = useSharedValue(0);
const pos = useSharedValue(value);
const held = useSharedValue(0);
const committed = useSharedValue(value);
const dragging = useSharedValue(false);
useEffect(() => {
if (dragging.get()) return;
pos.set(reduced ? value : withSpring(value, spring('bouncy')));
committed.set(value);
}, [value, reduced, pos, committed, dragging]);
const emit = (v: number) => {
onValueChange(v);
haptic('tick');
};
const moveTo = (x: number) => {
'worklet';
const w = width.get() - THUMB;
if (w <= 0) return;
const p = Math.max(0, Math.min(1, (x - THUMB / 2) / w)) * last;
pos.set(p);
const v = Math.round(p);
if (v !== committed.get()) {
committed.set(v);
scheduleOnRN(emit, v);
}
};
const release = () => {
'worklet';
dragging.set(false);
held.set(withSpring(0, spring('smooth', reduced)));
pos.set(withSpring(committed.get(), spring('bouncy', reduced)));
};
const pan = Gesture.Pan()
.withTestId('step-slider')
.enabled(!disabled)
.activeOffsetX([-4, 4])
.failOffsetY([-14, 14])
.onBegin(() => {
dragging.set(true);
held.set(withSpring(1, spring('snappy', reduced)));
})
.onStart((e) => moveTo(e.x))
.onUpdate((e) => moveTo(e.x))
.onFinalize(() => release());
const tap = Gesture.Tap()
.withTestId('step-slider-tap')
.enabled(!disabled)
.onEnd((e) => {
moveTo(e.x);
release();
});
const thumb = useAnimatedStyle(() => ({
transform: [
{ translateX: (pos.get() / last) * Math.max(0, width.get() - THUMB) },
{ scale: 1 + held.get() * 0.2 },
],
}));
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (disabled) return;
const next = committed.get() + (e.nativeEvent.actionName === 'increment' ? 1 : -1);
const v = Math.max(0, Math.min(last, next));
if (v === committed.get()) return;
committed.set(v);
pos.set(withSpring(v, spring('bouncy', reduced)));
onValueChange(v);
};
const text = labels?.[value] ?? `Step ${value + 1} of ${steps}`;
return (
<View
style={[
styles.panel,
{
backgroundColor: colors.surfaceRaised,
borderRadius: radius.lg,
paddingLeft: space[3],
paddingRight: action ? space[1] : space[3],
gap: space[2],
opacity: disabled ? 0.45 : 1,
},
style,
]}
>
<GestureDetector gesture={Gesture.Race(pan, tap)}>
<View
collapsable={false}
accessible
accessibilityRole="adjustable"
accessibilityLabel={label}
accessibilityValue={{ min: 0, max: last, now: value, text }}
accessibilityState={{ disabled }}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={onAccessibilityAction}
onLayout={(e) => width.set(e.nativeEvent.layout.width)}
style={styles.hit}
>
<View style={[styles.line, { backgroundColor: colors.track }]} />
<View style={styles.dots} pointerEvents="none">
{Array.from({ length: steps }, (_, i) => (
<Dot key={i} index={i} pos={pos} />
))}
</View>
<Animated.View
pointerEvents="none"
style={[
styles.thumb,
{ backgroundColor: colors.highlight, boxShadow: shadow.md },
thumb,
]}
/>
</View>
</GestureDetector>
{action ? <SpinButton action={action} disabled={disabled} /> : null}
</View>
);
}
/** A stop marker that grows as the knob comes near and dims once it is behind it. */
function Dot({ index, pos }: { index: number; pos: SharedValue<number> }) {
const { colors } = useKinetikTheme();
const style = useAnimatedStyle(() => {
const d = pos.get() - index;
return {
opacity: d > 0.5 ? 0.45 : 1,
transform: [{ scale: interpolate(Math.abs(d), [0, 1], [1.6, 1], 'clamp') }],
};
});
return (
<View style={styles.dotSlot}>
<Animated.View style={[styles.dot, { backgroundColor: colors.text }, style]} />
</View>
);
}
function SpinButton({ action, disabled }: { action: StepSliderAction; disabled: boolean }) {
const { colors } = useKinetikTheme();
const reduced = useReduceMotion();
const turn = useSharedValue(0);
const press = useSharedValue(0);
const style = useAnimatedStyle(() => ({
transform: [{ rotate: `${turn.get() * 360}deg` }, { scale: 1 - press.get() * 0.12 }],
}));
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={action.label}
disabled={disabled}
onPress={() => {
if (action.spin !== false && !reduced) {
turn.set(withTiming(Math.round(turn.get()) + 1, { ...timing('slow'), duration: 600 }));
}
action.onPress();
}}
onPressIn={() => press.set(withSpring(1, spring('snappy', reduced)))}
onPressOut={() => press.set(withSpring(0, spring('bouncy', reduced)))}
style={({ pressed }) => [
styles.action,
{ backgroundColor: pressed ? withAlpha(colors.text, 0.06) : 'transparent' },
]}
>
<Animated.View style={style}>{action.icon}</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create({
panel: { minHeight: 56, flexDirection: 'row', alignItems: 'center' },
hit: { flex: 1, height: minTouchTarget, justifyContent: 'center' },
line: { position: 'absolute', left: THUMB / 2, right: THUMB / 2, height: 2, borderRadius: 1 },
dots: {
position: 'absolute',
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'space-between',
},
dotSlot: { width: THUMB, alignItems: 'center' },
dot: { width: 6, height: 6, borderRadius: 3 },
thumb: { position: 'absolute', left: 0, width: THUMB, height: THUMB, borderRadius: THUMB / 2 },
action: {
width: minTouchTarget,
height: minTouchTarget,
borderRadius: minTouchTarget / 2,
alignItems: 'center',
justifyContent: 'center',
},
});Installation
npx kinetik-ui add step-sliderpnpm dlx kinetik-ui add step-sliderbunx kinetik-ui add step-slideryarn dlx kinetik-ui add step-sliderThe 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-slider.json
Install manually
- Install dependencies
npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/step-slider.tsx
Usage
import { StepSlider } from '@/components/kinetik/step-slider';
<StepSlider
label="Creativity"
steps={4}
labels={['Subtle', 'Balanced', 'Bold', 'Wild']}
value={level}
onValueChange={setLevel}
action={{ icon: <RefreshIcon />, label: 'Generate again', onPress: regenerate }}
/>;When to use
- Three to six levels that read as a scale: strength, quality, creativity.
- Next to an action that uses the level, such as generate or apply.
When not to use
- Unrelated choices: use Segment Bar.
- Fine values: use Value Slider.
Notes
- Pass
labelsso screen readers read the level by name. - The action spins a full turn on each press; set
spin: falsefor actions that are not refresh-like.
Props
StepSliderProps
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | number | — | The selected step, from 0. |
onValueChangerequired | (value: number) => void | — | |
labelrequired | string | — | Accessible name. |
steps | number | 4 | Number of stops. Defaults to 4. |
labels | readonly string[] | — | Names for the stops, read by screen readers, e.g. ["Low", "Medium", "High", "Max"]. |
action | StepSliderAction | — | A round button at the end, e.g. regenerate. |
disabled | boolean | false | |
haptics | boolean | true | A tick on every stop. Defaults to true. |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- The knob and dots are driven by one shared value on the UI thread; React re-renders only when the stop changes.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.00× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 150 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- The knob snaps without overshoot and the action icon does not spin.
- Screen readers
- Adjustable role reading the stop name (or "Step 2 of 4"); increment and decrement move one stop.
- Touch target
- The track is 44 pt tall; the action button is 44 pt.
Source files
components/kinetik/step-slider.tsx
import { useEffect, type ReactNode } from 'react';
import {
Pressable,
StyleSheet,
View,
type AccessibilityActionEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
interpolate,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring, timing } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';
export type StepSliderAction = {
icon: ReactNode;
/** Accessible name, e.g. "Generate again". */
label: string;
onPress: () => void;
/** Turn the icon a full circle on each press, for refresh-style actions. Defaults to true. */
spin?: boolean;
};
export type StepSliderProps = {
/** The selected step, from 0. */
value: number;
onValueChange: (value: number) => void;
/** Number of stops. Defaults to 4. */
steps?: number;
/** Names for the stops, read by screen readers, e.g. ["Low", "Medium", "High", "Max"]. */
labels?: readonly string[];
/** Accessible name. */
label: string;
/** A round button at the end, e.g. regenerate. */
action?: StepSliderAction;
disabled?: boolean;
/** A tick on every stop. Defaults to true. */
haptics?: boolean;
style?: StyleProp<ViewStyle>;
};
const THUMB = 22;
/**
* A slider with a few fixed stops marked as dots. The knob follows the finger
* and snaps to the nearest stop with a small overshoot; dots it has passed
* fade back, and the optional button at the end spins when pressed.
*/
export function StepSlider({
value,
onValueChange,
steps = 4,
labels,
label,
action,
disabled = false,
haptics = true,
style,
}: StepSliderProps) {
const { colors, radius, space, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const last = Math.max(1, steps - 1);
const width = useSharedValue(0);
const pos = useSharedValue(value);
const held = useSharedValue(0);
const committed = useSharedValue(value);
const dragging = useSharedValue(false);
useEffect(() => {
if (dragging.get()) return;
pos.set(reduced ? value : withSpring(value, spring('bouncy')));
committed.set(value);
}, [value, reduced, pos, committed, dragging]);
const emit = (v: number) => {
onValueChange(v);
haptic('tick');
};
const moveTo = (x: number) => {
'worklet';
const w = width.get() - THUMB;
if (w <= 0) return;
const p = Math.max(0, Math.min(1, (x - THUMB / 2) / w)) * last;
pos.set(p);
const v = Math.round(p);
if (v !== committed.get()) {
committed.set(v);
scheduleOnRN(emit, v);
}
};
const release = () => {
'worklet';
dragging.set(false);
held.set(withSpring(0, spring('smooth', reduced)));
pos.set(withSpring(committed.get(), spring('bouncy', reduced)));
};
const pan = Gesture.Pan()
.withTestId('step-slider')
.enabled(!disabled)
.activeOffsetX([-4, 4])
.failOffsetY([-14, 14])
.onBegin(() => {
dragging.set(true);
held.set(withSpring(1, spring('snappy', reduced)));
})
.onStart((e) => moveTo(e.x))
.onUpdate((e) => moveTo(e.x))
.onFinalize(() => release());
const tap = Gesture.Tap()
.withTestId('step-slider-tap')
.enabled(!disabled)
.onEnd((e) => {
moveTo(e.x);
release();
});
const thumb = useAnimatedStyle(() => ({
transform: [
{ translateX: (pos.get() / last) * Math.max(0, width.get() - THUMB) },
{ scale: 1 + held.get() * 0.2 },
],
}));
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (disabled) return;
const next = committed.get() + (e.nativeEvent.actionName === 'increment' ? 1 : -1);
const v = Math.max(0, Math.min(last, next));
if (v === committed.get()) return;
committed.set(v);
pos.set(withSpring(v, spring('bouncy', reduced)));
onValueChange(v);
};
const text = labels?.[value] ?? `Step ${value + 1} of ${steps}`;
return (
<View
style={[
styles.panel,
{
backgroundColor: colors.surfaceRaised,
borderRadius: radius.lg,
paddingLeft: space[3],
paddingRight: action ? space[1] : space[3],
gap: space[2],
opacity: disabled ? 0.45 : 1,
},
style,
]}
>
<GestureDetector gesture={Gesture.Race(pan, tap)}>
<View
collapsable={false}
accessible
accessibilityRole="adjustable"
accessibilityLabel={label}
accessibilityValue={{ min: 0, max: last, now: value, text }}
accessibilityState={{ disabled }}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={onAccessibilityAction}
onLayout={(e) => width.set(e.nativeEvent.layout.width)}
style={styles.hit}
>
<View style={[styles.line, { backgroundColor: colors.track }]} />
<View style={styles.dots} pointerEvents="none">
{Array.from({ length: steps }, (_, i) => (
<Dot key={i} index={i} pos={pos} />
))}
</View>
<Animated.View
pointerEvents="none"
style={[
styles.thumb,
{ backgroundColor: colors.highlight, boxShadow: shadow.md },
thumb,
]}
/>
</View>
</GestureDetector>
{action ? <SpinButton action={action} disabled={disabled} /> : null}
</View>
);
}
/** A stop marker that grows as the knob comes near and dims once it is behind it. */
function Dot({ index, pos }: { index: number; pos: SharedValue<number> }) {
const { colors } = useKinetikTheme();
const style = useAnimatedStyle(() => {
const d = pos.get() - index;
return {
opacity: d > 0.5 ? 0.45 : 1,
transform: [{ scale: interpolate(Math.abs(d), [0, 1], [1.6, 1], 'clamp') }],
};
});
return (
<View style={styles.dotSlot}>
<Animated.View style={[styles.dot, { backgroundColor: colors.text }, style]} />
</View>
);
}
function SpinButton({ action, disabled }: { action: StepSliderAction; disabled: boolean }) {
const { colors } = useKinetikTheme();
const reduced = useReduceMotion();
const turn = useSharedValue(0);
const press = useSharedValue(0);
const style = useAnimatedStyle(() => ({
transform: [{ rotate: `${turn.get() * 360}deg` }, { scale: 1 - press.get() * 0.12 }],
}));
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={action.label}
disabled={disabled}
onPress={() => {
if (action.spin !== false && !reduced) {
turn.set(withTiming(Math.round(turn.get()) + 1, { ...timing('slow'), duration: 600 }));
}
action.onPress();
}}
onPressIn={() => press.set(withSpring(1, spring('snappy', reduced)))}
onPressOut={() => press.set(withSpring(0, spring('bouncy', reduced)))}
style={({ pressed }) => [
styles.action,
{ backgroundColor: pressed ? withAlpha(colors.text, 0.06) : 'transparent' },
]}
>
<Animated.View style={style}>{action.icon}</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create({
panel: { minHeight: 56, flexDirection: 'row', alignItems: 'center' },
hit: { flex: 1, height: minTouchTarget, justifyContent: 'center' },
line: { position: 'absolute', left: THUMB / 2, right: THUMB / 2, height: 2, borderRadius: 1 },
dots: {
position: 'absolute',
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'space-between',
},
dotSlot: { width: THUMB, alignItems: 'center' },
dot: { width: 6, height: 6, borderRadius: 3 },
thumb: { position: 'absolute', left: 0, width: THUMB, height: THUMB, borderRadius: THUMB / 2 },
action: {
width: minTouchTarget,
height: minTouchTarget,
borderRadius: minTouchTarget / 2,
alignItems: 'center',
justifyContent: 'center',
},
});