Kinetik UI

Range Slider

.md

A two-ended slider for a range: the nearer end follows the finger with a value bubble, over an optional histogram.

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

Installation

Kinetik CLI
npx kinetik-ui add range-slider

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/range-slider.json

Install manually

  1. Install dependencies
    npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/range-slider.tsx
    View source files

Usage

import { RangeSlider } from '@/components/kinetik/range-slider';

const [price, setPrice] = useState<[number, number]>([120, 380]);

<RangeSlider
  label="Per night"
  value={price}
  onValueChange={setPrice}
  max={600}
  step={10}
  minDistance={50}
  formatValue={(v) => `$${v}`}
  histogram={bands}
/>;

histogram takes one number per band across the range, such as how many results fall in each price band. Bars inside the range light up as it changes.

When to use

  • Filtering by a range: price, distance, duration, year built.
  • Ranges where seeing the spread of results helps people choose, with histogram.

When not to use

  • A single value. Use Elastic Slider.
  • Ranges people know exactly and want to type. Pair two number fields instead.

Notes

  • Touch anywhere on the track: the nearer end jumps there and follows the finger. When the ends meet, the one in the direction of the drag moves.
  • minDistance keeps the ends apart (one step by default). Screen reader actions respect it too.
  • onValueChange fires on each step while dragging and onSlidingComplete once on release, so you can filter live or only when the finger lifts.
  • With reduced motion, values set from outside jump into place and the held end does not swell.

Props

RangeSliderProps

PropTypeDefaultDescription
valuerequiredRangeValue—
labelrequiredstring—Names the slider, e.g. "Price per night". Shown above the track.
onValueChange(value: [number, number]) => void—Called as either end changes while dragging.
onSlidingComplete(value: [number, number]) => void—Called once when the finger lifts.
minnumber0Defaults to 0.
maxnumber100Defaults to 100.
stepnumber1Defaults to 1.
minDistancenumber—The closest the two ends may come, in value units. Defaults to one step.
endLabelsreadonly [string, string]['Minimum', 'Maximum']Names the two ends for screen readers. Defaults to Minimum and Maximum.
formatValue(value: number) => string(v) => String(v)Formats values for the readout, the bubble and screen readers.
histogramreadonly number[]—Bars drawn behind the track, e.g. how many results fall in each price band.
hapticsbooleantrueHaptic ticks on each step when steps are coarse. Defaults to true.
disabledbooleanfalse
testIDstring—
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
Thumbs and fill move on the UI thread. The readout and histogram re-render once per step while dragging; 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 · 142 MB app memory

Real-device measurement pending.

Accessibility

Reduced motion
The ends jump to new values set from outside, and the thumbs do not swell; dragging still follows the finger.
Screen readers
Two adjustable elements ("Price, Minimum" and "Price, Maximum") with increment and decrement actions that never cross.
Touch target
The whole 44 pt track takes the drag and the nearer end follows. Each thumb's slop makes it 44 pt.

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/range-slider.tsx
import { useEffect, useState } from 'react';
import {
  StyleSheet,
  Text,
  View,
  type AccessibilityActionEvent,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useAnimatedStyle, useSharedValue, withSpring } 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 } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';

export type RangeValue = readonly [number, number];

