# 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.

- Category: Inputs
- Version: 1.0.0
- Platforms: iOS, Android, web
- Dependencies: `react-native-gesture-handler`, `react-native-reanimated`, `react-native-worklets`
- Requires: React Native New Architecture (Reanimated 4)

## Install

```bash
npx kinetik-ui add intensity-slider
```

With the shadcn CLI:

```bash
npx shadcn@latest add https://kinetik-ui.dev/r/intensity-slider.json
```

## Usage

```tsx
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.
- `color` sets the fill; the handle uses the theme azure.

## IntensitySliderProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `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>` |  |  |

## 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.

## Performance

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.

## Source

`components/kinetik/intensity-slider.tsx`

```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 },
});
```
