Elastic Slider

An elastic slider with smooth drag interactions

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated react-native-gesture-handler react-native-worklets

Copy and paste the following code into your project. component/micro-interactions/elastic-slider.tsx

// @ts-checkimport React, { memo, useCallback, useEffect } from "react";import {  View,  Text,  StyleSheet,  type LayoutChangeEvent,  type ViewStyle,} from "react-native";import { Gesture, GestureDetector } from "react-native-gesture-handler";import Animated, {  useSharedValue,  useAnimatedStyle,  withSpring,  interpolate,  Extrapolation,} from "react-native-reanimated";import { decay } from "./helper";import type { IElasticSlider, Region } from "./types";import { SNAPBACK_SPRING, SPRING_CONFIG } from "./const";import { scheduleOnRN } from "react-native-worklets";const ElasticSlider: React.FC<IElasticSlider> &  React.FunctionComponent<IElasticSlider> = memo<IElasticSlider>(  ({    defaultValue = 50,    startingValue = 0,    maxValue = 100,    isStepped = false,    stepSize = 1,    renderLeadingAccessory,    renderTrailingAccessory,    onValueChange,    onDragStart,    onDragEnd,    trackColor = "#9CA3AF",    fillColor = "#6B7280",    style,  }: IElasticSlider):    | (React.ReactNode & React.JSX.Element & React.ReactElement)    | null => {    const sliderWidth = useSharedValue<number>(0);    const value = useSharedValue<number>(defaultValue);    const overflow = useSharedValue<number>(0);    const region = useSharedValue<Region>("middle");    const scale = useSharedValue<number>(1);    useEffect(() => {      value.value = defaultValue;    }, [defaultValue]);    const updateValue = useCallback(      (val: number) => {        onValueChange?.(Math.round(val));      },      [onValueChange],    );    const handleDragStart = useCallback(() => {      onDragStart?.();    }, [onDragStart]);    const handleDragEnd = useCallback(      (finalValue: number) => {        onDragEnd?.(Math.round(finalValue));      },      [onDragEnd],    );    const onLayout = <T extends LayoutChangeEvent>(event: T) => {      sliderWidth.value = event.nativeEvent.layout.width;    };    const panGesture = Gesture.Pan()      .minDistance(1)      .onStart(() => {        "worklet";        scale.value = withSpring(1.2, SPRING_CONFIG);        scheduleOnRN(handleDragStart);      })      .onUpdate((event) => {        "worklet";        const x = event.x;        const width = sliderWidth.value;        let newValue = startingValue + (x / width) * (maxValue - startingValue);        if (isStepped) {          newValue = Math.round(newValue / stepSize) * stepSize;        }        newValue = Math.min(Math.max(newValue, startingValue), maxValue);        value.value = newValue;        if (x < 0) {          region.value = "left";          overflow.value = decay(-x);        } else if (x > width) {          region.value = "right";          overflow.value = decay(x - width);        } else {          region.value = "middle";          overflow.value = 0;        }        scheduleOnRN(updateValue, newValue);      })      .onEnd(() => {        "worklet";        overflow.value = withSpring<number>(0, SNAPBACK_SPRING);        scale.value = withSpring<number>(1, SPRING_CONFIG);        region.value = "middle";        value.value = withSpring<number>(defaultValue, SNAPBACK_SPRING);        scheduleOnRN(handleDragEnd, value.value);        scheduleOnRN(updateValue, defaultValue);      });    const gesture = panGesture;    const containerStyle = useAnimatedStyle<      Pick<ViewStyle, "transform" | "opacity">    >(() => ({      transform: [{ scale: scale.value }],      opacity: interpolate(scale.value, [1, 1.2], [0.7, 1]),    }));    const leftIconStyle = useAnimatedStyle<Pick<ViewStyle, "transform">>(() => {      const isLeft = region.value === "left";      return {        transform: [          { translateX: isLeft ? -overflow.value / scale.value : 0 },          {            scale: withSpring(isLeft ? 1.4 : 1, SPRING_CONFIG),          },        ],      };    });    const rightIconStyle = useAnimatedStyle<Pick<ViewStyle, "transform">>(      () => {        const isRight = region.value === "right";        return {          transform: [            { translateX: isRight ? overflow.value / scale.value : 0 },            {              scale: withSpring(isRight ? 1.4 : 1, SPRING_CONFIG),            },          ],        };      },    );    const trackStyle = useAnimatedStyle<      Pick<ViewStyle, "transform" | "height">    >(() => {      const width = sliderWidth.value || 1;      const scaleX = 1 + overflow.value / width;      const scaleY = interpolate(        overflow.value,        [0, 80],        [1, 0.8],        Extrapolation.CLAMP,      );      const height = interpolate(scale.value, [1, 1.2], [6, 12]);      const expansion = width * (scaleX - 1);      let translateX = 0;      if (region.value === "left") {        translateX = -expansion / 4;      } else if (region.value === "right") {        translateX = expansion / 4;      }      return {        transform: [{ translateX }, { scaleX }, { scaleY }],        height,      };    });    const fillStyle = useAnimatedStyle<Pick<ViewStyle, "width">>(() => {      const totalRange = maxValue - startingValue;      const percentage =        totalRange === 0          ? 0          : ((value.value - startingValue) / totalRange) * 100;      return {        width: `${percentage}%`,      };    });    return (      <View style={[styles.wrapper, style]}>        <Animated.View style={[styles.container, containerStyle]}>          <Animated.View style={[styles.iconContainer, leftIconStyle]}>            {renderLeadingAccessory?.() ?? (              <Text style={styles.iconText}>−</Text>            )}          </Animated.View>          <GestureDetector gesture={gesture}>            <View style={styles.sliderContainer} onLayout={onLayout}>              <Animated.View style={[styles.track, trackStyle]}>                <View style={[styles.trackBg, { backgroundColor: trackColor }]}>                  <Animated.View                    style={[                      styles.trackFill,                      { backgroundColor: fillColor },                      fillStyle,                    ]}                  />                </View>              </Animated.View>            </View>          </GestureDetector>          <Animated.View style={[styles.iconContainer, rightIconStyle]}>            {renderTrailingAccessory?.() ?? (              <Text style={styles.iconText}>+</Text>            )}          </Animated.View>        </Animated.View>      </View>    );  },);const styles = StyleSheet.create({  wrapper: {    alignItems: "center",    justifyContent: "center",    gap: 16,    width: 192,  },  container: {    flexDirection: "row",    alignItems: "center",    justifyContent: "center",    gap: 16,    width: "100%",  },  iconContainer: {    alignItems: "center",    justifyContent: "center",  },  iconText: {    fontSize: 18,    color: "#6B7280",    fontWeight: "500",  },  sliderContainer: {    flex: 1,    height: 44,    justifyContent: "center",  },  track: {    width: "100%",    borderRadius: 100,    overflow: "hidden",  },  trackBg: {    width: "100%",    height: "100%",    borderRadius: 100,    overflow: "hidden",  },  trackFill: {    height: "100%",    borderRadius: 100,  },  valueText: {    fontSize: 12,    color: "#9CA3AF",    fontWeight: "500",    letterSpacing: 0.5,  },});export default memo<  React.FC<IElasticSlider> & React.FunctionComponent<IElasticSlider>>(ElasticSlider);

