Ruler

A smooth draggable ruler with animated ticks

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated expo-haptics react-native-gesture-handler react-native-worklets @shopify/react-native-skia

Copy and paste the following code into your project. component/base/ruler.tsx

import React, { memo, useCallback, useMemo } from "react";import { Canvas, Group, Line } from "@shopify/react-native-skia";import { Gesture, GestureDetector } from "react-native-gesture-handler";import {  useAnimatedReaction,  useDerivedValue,  useSharedValue,  withClamp,  withDecay,  withSpring,  interpolate,  Extrapolation,  withTiming,} from "react-native-reanimated";import { Platform } from "react-native";import {  AndroidHaptics,  impactAsync,  ImpactFeedbackStyle,  performAndroidHapticsAsync,} from "expo-haptics";import {  SPRING_CONFIG,  SPRING_CONFIG_RESPONSIVE,  SPRING_CONFIG_SOFT,} from "./conf";// @ts-checkimport type { IRuler, ITick } from "./types";import { scheduleOnRN } from "react-native-worklets";const Tick = ({  index,  tickX,  xCenter,  yCenter,  translateX,  mountAnimation,  tickHeight,  isLarge,  tickColor,  activeTickColor,  step,}: ITick) => {  const distanceFromCenter = useDerivedValue(() => {    return Math.abs(tickX + translateX.value - xCenter);  });  const proximity = useDerivedValue(() => {    const activeZone = step * 0.6;    return interpolate(      distanceFromCenter.value,      [0, activeZone, step],      [1, 0.3, 0],      Extrapolation.CLAMP,    );  });  const baseOpacity = useDerivedValue(() => {    return interpolate(      distanceFromCenter.value,      [0, 50, 150, 300],      [1, 0.9, 0.6, 0.3],      Extrapolation.CLAMP,    );  });  const tickOpacity = useDerivedValue(() => {    const proximityBoost = proximity.value * 0.15;    return (      Math.min(1, baseOpacity.value + proximityBoost) * mountAnimation.value    );  });  const tickScale = useDerivedValue(() => {    const baseScale = interpolate(      distanceFromCenter.value,      [0, 100, 200],      [1, 0.96, 0.88],      Extrapolation.CLAMP,    );    const popScale = interpolate(      proximity.value,      [0, 0.5, 1],      [1, 1.04, 1.1],      Extrapolation.CLAMP,    );    return baseScale * popScale;  });  const animatedTickHeight = useDerivedValue(() => {    const extraHeight = interpolate(      proximity.value,      [0, 1],      [0, 12],      Extrapolation.CLAMP,    );    return (tickHeight + extraHeight) * tickScale.value;  });  const tickY1 = useDerivedValue(() => {    const baseLift = interpolate(      distanceFromCenter.value,      [0, 50],      [14, 15],      Extrapolation.CLAMP,    );    const proximityLift = interpolate(      proximity.value,      [0, 1],      [0, -5],      Extrapolation.CLAMP,    );    return yCenter + baseLift + proximityLift;  });  const tickY2 = useDerivedValue<number>(() => {    return tickY1.value + animatedTickHeight.value;  });  const strokeWidth = useDerivedValue<number>(() => {    const baseWidth = isLarge ? 2.5 : 1.5;    const extraWidth = interpolate(      proximity.value,      [0, 1],      [0, 1.2],      Extrapolation.CLAMP,    );    return baseWidth + extraWidth;  });  const tickColorAnimated = useDerivedValue<string>(() => {    return proximity.value > 0.4 ? activeTickColor : tickColor;  });  return (    <Line      p1={useDerivedValue(() => ({ x: tickX, y: tickY1.value }))}      p2={useDerivedValue(() => ({ x: tickX, y: tickY2.value }))}      color={tickColorAnimated}      strokeWidth={strokeWidth}      opacity={tickOpacity}    />  );};export const Ruler: React.FC<IRuler> & React.FunctionComponent<IRuler> =  memo<IRuler>(    ({      height,      width,      minValue,      maxValue,      step,      onScroll,      onValueChange,      labelInterval = 10,      tickColor = "rgba(255, 255, 255, 0.6)",      activeTickColor = "#00D4FF",      cursorColor = "#00D4FF",      backgroundColor = "transparent",      showCursor = true,      tickHeights = { small: 30, medium: 38, large: 45 },      enableHaptics = false,      animateOnMount = true,    }: IRuler) => {      const xCenter = width / 2;      const yCenter = height / 2;      const translateX = useSharedValue<number>(animateOnMount ? -width : 0);      const active = useSharedValue<boolean>(false);      const mountAnimation = useSharedValue<number>(0);      const lastHapticValue = useSharedValue<number>(0);      const numbers = useMemo<number[]>(() => {        const length = maxValue - minValue + 1;        return Array.from({ length }, (_, i) => minValue + i);      }, [minValue, maxValue]);      const triggerHaptic = useCallback<() => void>(() => {        if (enableHaptics) {          if (Platform.OS === "ios") {            impactAsync(ImpactFeedbackStyle.Light);          } else {            performAndroidHapticsAsync(AndroidHaptics.Segment_Tick);          }        }      }, [enableHaptics]);      const currentValue = useDerivedValue(() => {        const index = Math.round(-translateX.value / step);        return Math.max(minValue, Math.min(maxValue, minValue + index));      });      useAnimatedReaction(        () => currentValue.value,        (value, previous) => {          if (previous !== null && value !== previous && active.value) {            if (enableHaptics) {              scheduleOnRN(triggerHaptic);            }            lastHapticValue.value = value;          }          if (onValueChange) {            scheduleOnRN(onValueChange, value);          }        },        [onValueChange, enableHaptics],      );      useAnimatedReaction(        () => translateX.value,        (value) => {          if (onScroll) {            scheduleOnRN(onScroll, value);          }        },        [onScroll],      );      React.useEffect(() => {        if (animateOnMount) {          translateX.value = withSpring<number>(0, SPRING_CONFIG_SOFT);        }        mountAnimation.value = withTiming<number>(1, { duration: 600 });      }, []);      const pan = Gesture.Pan()        .onChange((e) => {          active.value = true;          const _translateX = -(translateX.value + e.changeX);          const maxTranslate = (numbers.length - 1) * step;          if (_translateX < 0) {            const resistance = 0.3;            translateX.value = -_translateX * resistance;          } else if (_translateX > maxTranslate) {            const overshoot = _translateX - maxTranslate;            const resistance = 0.3;            translateX.value = -(maxTranslate + overshoot * resistance);          } else {            translateX.value = -_translateX;          }        })        .onFinalize((e) => {          const maxTranslate = (numbers.length - 1) * step;          if (translateX.value > 0) {            translateX.value = withSpring<number>(0, SPRING_CONFIG);            active.value = false;          } else if (translateX.value < -maxTranslate) {            translateX.value = withSpring<number>(-maxTranslate, SPRING_CONFIG);            active.value = false;          } else {            translateX.value = withClamp<number>(              {                min: -maxTranslate,                max: 0,              },              withDecay(                {                  velocity: e.velocityX,                  clamp: [-maxTranslate, 0],                  deceleration: 0.997,                },                (finish) => {                  if (finish) {                    translateX.value = withSpring<number>(                      Math.round(translateX.value / step) * step,                      SPRING_CONFIG_RESPONSIVE,                    );                    active.value = false;                  }                },              ),            );          }        });      const transform = useDerivedValue(() => {        return [{ translateX: translateX.value }];      });      const cursorScale = useDerivedValue(() => {        return withSpring(active.value ? 1.15 : 1, {          damping: 15,          stiffness: 200,          mass: 0.5,        });      });      const cursorY1 = useDerivedValue(() => {        const baseY = yCenter - 10;        const lift = interpolate(          cursorScale.value,          [1, 1.15],          [0, -3],          Extrapolation.CLAMP,        );        return baseY + lift;      });      const cursorY2 = useDerivedValue<number>(() => {        return yCenter + 5;      });      const ticksData = useMemo(() => {        return numbers.map((number, index) => {          const tickX = index * step + xCenter;          const isLarge = index % labelInterval === 0;          const isMedium = number % 5 === 0;          const tickHeight = isLarge            ? tickHeights.large            : isMedium              ? tickHeights.medium              : tickHeights.small;          return {            index,            tickX,            isLarge,            isMedium,            tickHeight,          };        });      }, [numbers, step, xCenter, labelInterval, tickHeights]);      const cursorP1 = useDerivedValue(() => ({        x: xCenter,        y: cursorY1.value,      }));      const cursorP2 = useDerivedValue(() => ({        x: xCenter,        y: cursorY2.value,      }));      const cursorLeftP1 = useDerivedValue(() => ({        x: xCenter - 6,        y: cursorY1.value,      }));      const cursorLeftP2 = useDerivedValue(() => ({        x: xCenter,        y: cursorY1.value - 8,      }));      const cursorRightP1 = useDerivedValue(() => ({        x: xCenter + 6,        y: cursorY1.value,      }));      const cursorRightP2 = useDerivedValue(() => ({        x: xCenter,        y: cursorY1.value - 8,      }));      return (        <GestureDetector gesture={pan}>          <Canvas style={{ width, height, backgroundColor }}>            {showCursor && (              <Group>                <Line                  p1={cursorP1}                  p2={cursorP2}                  color={cursorColor}                  strokeWidth={3}                />                <Line                  p1={cursorLeftP1}                  p2={cursorLeftP2}                  color={cursorColor}                  strokeWidth={3}                />                <Line                  p1={cursorRightP1}                  p2={cursorRightP2}                  color={cursorColor}                  strokeWidth={3}                />              </Group>            )}            <Group transform={transform}>              {ticksData.map((tick) => (                <Tick                  key={tick.index}                  index={tick.index}                  tickX={tick.tickX}                  xCenter={xCenter}                  yCenter={yCenter}                  translateX={translateX}                  mountAnimation={mountAnimation}                  tickHeight={tick.tickHeight}                  isLarge={tick.isLarge}                  tickColor={tickColor}                  activeTickColor={activeTickColor}                  step={step}                />              ))}            </Group>          </Canvas>        </GestureDetector>      );    },  );export default memo<React.FC<IRuler>>(Ruler);

