Kinetik UI

Roll Stepper

.md

A number stepper whose figure rolls up or down with each step, speeds up while held and bumps at its limits.

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

Installation

Kinetik CLI
npx kinetik-ui add roll-stepper

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/roll-stepper.json

Install manually

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

Usage

import { RollStepper } from '@/components/kinetik/roll-stepper';

<RollStepper label="Guests" value={guests} onValueChange={setGuests} min={1} max={6} />;

formatValue changes what is shown and read aloud, for example (v) => `${v} kg` . The window is sized to the widest of the minimum, the maximum and the current value, so the buttons stay put.

When to use

  • Small counts changed a step at a time: guests, nights, quantities in a cart, sets and reps.
  • Values where the exact number matters more than speed of entry.

When not to use

  • Large ranges or rough values. Use a slider, such as Elastic Slider.
  • Numbers people already know and want to type, such as an amount of money. Use a text field.

Notes

  • Holding a button repeats after a short pause and speeds up to about twenty steps a second. Turn it off with repeat={false}.
  • At a limit the button dims and the figure bumps against the end, with a heavier haptic.
  • Fractional steps are rounded to six places, so 0.1 + 0.2 lands on 0.3.
  • With reduced motion the figure changes in place.

Props

RollStepperProps

PropTypeDefaultDescription
valuerequirednumber—
onValueChangerequired(value: number) => void—
labelrequiredstring—Names the stepper for screen readers, e.g. "Guests".
minnumber0Defaults to 0.
maxnumber99Defaults to 99.
stepnumber1Defaults to 1.
formatValue(value: number) => string(v) => String(v)Formats the value, e.g. `(v) => \`${v} nights\``.
repeatbooleantrueKeep stepping while a button is held, faster the longer it is held. Defaults to true.
hapticsbooleantrueA tick on each step and a bump at the limits. Defaults to true.
disabledbooleanfalse
testIDstring—
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
Two text views and one shared value per roll. Holding a button re-renders once per step, down to one step every 45 ms.
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 · 166 MB app memory

Real-device measurement pending.

Accessibility

Reduced motion
The figure changes in place, without the roll or the bump at the limits.
Screen readers
An adjustable value named by label, with increment and decrement actions, plus two buttons (Increase and Decrease) that report when a limit is reached.
Touch target
Both buttons are 44 pt circles.

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/roll-stepper.tsx
import { useEffect, useRef, useState } from 'react';
import {
  Pressable,
  StyleSheet,
  Text,
  View,
  type AccessibilityActionEvent,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withSequence,
  withSpring,
  withTiming,
} from 'react-native-reanimated';

import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget } from '../../lib/kinetik/tokens/tokens';