Usage

import { View, StyleSheet, Text, Image, Dimensions } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";import { Pressable } from "@/components/atoms/pressable";import { useCallback, useEffect, useState } from "react";import { Button } from "@/components/base/button";import { CircularLoader } from "@/components/molecules/Loaders/circular";import DynamicText from "@/components/molecules/dynamic-text";import ElasticSlider from "@/components/micro-interactions/elastic-slider";export default function App() {  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),  });  const [val, setVal] = useState<number>(34);  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="light" />      <ElasticSlider        defaultValue={val}        style={{          width: Dimensions.get("window").width - 220,        }}        onValueChange={setVal}        renderLeadingAccessory={() => (          <>            <Ionicons name="volume-low" size={24} color="#fff" />          </>        )}        renderTrailingAccessory={() => (          <>            <Ionicons name="volume-high" size={24} color="#fff" />          </>        )}        fillColor="#fff"      />    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#000",    alignItems: "center",    paddingTop: 110,  },  btn: {    flexDirection: "row",    alignItems: "center",    gap: 10,    backgroundColor: "#fff",    paddingVertical: 16,    paddingHorizontal: 32,    borderRadius: 16,  },  btnText: {    fontSize: 17,    fontWeight: "600",    color: "#000",  },});

Props

React Native Reanimated
React Native Worklets
React Native Gesture Handler