Intensity Slider
A soft gradient slider in a card with a slim handle and a Low-to-High ruler that lights up to the value.
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 { gradient } from '../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';
export type IntensitySliderProps = {
/** 0–100. */
value: number;
/** Called on each whole step while dragging. */
onValueChange?: (value: number) => void;
onSlidingComplete?: (value: number) => void;
/** Heading above the track. Defaults to "Intensity". */
label?: string;
/** Words under the ends of the track. Default to "Low" and "High". */
lowLabel?: string;
highLabel?: string;
/** Number of tick marks between the end labels. Defaults to 24. */
ticks?: number;
/** Fill colour. Defaults to the theme sky blue. */
color?: string;
disabled?: boolean;
/** A tick each time the fill crosses a mark. Defaults to true. */
haptics?: boolean;
style?: StyleProp<ViewStyle>;
};
const TRACK = 22;
/**
* A wide, soft slider in a card: a pale gradient fills the track up to a
* slim handle, the handle grows while held, and the ruler underneath lights
* up to the value, ticking as the fill passes each mark.
*/
export function IntensitySlider({
value,
onValueChange,
onSlidingComplete,
label = 'Intensity',
lowLabel = 'Low',
highLabel = 'High',
ticks = 24,
color,
disabled = false,
haptics = true,
style,
}: IntensitySliderProps) {
const { colors, radius, space, font, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const fillColor = color ?? colors.sky;
const width = useSharedValue(0);
const progress = useSharedValue(value / 100);
const held = useSharedValue(0);
const committed = useSharedValue(Math.round(value));
const dragging = useSharedValue(false);
const [shown, setShown] = useState<number | null>(null);
const now = shown ?? Math.round(value);
useEffect(() => {
if (dragging.get()) return;
const p = Math.max(0, Math.min(100, value)) / 100;
progress.set(reduced ? p : withSpring(p, spring('smooth')));
committed.set(Math.round(value));
}, [value, reduced, progress, committed, dragging]);
const emit = (v: number, tick: boolean) => {
setShown(v);
onValueChange?.(v);
if (tick) haptic('tick');
};
const complete = (v: number) => {
setShown(null);
onSlidingComplete?.(v);
};
const moveTo = (x: number) => {
'worklet';
const w = width.get();
if (w <= 0) return;
const p = Math.max(0, Math.min(1, x / w));
progress.set(p);
const v = Math.round(p * 100);
const before = committed.get();
if (v !== before) {
committed.set(v);
// Tick when the value crosses a ruler mark.
const mark = (n: number) => Math.floor((n / 100) * (ticks + 1));
scheduleOnRN(emit, v, mark(v) !== mark(before));
}
};
const release = () => {
'worklet';
dragging.set(false);
held.set(withSpring(0, spring('smooth', reduced)));
scheduleOnRN(complete, committed.get());
};
const pan = Gesture.Pan()
.withTestId('intensity-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('intensity-slider-tap')
.enabled(!disabled)
.onEnd((e) => {
moveTo(e.x);
release();
});
const fill = useAnimatedStyle(() => ({
width: Math.max(TRACK / 2, progress.get() * width.get()),
}));
const handle = useAnimatedStyle(() => ({
transform: [
{ translateX: Math.max(6, progress.get() * width.get()) - 8 },
{ scaleY: 1 + held.get() * 0.35 },
{ scaleX: 1 + held.get() * 0.5 },
],
}));
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (disabled) return;
const v = Math.max(
0,
Math.min(100, committed.get() + (e.nativeEvent.actionName === 'increment' ? 5 : -5)),
);
committed.set(v);
progress.set(withSpring(v / 100, spring('snappy', reduced)));
onValueChange?.(v);
onSlidingComplete?.(v);
};
const lit = Math.round((now / 100) * (ticks - 1));
return (
<View
style={[
styles.card,
{
backgroundColor: colors.surfaceRaised,
borderColor: colors.border,
borderRadius: radius.lg,
padding: space[4],
gap: space[3],
boxShadow: shadow.md,
opacity: disabled ? 0.45 : 1,
},
style,
]}
>
<Text style={{ color: colors.text, fontSize: font.size.md, fontWeight: font.weight.medium }}>
{label}
</Text>
<GestureDetector gesture={Gesture.Race(pan, tap)}>
<View
collapsable={false}
accessible
accessibilityRole="adjustable"
accessibilityLabel={label}
accessibilityValue={{ min: 0, max: 100, now, text: `${now}%` }}
accessibilityState={{ disabled }}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={onAccessibilityAction}
onLayout={(e) => width.set(e.nativeEvent.layout.width)}
style={styles.hit}
>
<View
style={[
styles.track,
{ backgroundColor: colors.surfaceSunken, borderRadius: radius.xs },
]}
>
<Animated.View
style={[
styles.fill,
{ borderRadius: radius.xs },
gradient(
`linear-gradient(90deg, ${withAlpha(fillColor, 0.35)} 0%, ${withAlpha(fillColor, 0.85)} 100%)`,
),
fill,
]}
/>
</View>
<Animated.View
pointerEvents="none"
style={[styles.handle, { backgroundColor: colors.azure }, handle]}
/>
</View>
</GestureDetector>
<View style={[styles.ruler, { gap: space[2] }]}>
<Text style={{ color: colors.textMuted, fontSize: font.size.sm }}>{lowLabel}</Text>
<View style={styles.ticks}>
{Array.from({ length: ticks }, (_, i) => (
<View
key={i}
style={[
styles.tick,
{ backgroundColor: i <= lit && now > 0 ? colors.textMuted : colors.track },
]}
/>
))}
</View>
<Text style={{ color: colors.textMuted, fontSize: font.size.sm }}>{highLabel}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
card: { borderWidth: StyleSheet.hairlineWidth },
hit: { height: minTouchTarget, justifyContent: 'center' },
track: { height: TRACK, overflow: 'hidden' },
fill: { height: '100%' },
handle: { position: 'absolute', left: 0, width: 4, height: 16, borderRadius: 2 },
ruler: { flexDirection: 'row', alignItems: 'center' },
ticks: { flex: 1, flexDirection: 'row', justifyContent: 'space-between' },
tick: { width: 1.5, height: 10, borderRadius: 1 },
});Installation
npx kinetik-ui add intensity-sliderpnpm dlx kinetik-ui add intensity-sliderbunx kinetik-ui add intensity-slideryarn dlx kinetik-ui add intensity-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/intensity-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/intensity-slider.tsx
Usage
import { IntensitySlider } from '@/components/kinetik/intensity-slider';
<IntensitySlider value={strength} onValueChange={setStrength} label="Blur" />;When to use
- A single 0–100 strength in an editor: effect intensity, blur, opacity, noise.
- Inside a popover opened from a toolbar value, as in the demo.
When not to use
- Ranges that are not percentages: label the ends and format the value yourself, or use another slider.
Notes
- The ruler lights up to the value and a haptic ticks each time the fill crosses a mark.
colorsets the fill; the handle uses the theme azure.
Props
IntensitySliderProps
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | number | — | 0–100. |
onValueChange | (value: number) => void | — | Called on each whole step while dragging. |
onSlidingComplete | (value: number) => void | — | |
label | string | 'Intensity' | Heading above the track. Defaults to "Intensity". |
lowLabel | string | 'Low' | Words under the ends of the track. Default to "Low" and "High". |
highLabel | string | 'High' | |
ticks | number | 24 | Number of tick marks between the end labels. Defaults to 24. |
color | string | — | Fill colour. Defaults to the theme sky blue. |
disabled | boolean | false | |
haptics | boolean | true | A tick each time the fill crosses a mark. Defaults to true. |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- Dragging moves the handle and fill on the UI thread; React re-renders once per whole step to light the ruler.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.24× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 158 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- The handle does not grow while held and the fill settles without a spring.
- Screen readers
- Adjustable role reading the value as a percentage; increment and decrement move by 5.
- Touch target
- The track is 44 pt tall across the full width.
Source files
components/kinetik/intensity-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 { gradient } from '../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';
export type IntensitySliderProps = {
/** 0–100. */
value: number;
/** Called on each whole step while dragging. */
onValueChange?: (value: number) => void;
onSlidingComplete?: (value: number) => void;
/** Heading above the track. Defaults to "Intensity". */
label?: string;
/** Words under the ends of the track. Default to "Low" and "High". */
lowLabel?: string;
highLabel?: string;
/** Number of tick marks between the end labels. Defaults to 24. */
ticks?: number;
/** Fill colour. Defaults to the theme sky blue. */
color?: string;
disabled?: boolean;
/** A tick each time the fill crosses a mark. Defaults to true. */
haptics?: boolean;
style?: StyleProp<ViewStyle>;
};
const TRACK = 22;
/**
* A wide, soft slider in a card: a pale gradient fills the track up to a
* slim handle, the handle grows while held, and the ruler underneath lights
* up to the value, ticking as the fill passes each mark.
*/
export function IntensitySlider({
value,
onValueChange,
onSlidingComplete,
label = 'Intensity',
lowLabel = 'Low',
highLabel = 'High',
ticks = 24,
color,
disabled = false,
haptics = true,
style,
}: IntensitySliderProps) {
const { colors, radius, space, font, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const fillColor = color ?? colors.sky;
const width = useSharedValue(0);
const progress = useSharedValue(value / 100);
const held = useSharedValue(0);
const committed = useSharedValue(Math.round(value));
const dragging = useSharedValue(false);
const [shown, setShown] = useState<number | null>(null);
const now = shown ?? Math.round(value);
useEffect(() => {
if (dragging.get()) return;
const p = Math.max(0, Math.min(100, value)) / 100;
progress.set(reduced ? p : withSpring(p, spring('smooth')));
committed.set(Math.round(value));
}, [value, reduced, progress, committed, dragging]);
const emit = (v: number, tick: boolean) => {
setShown(v);
onValueChange?.(v);
if (tick) haptic('tick');
};
const complete = (v: number) => {
setShown(null);
onSlidingComplete?.(v);
};
const moveTo = (x: number) => {
'worklet';
const w = width.get();
if (w <= 0) return;
const p = Math.max(0, Math.min(1, x / w));
progress.set(p);
const v = Math.round(p * 100);
const before = committed.get();
if (v !== before) {
committed.set(v);
// Tick when the value crosses a ruler mark.
const mark = (n: number) => Math.floor((n / 100) * (ticks + 1));
scheduleOnRN(emit, v, mark(v) !== mark(before));
}
};
const release = () => {
'worklet';
dragging.set(false);
held.set(withSpring(0, spring('smooth', reduced)));
scheduleOnRN(complete, committed.get());
};
const pan = Gesture.Pan()
.withTestId('intensity-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('intensity-slider-tap')
.enabled(!disabled)
.onEnd((e) => {
moveTo(e.x);
release();
});
const fill = useAnimatedStyle(() => ({
width: Math.max(TRACK / 2, progress.get() * width.get()),
}));
const handle = useAnimatedStyle(() => ({
transform: [
{ translateX: Math.max(6, progress.get() * width.get()) - 8 },
{ scaleY: 1 + held.get() * 0.35 },
{ scaleX: 1 + held.get() * 0.5 },
],
}));
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (disabled) return;
const v = Math.max(
0,
Math.min(100, committed.get() + (e.nativeEvent.actionName === 'increment' ? 5 : -5)),
);
committed.set(v);
progress.set(withSpring(v / 100, spring('snappy', reduced)));
onValueChange?.(v);
onSlidingComplete?.(v);
};
const lit = Math.round((now / 100) * (ticks - 1));
return (
<View
style={[
styles.card,
{
backgroundColor: colors.surfaceRaised,
borderColor: colors.border,
borderRadius: radius.lg,
padding: space[4],
gap: space[3],
boxShadow: shadow.md,
opacity: disabled ? 0.45 : 1,
},
style,
]}
>
<Text style={{ color: colors.text, fontSize: font.size.md, fontWeight: font.weight.medium }}>
{label}
</Text>
<GestureDetector gesture={Gesture.Race(pan, tap)}>
<View
collapsable={false}
accessible
accessibilityRole="adjustable"
accessibilityLabel={label}
accessibilityValue={{ min: 0, max: 100, now, text: `${now}%` }}
accessibilityState={{ disabled }}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={onAccessibilityAction}
onLayout={(e) => width.set(e.nativeEvent.layout.width)}
style={styles.hit}
>
<View
style={[
styles.track,
{ backgroundColor: colors.surfaceSunken, borderRadius: radius.xs },
]}
>
<Animated.View
style={[
styles.fill,
{ borderRadius: radius.xs },
gradient(
`linear-gradient(90deg, ${withAlpha(fillColor, 0.35)} 0%, ${withAlpha(fillColor, 0.85)} 100%)`,
),
fill,
]}
/>
</View>
<Animated.View
pointerEvents="none"
style={[styles.handle, { backgroundColor: colors.azure }, handle]}
/>
</View>
</GestureDetector>
<View style={[styles.ruler, { gap: space[2] }]}>
<Text style={{ color: colors.textMuted, fontSize: font.size.sm }}>{lowLabel}</Text>
<View style={styles.ticks}>
{Array.from({ length: ticks }, (_, i) => (
<View
key={i}
style={[
styles.tick,
{ backgroundColor: i <= lit && now > 0 ? colors.textMuted : colors.track },
]}
/>
))}
</View>
<Text style={{ color: colors.textMuted, fontSize: font.size.sm }}>{highLabel}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
card: { borderWidth: StyleSheet.hairlineWidth },
hit: { height: minTouchTarget, justifyContent: 'center' },
track: { height: TRACK, overflow: 'hidden' },
fill: { height: '100%' },
handle: { position: 'absolute', left: 0, width: 4, height: 16, borderRadius: 2 },
ruler: { flexDirection: 'row', alignItems: 'center' },
ticks: { flex: 1, flexDirection: 'row', justifyContent: 'space-between' },
tick: { width: 1.5, height: 10, borderRadius: 1 },
});