Veltrix

gestures

Friction Slider

A slider whose visual friction changes with its value and boundaries.

AppleiOSAndroidAndroidExpoExpo Go
reanimatedworkletsgesture handler
GitHubOpen in GitHub

Installation

Install the dependencies, then copy the files for your preferred renderer.

pnpm add react-native-reanimated react-native-worklets react-native-gesture-handler

Install tailwind variants

pnpm add tailwind-variants

Usage

FrictionSliderExample.tsx
import { FrictionSlider } from "@animations/ui/components/gestures/friction-slider/uniwind";export function FrictionSliderExample() {  return (    <FrictionSlider      className="w-full"      defaultValue={40}      label="Intensity"      maximumValue={100}      minimumValue={0}      onValueChange={(value) => console.log({ value })}      step={5}    />  );}

Component files

5 files

friction-slider/config.ts
export const FRICTION_SLIDER_CONFIG = {  boundaryResistance: 0.16,  defaultMaximumValue: 100,  defaultMinimumValue: 0,  defaultStep: 5,  defaultValue: 40,  spring: {    damping: 18,    mass: 0.7,    stiffness: 220,  },  thumbSize: 30,  velocityThreshold: 420,} as const;
friction-slider/types.ts
export type FrictionSliderBaseProps = {  accessibilityLabel?: string;  defaultValue?: number;  disabled?: boolean;  formatValue?: (value: number) => string;  label?: string;  maximumValue?: number;  minimumValue?: number;  onSlidingComplete?: (value: number) => void;  onValueChange?: (value: number) => void;  step?: number;  value?: number;};
friction-slider/uniwind/index.tsx
import { Text, View } from "react-native";import { GestureDetector } from "react-native-gesture-handler";import Animated from "react-native-reanimated";import type { SlotsToClasses } from "#shared/types/slots";import type { FrictionSliderBaseProps } from "../types";import { useFrictionSlider } from "../use-slider";import {  frictionSliderVariants,  type FrictionSliderSlot,} from "./variants";export type FrictionSliderProps = FrictionSliderBaseProps & {  className?: string;  classNames?: SlotsToClasses<FrictionSliderSlot>;};export function FrictionSlider({  accessibilityLabel,  className,  classNames,  defaultValue,  disabled,  formatValue,  label,  maximumValue,  minimumValue,  onSlidingComplete,  onValueChange,  step,  value,}: FrictionSliderProps) {  const {    accessibilityActions,    accessibilityValue,    displayValue,    fillAnimatedStyle,    gesture,    onAccessibilityAction,    onTrackLayout,    thumbAnimatedStyle,  } = useFrictionSlider({    defaultValue,    disabled,    formatValue,    maximumValue,    minimumValue,    onSlidingComplete,    onValueChange,    step,    value,  });  const {    boundaries,    boundaryLabel,    container,    fill,    header,    label: labelSlot,    thumb,    track,    trackArea,    value: valueSlot,  } = frictionSliderVariants({ disabled });  return (    <View      className={container({        className: [className, classNames?.container],      })}    >      <View className={header({ className: classNames?.header })}>        {label && (          <Text            className={labelSlot({              className: classNames?.label,            })}          >            {label}          </Text>        )}        <Text          className={valueSlot({            className: classNames?.value,          })}        >          {displayValue}        </Text>      </View>      <GestureDetector gesture={gesture}>        <View          accessibilityActions={accessibilityActions}          accessibilityLabel={accessibilityLabel ?? label}          accessibilityRole="adjustable"          accessibilityState={{ disabled }}          accessibilityValue={accessibilityValue}          accessible          className={trackArea({            className: classNames?.trackArea,          })}          onAccessibilityAction={onAccessibilityAction}          onLayout={onTrackLayout}        >          <View className={track({ className: classNames?.track })}>            <Animated.View              className={fill({ className: classNames?.fill })}              style={fillAnimatedStyle}            />          </View>          <Animated.View            className={thumb({ className: classNames?.thumb })}            style={thumbAnimatedStyle}          />        </View>      </GestureDetector>      <View        className={boundaries({          className: classNames?.boundaries,        })}      >        <Text          className={boundaryLabel({            className: classNames?.boundaryLabel,          })}        >          {accessibilityValue.min}        </Text>        <Text          className={boundaryLabel({            className: classNames?.boundaryLabel,          })}        >          {accessibilityValue.max}        </Text>      </View>    </View>  );}
friction-slider/uniwind/variants.ts
import { tv } from "tailwind-variants";export const frictionSliderSlots = {  boundaries: "flex-row justify-between",  boundaryLabel: "text-[11px] font-semibold text-[#8a8174]",  container: "w-full gap-2.5",  fill: "absolute inset-x-0 h-2 rounded-full bg-[#d97706]",  header: "flex-row items-center justify-between",  label: "text-sm font-bold text-[#272722]",  thumb:    "absolute left-0 top-1.75 size-7.5 rounded-full border-[3px] border-[#d97706] bg-white shadow-[0_4px_10px_rgba(87,55,14,0.24)]",  track: "h-2 overflow-hidden rounded-full bg-[#e8e1d5]",  trackArea: "h-11 justify-center",  value:    "text-lg font-extrabold text-[#9a5b08] [font-variant:tabular-nums]",} as const;export type FrictionSliderSlot = keyof typeof frictionSliderSlots;export const frictionSliderVariants = tv({  slots: frictionSliderSlots,  variants: {    disabled: {      true: {        container: "opacity-[0.45]",      },    },  },});
friction-slider/use-slider.ts
import {  useCallback,  useMemo,  useState,} from "react";import type {  AccessibilityActionEvent,  LayoutChangeEvent,} from "react-native";import { Gesture } from "react-native-gesture-handler";import {  Extrapolation,  interpolate,  runOnJS,  useAnimatedStyle,  useDerivedValue,  useReducedMotion,  useSharedValue,  withSpring,  withTiming,} from "react-native-reanimated";import { FRICTION_SLIDER_CONFIG } from "./config";import type { FrictionSliderBaseProps } from "./types";function clamp(value: number, minimum: number, maximum: number) {  "worklet";  return Math.min(maximum, Math.max(minimum, value));}function normalizeValue(  value: number,  minimumValue: number,  maximumValue: number,  step: number,) {  "worklet";  const clampedValue = clamp(value, minimumValue, maximumValue);  const stepIndex = Math.round((clampedValue - minimumValue) / step);  const steppedValue = minimumValue + stepIndex * step;  return Math.round(clamp(steppedValue, minimumValue, maximumValue) * 1e6) /    1e6;}function valueToProgress(  value: number,  minimumValue: number,  maximumValue: number,) {  "worklet";  return (value - minimumValue) / (maximumValue - minimumValue);}function progressToValue(  progress: number,  minimumValue: number,  maximumValue: number,  step: number,) {  "worklet";  return normalizeValue(    minimumValue +      clamp(progress, 0, 1) * (maximumValue - minimumValue),    minimumValue,    maximumValue,    step,  );}function applyBoundaryResistance(progress: number) {  "worklet";  if (progress < 0) {    return progress * FRICTION_SLIDER_CONFIG.boundaryResistance;  }  if (progress > 1) {    return 1 +      (progress - 1) * FRICTION_SLIDER_CONFIG.boundaryResistance;  }  return progress;}export function useFrictionSlider({  defaultValue = FRICTION_SLIDER_CONFIG.defaultValue,  disabled = false,  formatValue,  maximumValue = FRICTION_SLIDER_CONFIG.defaultMaximumValue,  minimumValue = FRICTION_SLIDER_CONFIG.defaultMinimumValue,  onSlidingComplete,  onValueChange,  step = FRICTION_SLIDER_CONFIG.defaultStep,  value,}: FrictionSliderBaseProps) {  if (maximumValue <= minimumValue) {    throw new Error(      "FrictionSlider requires maximumValue to be greater than minimumValue.",    );  }  if (step <= 0) {    throw new Error("FrictionSlider requires step to be greater than zero.");  }  const isControlled = value !== undefined;  const [internalValue, setInternalValue] = useState(() =>    normalizeValue(defaultValue, minimumValue, maximumValue, step),  );  const normalizedValue = normalizeValue(    value ?? internalValue,    minimumValue,    maximumValue,    step,  );  const initialProgress = valueToProgress(    normalizedValue,    minimumValue,    maximumValue,  );  const [previewedValue, setPreviewedValue] = useState<number>();  const displayValue = previewedValue ?? normalizedValue;  const progress = useSharedValue(initialProgress);  const dragStartProgress = useSharedValue(initialProgress);  const isSliding = useSharedValue(false);  const lastPreviewValue = useSharedValue(normalizedValue);  const trackWidth = useSharedValue(0);  const pressed = useSharedValue(0);  const reducedMotion = useReducedMotion();  const animatedProgress = useDerivedValue(() => {    if (isSliding.get()) {      return progress.get();    }    const nextProgress = valueToProgress(      normalizedValue,      minimumValue,      maximumValue,    );    return reducedMotion      ? nextProgress      : withSpring(nextProgress, FRICTION_SLIDER_CONFIG.spring);  });  const previewValue = useCallback(    (nextValue: number) => {      setPreviewedValue(nextValue);      onValueChange?.(nextValue);    },    [onValueChange],  );  const commitValue = useCallback(    (nextValue: number) => {      if (!isControlled) {        setInternalValue(nextValue);      }      setPreviewedValue(undefined);      if (nextValue !== lastPreviewValue.get()) {        lastPreviewValue.set(nextValue);        onValueChange?.(nextValue);      }      onSlidingComplete?.(nextValue);    },    [      isControlled,      lastPreviewValue,      onSlidingComplete,      onValueChange,    ],  );  const onTrackLayout = useCallback(    (event: LayoutChangeEvent) => {      trackWidth.set(event.nativeEvent.layout.width);    },    [trackWidth],  );  const gesture = useMemo(() => {    function settle(nextValue: number) {      "worklet";      const nextProgress = valueToProgress(        nextValue,        minimumValue,        maximumValue,      );      isSliding.set(true);      if (reducedMotion) {        progress.set(nextProgress);        isSliding.set(false);        return;      }      progress.set(        withSpring(          nextProgress,          FRICTION_SLIDER_CONFIG.spring,          (finished) => {            if (finished) {              isSliding.set(false);            }          },        ),      );    }    const tap = Gesture.Tap()      .enabled(!disabled)      .onBegin(() => {        pressed.set(withTiming(1, { duration: 80 }));      })      .onEnd((event, success) => {        const availableWidth =          trackWidth.get() - FRICTION_SLIDER_CONFIG.thumbSize;        if (!success || availableWidth <= 0) {          return;        }        const nextProgress = clamp(          (event.x - FRICTION_SLIDER_CONFIG.thumbSize / 2) /            availableWidth,          0,          1,        );        const nextValue = progressToValue(          nextProgress,          minimumValue,          maximumValue,          step,        );        lastPreviewValue.set(normalizedValue);        settle(nextValue);        runOnJS(commitValue)(nextValue);      })      .onFinalize(() => {        pressed.set(withTiming(0, { duration: 140 }));      });    const pan = Gesture.Pan()      .enabled(!disabled)      .minDistance(2)      .onStart(() => {        const currentProgress = animatedProgress.get();        progress.set(currentProgress);        dragStartProgress.set(currentProgress);        isSliding.set(true);        lastPreviewValue.set(normalizedValue);        pressed.set(withTiming(1, { duration: 80 }));      })      .onUpdate((event) => {        const availableWidth =          trackWidth.get() - FRICTION_SLIDER_CONFIG.thumbSize;        if (availableWidth <= 0) {          return;        }        const rawProgress =          dragStartProgress.get() + event.translationX / availableWidth;        const nextProgress = applyBoundaryResistance(rawProgress);        const nextValue = progressToValue(          nextProgress,          minimumValue,          maximumValue,          step,        );        progress.set(nextProgress);        if (nextValue !== lastPreviewValue.get()) {          lastPreviewValue.set(nextValue);          runOnJS(previewValue)(nextValue);        }      })      .onEnd((event) => {        const availableWidth =          trackWidth.get() - FRICTION_SLIDER_CONFIG.thumbSize;        if (availableWidth <= 0) {          return;        }        const projectedProgress =          progress.get() +          (Math.abs(event.velocityX) >          FRICTION_SLIDER_CONFIG.velocityThreshold            ? event.velocityX / availableWidth / 8            : 0);        const nextValue = progressToValue(          projectedProgress,          minimumValue,          maximumValue,          step,        );        settle(nextValue);        runOnJS(commitValue)(nextValue);      })      .onFinalize((_event, success) => {        if (!success) {          isSliding.set(false);        }        pressed.set(withTiming(0, { duration: 140 }));      });    return Gesture.Race(pan, tap);  }, [    animatedProgress,    commitValue,    disabled,    dragStartProgress,    isSliding,    lastPreviewValue,    maximumValue,    minimumValue,    normalizedValue,    pressed,    previewValue,    progress,    reducedMotion,    step,    trackWidth,  ]);  const onAccessibilityAction = useCallback(    (event: AccessibilityActionEvent) => {      if (disabled) {        return;      }      const { actionName } = event.nativeEvent;      if (actionName !== "increment" && actionName !== "decrement") {        return;      }      const direction = actionName === "increment" ? 1 : -1;      const nextValue = normalizeValue(        normalizedValue + direction * step,        minimumValue,        maximumValue,        step,      );      const nextProgress = valueToProgress(        nextValue,        minimumValue,        maximumValue,      );      progress.set(        reducedMotion          ? nextProgress          : withSpring(nextProgress, FRICTION_SLIDER_CONFIG.spring),      );      commitValue(nextValue);    },    [      commitValue,      disabled,      maximumValue,      minimumValue,      normalizedValue,      progress,      reducedMotion,      step,    ],  );  const thumbAnimatedStyle = useAnimatedStyle(() => {    const availableWidth =      trackWidth.get() - FRICTION_SLIDER_CONFIG.thumbSize;    const currentProgress = animatedProgress.get();    const clampedProgress = clamp(currentProgress, 0, 1);    const edgeDistance = Math.min(      clampedProgress,      1 - clampedProgress,    );    const dragScaleX = interpolate(      edgeDistance,      [0, 0.5],      [0.76, 0.94],      Extrapolation.CLAMP,    );    return {      transform: [        { translateX: currentProgress * Math.max(0, availableWidth) },        {          scaleX: interpolate(            pressed.get(),            [0, 1],            [1, dragScaleX],          ),        },        {          scaleY: interpolate(pressed.get(), [0, 1], [1, 1.08]),        },      ],    };  });  const fillAnimatedStyle = useAnimatedStyle(() => {    const clampedProgress = clamp(animatedProgress.get(), 0, 1);    const width = trackWidth.get();    return {      transform: [        { translateX: -((1 - clampedProgress) * width) / 2 },        { scaleX: clampedProgress },      ],    };  });  const formattedValue =    formatValue?.(displayValue) ?? String(displayValue);  return {    accessibilityActions: [      { name: "increment" as const },      { name: "decrement" as const },    ],    accessibilityValue: {      max: maximumValue,      min: minimumValue,      now: displayValue,      text: formattedValue,    },    displayValue: formattedValue,    fillAnimatedStyle,    gesture,    onAccessibilityAction,    onTrackLayout,    thumbAnimatedStyle,  };}

API reference

Generated directly from the exported component props.

FrictionSlider