Usage

import { View, Text, StyleSheet } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { useState } from "react";import Ruler from "@/components/base/ruler";export default function App() {  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),  });  const [value, setValue] = useState(18);  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="light" />      <View style={{ marginTop: 100 }}>        <View style={styles.content}>          <Text            style={[styles.label, fontLoaded && { fontFamily: "SfProRounded" }]}          >            Weight          </Text>          <View style={styles.valueRow}>            <Text              style={[                styles.value,                fontLoaded && { fontFamily: "HelveticaNowDisplay" },              ]}            >              {value}            </Text>            <Text              style={[                styles.unit,                fontLoaded && { fontFamily: "SfProRounded" },              ]}            >              kg            </Text>          </View>        </View>        <Ruler          height={120}          width={380}          minValue={5}          maxValue={50}          step={18}          tickColor="rgba(255,255,255,0.3)"          activeTickColor="#fff"          cursorColor="#fff"          showCursor          enableHaptics          tickHeights={{ small: 20, medium: 28, large: 40 }}          onValueChange={setValue}        />      </View>    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#0a0a0a",    alignItems: "center",    gap: 40,  },  content: {    alignItems: "center",    gap: 9,  },  label: {    fontSize: 15,    color: "rgba(255,255,255,0.5)",    letterSpacing: 1,    textTransform: "uppercase",  },  valueRow: {    flexDirection: "row",    alignItems: "baseline",    gap: 6,  },  value: {    fontSize: 72,    color: "#fff",    fontWeight: "600",  },  unit: {    fontSize: 24,    color: "rgba(255,255,255,0.5)",  },});

Props

ITick

React Native Reanimated
React Native Worklets
React Native Gesture Handler
React Native SKia
Expo Haptics