export type RangeSliderProps = {
  value: RangeValue;
  /** Called as either end changes while dragging. */
  onValueChange?: (value: [number, number]) => void;
  /** Called once when the finger lifts. */
  onSlidingComplete?: (value: [number, number]) => void;
  /** Defaults to 0. */
  min?: number;
  /** Defaults to 100. */
  max?: number;
  /** Defaults to 1. */
  step?: number;
  /** The closest the two ends may come, in value units. Defaults to one step. */
  minDistance?: number;
  /** Names the slider, e.g. "Price per night". Shown above the track. */
  label: string;
  /** Names the two ends for screen readers. Defaults to Minimum and Maximum. */
  endLabels?: readonly [string, string];
  /** Formats values for the readout, the bubble and screen readers. */
  formatValue?: (value: number) => string;
  /** Bars drawn behind the track, e.g. how many results fall in each price band. */
  histogram?: readonly number[];
  /** Haptic ticks on each step when steps are coarse. Defaults to true. */
  haptics?: boolean;
  disabled?: boolean;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

const THUMB = 26;
const TRACK = 4;
const HISTOGRAM_H = 48;

/**
 * A slider with two ends for picking a range. The nearer end follows the
 * finger and swells while held, a bubble carries its value above it, and an
 * optional histogram behind the track lights up the bars inside the range.
 */
export function RangeSlider({
  value,
  onValueChange,
  onSlidingComplete,
  min = 0,
  max = 100,
  step = 1,
  minDistance,
  label,
  endLabels = ['Minimum', 'Maximum'],
  formatValue = (v) => String(v),
  histogram,
  haptics = true,
  disabled = false,
  testID,
  style,
}: RangeSliderProps) {
  const { colors, font, space, radius, shadow } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const range = max - min || 1;
  const gap = minDistance ?? step;
  const tickSteps = range / step <= 40;

  const width = useSharedValue(0);
  const lo = useSharedValue((value[0] - min) / range);
  const hi = useSharedValue((value[1] - min) / range);
  const active = useSharedValue(-1);
  const held = useSharedValue(0);
  const committed = useSharedValue<[number, number]>([value[0], value[1]]);
  const [live, setLive] = useState<[number, number] | null>(null);
  const [activeEnd, setActiveEnd] = useState(-1);
  const shown = live ?? value;

  useEffect(() => {
    if (active.get() !== -1) return;
    committed.set([value[0], value[1]]);
    const a = (value[0] - min) / range;
    const b = (value[1] - min) / range;
    lo.set(reduced ? a : withSpring(a, spring('smooth')));
    hi.set(reduced ? b : withSpring(b, spring('smooth')));
  }, [value, min, range, reduced, lo, hi, active, committed]);

  const emit = (next: [number, number]) => {
    setLive(next);
    onValueChange?.(next);
    if (tickSteps) haptic('tick');
  };
  const finish = (next: [number, number]) => {
    setLive(null);
    setActiveEnd(-1);
    onSlidingComplete?.(next);
  };

  const snap = (p: number) => {
    'worklet';
    const v = Math.round((min + p * range) / step) * step;
    return Number(Math.max(min, Math.min(max, v)).toFixed(6));
  };

  const moveTo = (x: number) => {
    'worklet';
    const w = width.get();
    if (w === 0 || active.get() < 0) return;
    const p = Math.max(0, Math.min(1, (x - THUMB / 2) / w));
    const [a, b] = committed.get();
    const end = active.get();
    // Each end stops short of the other by the minimum distance.
    const v = end === 0 ? Math.min(snap(p), b - gap) : Math.max(snap(p), a + gap);
    // The thumb stops where the value stops: at the other end, less the minimum distance.
    if (end === 0) lo.set(Math.max(0, Math.min(p, (b - gap - min) / range)));
    else hi.set(Math.min(1, Math.max(p, (a + gap - min) / range)));
    const next: [number, number] = end === 0 ? [v, b] : [a, v];
    if (next[0] !== a || next[1] !== b) {
      committed.set(next);
      scheduleOnRN(emit, next);
    }
  };

  const pan = Gesture.Pan()
    .withTestId('range-slider')
    .enabled(!disabled)
    .activeOffsetX([-3, 3])
    .failOffsetY([-14, 14])
    .onBegin((e) => {
      const w = width.get();
      const x = (e.x - THUMB / 2) / Math.max(1, w);
      // The nearer end takes the drag; when they touch, the one in the direction of travel does.
      const dLo = Math.abs(x - lo.get());
      const dHi = Math.abs(x - hi.get());
      const end = dLo < dHi || (dLo === dHi && x < lo.get()) ? 0 : 1;
      active.set(end);
      held.set(withSpring(1, spring('snappy', reduced)));
      scheduleOnRN(setActiveEnd, end);
    })
    .onUpdate((e) => moveTo(e.x))
    .onFinalize(() => {
      if (active.get() < 0) return;
      const [a, b] = committed.get();
      lo.set(withSpring((a - min) / range, spring('snappy', reduced)));
      hi.set(withSpring((b - min) / range, spring('snappy', reduced)));
      active.set(-1);
      held.set(withSpring(0, spring('smooth', reduced)));
      scheduleOnRN(finish, [a, b] as [number, number]);
    });

  const fill = useAnimatedStyle(() => ({
    left: THUMB / 2 + lo.get() * width.get(),
    width: Math.max(0, (hi.get() - lo.get()) * width.get()),
  }));
  const loThumb = useAnimatedStyle(() => ({
    transform: [
      { translateX: lo.get() * width.get() },
      { scale: 1 + (active.get() === 0 ? held.get() : 0) * 0.18 },
    ],
  }));
  const hiThumb = useAnimatedStyle(() => ({
    transform: [
      { translateX: hi.get() * width.get() },
      { scale: 1 + (active.get() === 1 ? held.get() : 0) * 0.18 },
    ],
  }));
  const bubble = useAnimatedStyle(() => {
    const end = active.get();
    const p = end === 1 ? hi.get() : lo.get();
    return {
      opacity: end < 0 ? 0 : held.get(),
      transform: [
        { translateX: THUMB / 2 + p * width.get() },
        { translateY: (1 - held.get()) * 8 },
        { scale: 0.7 + held.get() * 0.3 },
      ],
    };
  });

  const nudge = (end: 0 | 1) => (e: AccessibilityActionEvent) => {
    if (disabled) return;
    const delta = Math.max(step, range / 20) * (e.nativeEvent.actionName === 'increment' ? 1 : -1);
    const [a, b] = [value[0], value[1]];
    const next: [number, number] =
      end === 0
        ? [Math.max(min, Math.min(b - gap, snap((a + delta - min) / range))), b]
        : [a, Math.min(max, Math.max(a + gap, snap((b + delta - min) / range)))];
    onValueChange?.(next);
    onSlidingComplete?.(next);
  };

  const bars = histogram && histogram.length > 0 ? histogram : null;
  const peak = bars ? Math.max(1, ...bars) : 1;

  return (
    <View testID={testID} style={[{ gap: space[2], opacity: disabled ? 0.45 : 1 }, style]}>
      <View style={styles.header}>
        <Text
          style={{ color: colors.text, fontSize: font.size.sm, fontWeight: font.weight.medium }}
        >
          {label}
        </Text>
        <Text
          style={{ color: colors.textMuted, fontSize: font.size.sm, fontVariant: ['tabular-nums'] }}
        >
          {formatValue(shown[0])} – {formatValue(shown[1])}
        </Text>
      </View>

      {bars ? (
        <View style={[styles.histogram, { paddingHorizontal: THUMB / 2 }]} pointerEvents="none">
          {bars.map((n, i) => {
            const at = min + ((i + 0.5) / bars.length) * range;
            const inside = at >= shown[0] && at <= shown[1];
            return (
              <View
                key={i}
                style={[
                  styles.bar,
                  {
                    height: Math.max(3, (n / peak) * HISTOGRAM_H),
                    backgroundColor: inside ? withAlpha(colors.azure, 0.55) : colors.track,
                    borderTopLeftRadius: radius.xs / 2,
                    borderTopRightRadius: radius.xs / 2,
                  },
                ]}
              />
            );
          })}
        </View>
      ) : null}

      <GestureDetector gesture={pan}>
        <View
          collapsable={false}
          testID={testID ? `${testID}-track` : undefined}
          style={styles.hit}
          onLayout={(e) => width.set(Math.max(0, e.nativeEvent.layout.width - THUMB))}
        >
          <View
            style={[
              styles.track,
              {
                backgroundColor: colors.track,
                borderRadius: radius.pill,
                marginHorizontal: THUMB / 2,
              },
            ]}
          />
          <Animated.View
            style={[
              styles.fill,
              { backgroundColor: colors.azure, borderRadius: radius.pill },
              fill,
            ]}
          />
          {([0, 1] as const).map((end) => (
            <Animated.View
              key={end}
              accessible
              accessibilityRole="adjustable"
              accessibilityLabel={`${label}, ${endLabels[end]}`}
              accessibilityValue={{ min, max, now: shown[end], text: formatValue(shown[end]) }}
              accessibilityState={{ disabled }}
              accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
              onAccessibilityAction={nudge(end)}
              hitSlop={(minTouchTarget - THUMB) / 2}
              style={[
                styles.thumb,
                {
                  backgroundColor: colors.highlight,
                  borderColor: activeEnd === end ? colors.azure : colors.borderStrong,
                  boxShadow: shadow.sm,
                },
                end === 0 ? loThumb : hiThumb,
              ]}
            />
          ))}
          <Animated.View pointerEvents="none" style={[styles.bubbleBox, bubble]}>
            <View
              style={[styles.bubble, { backgroundColor: colors.ember, borderRadius: radius.sm }]}
            >
              <Text
                style={{
                  color: colors.onEmber,
                  fontSize: font.size.xs,
                  fontWeight: font.weight.semibold,
                  fontVariant: ['tabular-nums'],
                }}
              >
                {activeEnd >= 0 ? formatValue(shown[activeEnd]!) : ''}
              </Text>
            </View>
          </Animated.View>
        </View>
      </GestureDetector>
    </View>
  );
}

const styles = StyleSheet.create({
  header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline' },
  histogram: {
    height: HISTOGRAM_H,
    flexDirection: 'row',
    alignItems: 'flex-end',
    gap: 2,
    marginBottom: -minTouchTarget / 2 + TRACK / 2,
  },
  bar: { flex: 1 },
  hit: { height: minTouchTarget, justifyContent: 'center' },
  track: { height: TRACK },
  fill: { position: 'absolute', height: TRACK },
  thumb: {
    position: 'absolute',
    left: 0,
    width: THUMB,
    height: THUMB,
    borderRadius: THUMB / 2,
    borderWidth: 2,
  },
  bubbleBox: {
    position: 'absolute',
    left: 0,
    bottom: minTouchTarget - 4,
    width: 0,
    alignItems: 'center',
  },
  bubble: { paddingHorizontal: 8, paddingVertical: 4, minWidth: 40, alignItems: 'center' },
});