gestures
Friction Slider
A slider whose visual friction changes with its value and boundaries.
iOSAndroidExpo Go
reanimatedworkletsgesture handler
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add react-native-reanimated react-native-worklets react-native-gesture-handlerInstall tailwind variants
pnpm add tailwind-variantsUsage
FrictionSliderExample.tsximport { 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} /> );}friction-slider/config.tsexport 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.tsexport 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.tsximport { 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.tsimport { 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.tsimport { 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, };}