Elastic Slider
A slider that stretches like rubber past its ends and springs back, with a value bubble.
import { useEffect, useState, type ReactNode } 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 } from '../../lib/kinetik/tokens/tokens';
export type ElasticSliderProps = {
value: number;
/** Called as the stepped value changes while dragging. */
onValueChange?: (value: number) => void;
/** Called once when the finger lifts. */
onSlidingComplete?: (value: number) => void;
/** Defaults to 0. */
min?: number;
/** Defaults to 1. */
max?: number;
/** Snap interval. Defaults to 1/100 of the range. Detents tick when `haptics` is on. */
step?: number;
/** Accessible name. Shown above the track when `showLabel` is true. */
label: string;
/** Show the label and current value above the track. Defaults to true. */
showLabel?: boolean;
/** Formats the value for the label, bubble and screen readers. */
formatValue?: (value: number) => string;
/** Icon before the track, e.g. a quiet speaker. Nudged outward when the track is pulled past its start. */
leading?: ReactNode;
/** Icon after the track, e.g. a loud speaker. Nudged outward when the track is pulled past its end. */
trailing?: ReactNode;
/** Fill colour. Defaults to the theme ember. */
color?: string;
disabled?: boolean;
/** Ticks on each step (when steps are coarse) and at the ends. Defaults to true. */
haptics?: boolean;
style?: StyleProp<ViewStyle>;
};
/** Track thickness at rest and while held. */
const TRACK = 8;
const TRACK_ACTIVE = 16;
/** Rubber-band resistance: the further past the end, the harder it pulls back. */
function rubber(distance: number, dimension: number) {
'worklet';
const d = Math.abs(distance);
const resisted = (d * dimension * 0.55) / (dimension + d * 0.55);
return Math.sign(distance) * resisted;
}
const decimalsOf = (n: number) => {
const s = String(n);
return s.includes('.') ? s.split('.')[1]!.length : 0;
};
/**
* A slider in the style of the iOS media controls: a slim track that swells
* while held and fills from the left, with no thumb to aim for. Pulling past
* either end stretches it like rubber, nudges the icon on that side, and it
* springs back on release.
*/
export function ElasticSlider({
value,
onValueChange,
onSlidingComplete,
min = 0,
max = 1,
step,
label,
showLabel = true,
formatValue,
leading,
trailing,
color,
disabled = false,
haptics = true,
style,
}: ElasticSliderProps) {
const { colors, font, space, radius } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const fill = color ?? colors.ember;
const range = max - min || 1;
const stepSize = step ?? range / 100;
const decimals = Math.min(6, decimalsOf(stepSize));
// Only tick on detents that are coarse enough to feel; continuous sliders tick at the ends.
const tickSteps = range / stepSize <= 40;
const format = formatValue ?? ((v: number) => v.toFixed(decimals));
const width = useSharedValue(0);
const progress = useSharedValue((value - min) / range);
const stretch = useSharedValue(0);
const active = useSharedValue(0);
const committed = useSharedValue(value);
const dragging = useSharedValue(false);
// Value under the finger while dragging; otherwise the controlled `value` is shown.
const [dragValue, setDragValue] = useState<number | null>(null);
const bubble = dragValue ?? value;
// Follow external value changes when the user is not dragging.
useEffect(() => {
if (dragging.get()) return;
const p = (Math.min(max, Math.max(min, value)) - min) / range;
progress.set(reduced ? p : withSpring(p, spring('smooth')));
committed.set(value);
}, [value, min, max, range, reduced, progress, committed, dragging]);
const emit = (v: number, edge: boolean) => {
setDragValue(v);
onValueChange?.(v);
if (edge || tickSteps) haptic('tick');
};
const complete = (v: number) => {
setDragValue(null);
onSlidingComplete?.(v);
};
const moveTo = (x: number) => {
'worklet';
const w = width.get();
if (w === 0) return;
const raw = x / w;
const clamped = Math.max(0, Math.min(1, raw));
progress.set(clamped);
// Past the ends, stretch with resistance instead of moving the thumb.
const over = (raw - clamped) * w;
stretch.set(reduced ? 0 : rubber(over, w * 0.35));
const stepped = Math.round((min + clamped * range) / stepSize) * stepSize;
const v = Math.max(min, Math.min(max, Number(stepped.toFixed(decimals))));
if (v !== committed.get()) {
committed.set(v);
scheduleOnRN(emit, v, v === min || v === max);
}
};
const release = () => {
'worklet';
dragging.set(false);
active.set(withSpring(0, spring('smooth', reduced)));
stretch.set(withSpring(0, spring('bouncy', reduced)));
// Settle the thumb exactly on the committed step.
progress.set(withSpring((committed.get() - min) / range, spring('snappy', reduced)));
scheduleOnRN(complete, committed.get());
};
const pan = Gesture.Pan()
.withTestId('elastic-slider')
.enabled(!disabled)
.activeOffsetX([-4, 4])
.failOffsetY([-14, 14])
.onBegin(() => {
dragging.set(true);
active.set(withSpring(1, spring('snappy', reduced)));
})
.onStart((e) => moveTo(e.x))
.onUpdate((e) => moveTo(e.x))
.onFinalize(() => release());
const tap = Gesture.Tap()
.withTestId('elastic-slider-tap')
.enabled(!disabled)
.onEnd((e) => {
moveTo(e.x);
release();
});
const trackStyle = useAnimatedStyle(() => {
const w = width.get();
const st = stretch.get();
const grow = w === 0 ? 1 : (w + Math.abs(st)) / w;
return {
height: TRACK + active.get() * (TRACK_ACTIVE - TRACK),
// Stretched rubber gets longer and a little thinner.
transform: [
{ translateX: st / 2 },
{ scaleX: grow },
{ scaleY: 1 - Math.min(0.35, (grow - 1) * 1.6) },
],
};
});
const fillStyle = useAnimatedStyle(() => ({ width: `${progress.get() * 100}%` }));
// The whole control leans in a touch while held.
const rowStyle = useAnimatedStyle(() => ({ transform: [{ scale: 1 + active.get() * 0.02 }] }));
const leadingStyle = useAnimatedStyle(() => {
const st = Math.min(0, stretch.get());
return {
opacity: 0.6 + active.get() * 0.4,
transform: [{ translateX: st * 0.6 }, { scale: 1 + Math.min(0.25, -st / 160) }],
};
});
const trailingStyle = useAnimatedStyle(() => {
const st = Math.max(0, stretch.get());
return {
opacity: 0.6 + active.get() * 0.4,
transform: [{ translateX: st * 0.6 }, { scale: 1 + Math.min(0.25, st / 160) }],
};
});
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (disabled) return;
const delta = Math.max(stepSize, range / 20);
const next =
e.nativeEvent.actionName === 'increment' ? committed.get() + delta : committed.get() - delta;
const v = Number(
(Math.round(Math.max(min, Math.min(max, next)) / stepSize) * stepSize).toFixed(decimals),
);
committed.set(v);
progress.set(withSpring((v - min) / range, spring('snappy', reduced)));
onValueChange?.(v);
onSlidingComplete?.(v);
};
return (
<View style={[{ gap: space[2], opacity: disabled ? 0.45 : 1 }, style]}>
{showLabel && (
<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'],
}}
>
{format(bubble)}
</Text>
</View>
)}
<Animated.View style={[styles.row, { gap: space[3] }, rowStyle]}>
{leading ? (
<Animated.View style={[styles.icon, leadingStyle]} pointerEvents="none">
{leading}
</Animated.View>
) : null}
<GestureDetector gesture={Gesture.Race(pan, tap)}>
<View
collapsable={false}
accessible
accessibilityRole="adjustable"
accessibilityLabel={label}
accessibilityValue={{ min, max, now: bubble, text: format(bubble) }}
accessibilityState={{ disabled }}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={onAccessibilityAction}
onLayout={(e) => width.set(e.nativeEvent.layout.width)}
style={[styles.hit, { height: minTouchTarget }]}
>
<Animated.View
style={[
styles.track,
{ backgroundColor: colors.track, borderRadius: radius.pill },
trackStyle,
]}
>
<Animated.View style={[styles.fill, { backgroundColor: fill }, fillStyle]} />
</Animated.View>
</View>
</GestureDetector>
{trailing ? (
<Animated.View style={[styles.icon, trailingStyle]} pointerEvents="none">
{trailing}
</Animated.View>
) : null}
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline' },
row: { flexDirection: 'row', alignItems: 'center' },
icon: { minWidth: 20, alignItems: 'center', justifyContent: 'center' },
hit: { flex: 1, justifyContent: 'center' },
track: { overflow: 'hidden' },
fill: { height: '100%' },
});Installation
npx kinetik-ui add elastic-sliderpnpm dlx kinetik-ui add elastic-sliderbunx kinetik-ui add elastic-slideryarn dlx kinetik-ui add elastic-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/elastic-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/elastic-slider.tsx
Usage
import { ElasticSlider } from '@/components/kinetik/elastic-slider';
const [volume, setVolume] = useState(64);
<ElasticSlider
label="Volume"
value={volume}
onValueChange={setVolume}
min={0}
max={100}
step={1}
formatValue={(v) => `${v}%`}
/>;The slider is controlled: keep value in state and update it from onValueChange. Use onSlidingComplete for work that should happen once, such as saving.
When to use
- Continuous settings where the exact number matters less than the feel: volume, brightness, intensity.
- Ranges with a few meaningful steps; with 40 steps or fewer each detent ticks under the finger.
When not to use
- Precise numeric entry. Pair it with a text field or use a stepper.
- Ranges in a horizontally scrolling container; both want the horizontal drag.
Notes
- Pulling past either end stretches the track with rubber-band resistance and it springs back on release.
- A tap on the track jumps to that point.
- Vertical drags are left to the parent scroll view.
Props
ElasticSliderProps
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | number | — | |
labelrequired | string | — | Accessible name. Shown above the track when `showLabel` is true. |
onValueChange | (value: number) => void | — | Called as the stepped value changes while dragging. |
onSlidingComplete | (value: number) => void | — | Called once when the finger lifts. |
min | number | 0 | Defaults to 0. |
max | number | 1 | Defaults to 1. |
step | number | — | Snap interval. Defaults to 1/100 of the range. Detents tick when `haptics` is on. |
showLabel | boolean | true | Show the label and current value above the track. Defaults to true. |
formatValue | (value: number) => string | — | Formats the value for the label, bubble and screen readers. |
leading | ReactNode | — | Icon before the track, e.g. a quiet speaker. Nudged outward when the track is pulled past its start. |
trailing | ReactNode | — | Icon after the track, e.g. a loud speaker. Nudged outward when the track is pulled past its end. |
color | string | — | Fill colour. Defaults to the theme ember. |
disabled | boolean | false | |
haptics | boolean | true | Ticks on each step (when steps are coarse) and at the ends. Defaults to true. |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- Dragging updates shared values on the UI thread; React re-renders only when the stepped value changes.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.06× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 151 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- No rubber-band stretch; the thumb still follows the finger and the value bubble appears without travel.
- Screen readers
- Adjustable role with min, max, current value and formatted text; increment and decrement move by one step (at least 5% of the range).
- Touch target
- At least 44 pt tall across the full 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/elastic-slider.tsx
import { useEffect, useState, type ReactNode } 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 } from '../../lib/kinetik/tokens/tokens';
export type ElasticSliderProps = {
value: number;
/** Called as the stepped value changes while dragging. */
onValueChange?: (value: number) => void;
/** Called once when the finger lifts. */
onSlidingComplete?: (value: number) => void;
/** Defaults to 0. */
min?: number;
/** Defaults to 1. */
max?: number;
/** Snap interval. Defaults to 1/100 of the range. Detents tick when `haptics` is on. */
step?: number;
/** Accessible name. Shown above the track when `showLabel` is true. */
label: string;
/** Show the label and current value above the track. Defaults to true. */
showLabel?: boolean;
/** Formats the value for the label, bubble and screen readers. */
formatValue?: (value: number) => string;
/** Icon before the track, e.g. a quiet speaker. Nudged outward when the track is pulled past its start. */
leading?: ReactNode;
/** Icon after the track, e.g. a loud speaker. Nudged outward when the track is pulled past its end. */
trailing?: ReactNode;
/** Fill colour. Defaults to the theme ember. */
color?: string;
disabled?: boolean;
/** Ticks on each step (when steps are coarse) and at the ends. Defaults to true. */
haptics?: boolean;
style?: StyleProp<ViewStyle>;
};
/** Track thickness at rest and while held. */
const TRACK = 8;
const TRACK_ACTIVE = 16;
/** Rubber-band resistance: the further past the end, the harder it pulls back. */
function rubber(distance: number, dimension: number) {
'worklet';
const d = Math.abs(distance);
const resisted = (d * dimension * 0.55) / (dimension + d * 0.55);
return Math.sign(distance) * resisted;
}
const decimalsOf = (n: number) => {
const s = String(n);
return s.includes('.') ? s.split('.')[1]!.length : 0;
};
/**
* A slider in the style of the iOS media controls: a slim track that swells
* while held and fills from the left, with no thumb to aim for. Pulling past
* either end stretches it like rubber, nudges the icon on that side, and it
* springs back on release.
*/
export function ElasticSlider({
value,
onValueChange,
onSlidingComplete,
min = 0,
max = 1,
step,
label,
showLabel = true,
formatValue,
leading,
trailing,
color,
disabled = false,
haptics = true,
style,
}: ElasticSliderProps) {
const { colors, font, space, radius } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const fill = color ?? colors.ember;
const range = max - min || 1;
const stepSize = step ?? range / 100;
const decimals = Math.min(6, decimalsOf(stepSize));
// Only tick on detents that are coarse enough to feel; continuous sliders tick at the ends.
const tickSteps = range / stepSize <= 40;
const format = formatValue ?? ((v: number) => v.toFixed(decimals));
const width = useSharedValue(0);
const progress = useSharedValue((value - min) / range);
const stretch = useSharedValue(0);
const active = useSharedValue(0);
const committed = useSharedValue(value);
const dragging = useSharedValue(false);
// Value under the finger while dragging; otherwise the controlled `value` is shown.
const [dragValue, setDragValue] = useState<number | null>(null);
const bubble = dragValue ?? value;
// Follow external value changes when the user is not dragging.
useEffect(() => {
if (dragging.get()) return;
const p = (Math.min(max, Math.max(min, value)) - min) / range;
progress.set(reduced ? p : withSpring(p, spring('smooth')));
committed.set(value);
}, [value, min, max, range, reduced, progress, committed, dragging]);
const emit = (v: number, edge: boolean) => {
setDragValue(v);
onValueChange?.(v);
if (edge || tickSteps) haptic('tick');
};
const complete = (v: number) => {
setDragValue(null);
onSlidingComplete?.(v);
};
const moveTo = (x: number) => {
'worklet';
const w = width.get();
if (w === 0) return;
const raw = x / w;
const clamped = Math.max(0, Math.min(1, raw));
progress.set(clamped);
// Past the ends, stretch with resistance instead of moving the thumb.
const over = (raw - clamped) * w;
stretch.set(reduced ? 0 : rubber(over, w * 0.35));
const stepped = Math.round((min + clamped * range) / stepSize) * stepSize;
const v = Math.max(min, Math.min(max, Number(stepped.toFixed(decimals))));
if (v !== committed.get()) {
committed.set(v);
scheduleOnRN(emit, v, v === min || v === max);
}
};
const release = () => {
'worklet';
dragging.set(false);
active.set(withSpring(0, spring('smooth', reduced)));
stretch.set(withSpring(0, spring('bouncy', reduced)));
// Settle the thumb exactly on the committed step.
progress.set(withSpring((committed.get() - min) / range, spring('snappy', reduced)));
scheduleOnRN(complete, committed.get());
};
const pan = Gesture.Pan()
.withTestId('elastic-slider')
.enabled(!disabled)
.activeOffsetX([-4, 4])
.failOffsetY([-14, 14])
.onBegin(() => {
dragging.set(true);
active.set(withSpring(1, spring('snappy', reduced)));
})
.onStart((e) => moveTo(e.x))
.onUpdate((e) => moveTo(e.x))
.onFinalize(() => release());
const tap = Gesture.Tap()
.withTestId('elastic-slider-tap')
.enabled(!disabled)
.onEnd((e) => {
moveTo(e.x);
release();
});
const trackStyle = useAnimatedStyle(() => {
const w = width.get();
const st = stretch.get();
const grow = w === 0 ? 1 : (w + Math.abs(st)) / w;
return {
height: TRACK + active.get() * (TRACK_ACTIVE - TRACK),
// Stretched rubber gets longer and a little thinner.
transform: [
{ translateX: st / 2 },
{ scaleX: grow },
{ scaleY: 1 - Math.min(0.35, (grow - 1) * 1.6) },
],
};
});
const fillStyle = useAnimatedStyle(() => ({ width: `${progress.get() * 100}%` }));
// The whole control leans in a touch while held.
const rowStyle = useAnimatedStyle(() => ({ transform: [{ scale: 1 + active.get() * 0.02 }] }));
const leadingStyle = useAnimatedStyle(() => {
const st = Math.min(0, stretch.get());
return {
opacity: 0.6 + active.get() * 0.4,
transform: [{ translateX: st * 0.6 }, { scale: 1 + Math.min(0.25, -st / 160) }],
};
});
const trailingStyle = useAnimatedStyle(() => {
const st = Math.max(0, stretch.get());
return {
opacity: 0.6 + active.get() * 0.4,
transform: [{ translateX: st * 0.6 }, { scale: 1 + Math.min(0.25, st / 160) }],
};
});
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (disabled) return;
const delta = Math.max(stepSize, range / 20);
const next =
e.nativeEvent.actionName === 'increment' ? committed.get() + delta : committed.get() - delta;
const v = Number(
(Math.round(Math.max(min, Math.min(max, next)) / stepSize) * stepSize).toFixed(decimals),
);
committed.set(v);
progress.set(withSpring((v - min) / range, spring('snappy', reduced)));
onValueChange?.(v);
onSlidingComplete?.(v);
};
return (
<View style={[{ gap: space[2], opacity: disabled ? 0.45 : 1 }, style]}>
{showLabel && (
<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'],
}}
>
{format(bubble)}
</Text>
</View>
)}
<Animated.View style={[styles.row, { gap: space[3] }, rowStyle]}>
{leading ? (
<Animated.View style={[styles.icon, leadingStyle]} pointerEvents="none">
{leading}
</Animated.View>
) : null}
<GestureDetector gesture={Gesture.Race(pan, tap)}>
<View
collapsable={false}
accessible
accessibilityRole="adjustable"
accessibilityLabel={label}
accessibilityValue={{ min, max, now: bubble, text: format(bubble) }}
accessibilityState={{ disabled }}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={onAccessibilityAction}
onLayout={(e) => width.set(e.nativeEvent.layout.width)}
style={[styles.hit, { height: minTouchTarget }]}
>
<Animated.View
style={[
styles.track,
{ backgroundColor: colors.track, borderRadius: radius.pill },
trackStyle,
]}
>
<Animated.View style={[styles.fill, { backgroundColor: fill }, fillStyle]} />
</Animated.View>
</View>
</GestureDetector>
{trailing ? (
<Animated.View style={[styles.icon, trailingStyle]} pointerEvents="none">
{trailing}
</Animated.View>
) : null}
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline' },
row: { flexDirection: 'row', alignItems: 'center' },
icon: { minWidth: 20, alignItems: 'center', justifyContent: 'center' },
hit: { flex: 1, justifyContent: 'center' },
track: { overflow: 'hidden' },
fill: { height: '100%' },
});