export type RollStepperProps = {
  value: number;
  onValueChange: (value: number) => void;
  /** Defaults to 0. */
  min?: number;
  /** Defaults to 99. */
  max?: number;
  /** Defaults to 1. */
  step?: number;
  /** Names the stepper for screen readers, e.g. "Guests". */
  label: string;
  /** Formats the value, e.g. `(v) => \`${v} nights\``. */
  formatValue?: (value: number) => string;
  /** Keep stepping while a button is held, faster the longer it is held. Defaults to true. */
  repeat?: boolean;
  /** A tick on each step and a bump at the limits. Defaults to true. */
  haptics?: boolean;
  disabled?: boolean;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

const HEIGHT = 48;
/** Hold this long before repeating, then repeat ever faster down to the floor. */
const HOLD_MS = 380;
const FIRST_MS = 140;
const FLOOR_MS = 45;

/**
 * A number stepper whose value rolls: up from below when it grows, down from
 * above when it shrinks. Holding a button keeps it rolling and speeds it up;
 * at a limit the figure bumps against the end instead.
 */
export function RollStepper({
  value,
  onValueChange,
  min = 0,
  max = 99,
  step = 1,
  label,
  formatValue = (v) => String(v),
  repeat = true,
  haptics = true,
  disabled = false,
  testID,
  style,
}: RollStepperProps) {
  const { colors, font, radius, space } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);

  // The figure on its way out, the direction of travel and a counter that restarts the roll.
  const [roll, setRoll] = useState({ from: value, to: value, dir: 1, id: 0 });
  if (roll.to !== value) {
    setRoll({ from: roll.to, to: value, dir: value > roll.to ? 1 : -1, id: roll.id + 1 });
  }
  const t = useSharedValue(1);
  const bump = useSharedValue(0);
  // The latest value for steps fired from a held button, between renders.
  const latest = useRef(value);
  useEffect(() => {
    latest.current = value;
  }, [value]);

  useEffect(() => {
    if (roll.id === 0) return;
    t.set(0);
    t.set(reduced ? 1 : withSpring(1, spring('snappy')));
  }, [roll.id, reduced, t]);

  const nudge = (dir: number) => {
    haptic('thud');
    if (reduced) return;
    bump.set(withSequence(withTiming(dir * 5, { duration: 70 }), withSpring(0, spring('bouncy'))));
  };

  const stepBy = (dir: 1 | -1) => {
    const current = latest.current;
    const next = Math.min(max, Math.max(min, current + dir * step));
    // Round away float drift from fractional steps.
    const clean = Number(next.toFixed(6));
    if (clean === current) {
      nudge(dir);
      return false;
    }
    latest.current = clean;
    haptic('tick');
    onValueChange(clean);
    return true;
  };

  // Hold to repeat: a pause, then steps that come faster and faster.
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const repeated = useRef(false);
  const stop = () => {
    if (timer.current) clearTimeout(timer.current);
    timer.current = null;
  };
  useEffect(() => stop, []);
  const start = (dir: 1 | -1) => {
    repeated.current = false;
    if (!repeat) return;
    let delay = FIRST_MS;
    const tick = () => {
      repeated.current = true;
      if (!stepBy(dir)) return stop();
      delay = Math.max(FLOOR_MS, delay * 0.86);
      timer.current = setTimeout(tick, delay);
    };
    timer.current = setTimeout(tick, HOLD_MS);
  };

  const outgoing = useAnimatedStyle(() => ({
    opacity: 1 - t.get(),
    transform: [{ translateY: -roll.dir * t.get() * HEIGHT * 0.55 }, { scale: 1 - t.get() * 0.2 }],
  }));
  const incoming = useAnimatedStyle(() => ({
    opacity: t.get(),
    transform: [
      { translateY: roll.dir * (1 - t.get()) * HEIGHT * 0.55 },
      { scale: 0.8 + t.get() * 0.2 },
    ],
  }));
  const figure = useAnimatedStyle(() => ({ transform: [{ translateX: bump.get() }] }));

  const onAccessibilityAction = (e: AccessibilityActionEvent) => {
    if (disabled) return;
    stepBy(e.nativeEvent.actionName === 'increment' ? 1 : -1);
  };

  const button = (dir: 1 | -1) => {
    const atLimit = dir === 1 ? value >= max : value <= min;
    return (
      <Pressable
        // Left undefined when enabled, so Pressable keeps the limit in accessibilityState;
        // a press at the limit still bumps the figure.
        disabled={disabled || undefined}
        onPressIn={() => start(dir)}
        onPressOut={stop}
        onPress={() => {
          if (!repeated.current) stepBy(dir);
        }}
        accessibilityRole="button"
        accessibilityLabel={`${dir === 1 ? 'Increase' : 'Decrease'} ${label}`}
        accessibilityState={{ disabled: disabled || atLimit }}
        style={({ pressed }) => [
          styles.button,
          {
            borderRadius: radius.pill,
            backgroundColor: pressed ? colors.surfaceSunken : colors.surfaceRaised,
            opacity: atLimit ? 0.35 : 1,
            transform: [{ scale: pressed && !reduced ? 0.92 : 1 }],
          },
        ]}
      >
        <View style={[styles.bar, { backgroundColor: colors.text }]} />
        {dir === 1 ? (
          <View style={[styles.bar, styles.vertical, { backgroundColor: colors.text }]} />
        ) : null}
      </Pressable>
    );
  };

  const text = { color: colors.text, fontSize: font.size.xl, fontWeight: font.weight.semibold };
  const widest = [formatValue(min), formatValue(max), formatValue(value)].reduce((a, b) =>
    b.length > a.length ? b : a,
  );

  return (
    <View
      testID={testID}
      style={[
        styles.shell,
        {
          borderRadius: radius.pill,
          borderColor: colors.border,
          backgroundColor: colors.surface,
          gap: space[2],
          opacity: disabled ? 0.45 : 1,
        },
        style,
      ]}
    >
      {button(-1)}
      <View
        accessible
        accessibilityRole="adjustable"
        accessibilityLabel={label}
        accessibilityValue={{ min, max, now: value, text: formatValue(value) }}
        accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
        onAccessibilityAction={onAccessibilityAction}
        style={styles.window}
      >
        {/* Sizes the window to the widest figure, so the buttons never shift. */}
        <Text style={[text, styles.tabular, styles.sizer]} importantForAccessibility="no">
          {widest}
        </Text>
        <Animated.View style={[StyleSheet.absoluteFill, figure]}>
          {roll.from !== roll.to ? (
            <Animated.Text
              key={`out-${roll.id}`}
              numberOfLines={1}
              style={[styles.figure, text, styles.tabular, outgoing]}
            >
              {formatValue(roll.from)}
            </Animated.Text>
          ) : null}
          <Animated.Text
            key={`in-${roll.id}`}
            numberOfLines={1}
            style={[styles.figure, text, styles.tabular, incoming]}
          >
            {formatValue(roll.to)}
          </Animated.Text>
        </Animated.View>
      </View>
      {button(1)}
    </View>
  );
}

const styles = StyleSheet.create({
  shell: {
    flexDirection: 'row',
    alignItems: 'center',
    padding: 4,
    borderWidth: StyleSheet.hairlineWidth,
    alignSelf: 'flex-start',
  },
  button: {
    width: minTouchTarget,
    height: minTouchTarget,
    alignItems: 'center',
    justifyContent: 'center',
  },
  bar: { width: 14, height: 2, borderRadius: 1 },
  vertical: { position: 'absolute', transform: [{ rotate: '90deg' }] },
  window: { minWidth: 64, height: HEIGHT, overflow: 'hidden', justifyContent: 'center' },
  sizer: { opacity: 0, paddingHorizontal: 8, textAlign: 'center' },
  figure: {
    position: 'absolute',
    left: 0,
    right: 0,
    top: 0,
    bottom: 0,
    textAlign: 'center',
    textAlignVertical: 'center',
    lineHeight: HEIGHT,
  },
  tabular: { fontVariant: ['tabular-nums'] },
});