Seekbar

An interactive seek bar with drag and tap control

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

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

Copy and paste the following code into your project. component/molecules/seek-bar

import React, { useEffect } from "react";import { View, StyleSheet, ViewStyle } from "react-native";import Animated, {  useSharedValue,  useAnimatedStyle,  withTiming,  withSpring,  Easing,} from "react-native-reanimated";import { Gesture, GestureDetector } from "react-native-gesture-handler";import type { SeekBarProps } from "./SeekBar.types";import { scheduleOnRN } from "react-native-worklets";import { impactAsync, ImpactFeedbackStyle } from "expo-haptics";export const SeekBar: React.FC<SeekBarProps> &  React.FunctionComponent<SeekBarProps> = ({  value,  onValueChange,  width = 300,  height = 8,  activeHeight = 10,  activeColor = "#FFFFFF",  inactiveColor = "rgba(255, 255, 255, 0.3)",  disabled = false,  tapToSeek = true,  thumbSize = 35,  thumbColor = "#FFFFFF",  trackScale: _trackScale,  thumbScale: _thumbScale,  containerScale: _containerScale,  showThumb = true,}: SeekBarProps):  | (React.ReactNode & React.JSX.Element & React.ReactElement)  | null => {  const initialValue = Math.max(0, Math.min(1, value));  const progress = useSharedValue<number>(initialValue);  const isActive = useSharedValue<boolean>(false);  const trackHeight = useSharedValue<number>(height);  const thumbScale = useSharedValue<number>(1);  const containerScaleX = useSharedValue<number>(1);  const customEasing = Easing.bezier(0.25, 0.1, 0.25, 1);  useEffect(() => {    const clampedValue = Math.max(0, Math.min(1, value));    if (!isActive.value) {      progress.value = withTiming(clampedValue, {        duration: 300,        easing: customEasing,      });    }  }, [value]);  const panGesture = Gesture.Pan()    .enabled(!disabled)    .onStart((event) => {      scheduleOnRN(impactAsync, ImpactFeedbackStyle.Rigid);      isActive.value = true;      const newProgress = Math.max(0, Math.min(1, event.x / width));      progress.value = newProgress;      scheduleOnRN(onValueChange, newProgress);      trackHeight.value = withSpring(        _trackScale ? height * _trackScale : activeHeight,        {},      );      thumbScale.value = withSpring(_thumbScale ?? 1.3, {});      containerScaleX.value = withSpring(_containerScale ?? 1.05, {});    })    .onUpdate((event) => {      const newProgress = Math.max(0, Math.min(1, event.x / width));      progress.value = newProgress;    })    .onChange((event) => {      const newProgress = Math.max(0, Math.min(1, event.x / width));      scheduleOnRN(onValueChange, newProgress);    })    .onEnd(() => {      isActive.value = false;      trackHeight.value = withSpring(height, {});      thumbScale.value = withSpring(1, {});      containerScaleX.value = withSpring(1, {});      scheduleOnRN(impactAsync, ImpactFeedbackStyle.Rigid);    });  const tapGesture = Gesture.Tap()    .enabled(!disabled && tapToSeek)    .onStart((event) => {      const newProgress = Math.max(0, Math.min(1, event.x / width));      progress.value = withSpring(newProgress, {});      scheduleOnRN(onValueChange, newProgress);    });  const composedGesture = Gesture.Race(panGesture, tapGesture);  const containerAnimatedStyle = useAnimatedStyle<    Pick<ViewStyle, "transform" | "height">  >(() => {    return {      height: trackHeight.value,      transform: [{ scaleX: containerScaleX.value }],    };  });  const trackAnimatedStyle = useAnimatedStyle<Pick<ViewStyle, "height">>(() => {    return {      height: trackHeight.value,    };  });  const activeTrackAnimatedStyle = useAnimatedStyle<    Pick<ViewStyle, "width" | "height">  >(() => {    const progressWidth = Math.max(0, Math.min(width, progress.value * width));    return {      width: progressWidth,      height: trackHeight.value,    };  });  const thumbAnimatedStyle = useAnimatedStyle<    Pick<ViewStyle, "transform" | "opacity">  >(() => {    const progressWidth = progress.value * width;    const clampedPosition = Math.max(0, Math.min(width, progressWidth));    return {      transform: [        { translateX: clampedPosition - thumbSize / 2 },        { scale: thumbScale.value },      ],      opacity: showThumb ? 1 : 0,    };  });  return (    <View      style={[        styles.wrapper,        { width, height: Math.max(activeHeight, thumbSize) },      ]}    >      <GestureDetector gesture={composedGesture}>        <Animated.View style={styles.gestureContainer}>          <Animated.View            style={[              styles.container,              {                width,                height,              },              containerAnimatedStyle,            ]}          >            <Animated.View              style={[                styles.track,                {                  width,                  backgroundColor: inactiveColor,                },                trackAnimatedStyle,              ]}            />            <Animated.View              style={[                styles.activeTrack,                {                  backgroundColor: activeColor,                },                activeTrackAnimatedStyle,              ]}            />          </Animated.View>          {showThumb && (            <Animated.View              style={[                styles.thumb,                {                  width: thumbSize,                  height: thumbSize,                  backgroundColor: thumbColor,                  borderRadius: thumbSize / 2,                },                thumbAnimatedStyle,              ]}            />          )}        </Animated.View>      </GestureDetector>    </View>  );};const styles = StyleSheet.create({  wrapper: {    justifyContent: "center",    alignItems: "center",  },  gestureContainer: {    width: "100%",    height: "100%",    justifyContent: "center",    alignItems: "flex-start",  },  container: {    justifyContent: "center",    alignItems: "flex-start",    overflow: "hidden",    borderRadius: 2000,  },  track: {    borderRadius: 2000,  },  activeTrack: {    borderRadius: 2000,    position: "absolute",  },  thumb: {    position: "absolute",    shadowColor: "#000",    shadowOffset: {      width: 0,      height: 2,    },    shadowOpacity: 0.25,    shadowRadius: 3.84,    elevation: 5,  },});export default SeekBar;

Usage

import { View, StyleSheet, Dimensions } 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 { SeekBar } from "@/components";export default function App() {  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),    StretchPro: require("@/assets/fonts/StretchPro.otf"),  });  const [currentIndex, setCurrentIndex] = useState<number>(0);  const DATA = [    {      id: "1",      name: "MY DEAR MELANCHOLY",      artist: "The Weeknd",      year: "2018",      image:        "https://i.pinimg.com/1200x/18/e6/e8/18e6e8e2d2b8c5b4dd77a4ae705bf96a.jpg",    },    {      id: "2",      name: "RANDOM ACCESS MEMORIES",      artist: "Daft Punk",      year: "2013",      image:        "https://i.pinimg.com/1200x/91/52/b2/9152b2dc174934279cda4509b0931434.jpg",    },    {      id: "3",      name: "CURRENTS",      artist: "Tame Impala",      year: "2015",      image:        "https://i.pinimg.com/1200x/1e/38/7f/1e387f131098067f7a9be0bc68b0b6f2.jpg",    },    {      id: "4",      name: "PLASTIC BEACH",      artist: "Gorillaz",      year: "2010",      image:        "https://i.pinimg.com/736x/43/e0/e0/43e0e0a542c0ccfbc5cf1b802bcf2d66.jpg",    },  ];  const ITEMS: string[] = [    "https://i.pinimg.com/1200x/5a/ad/c6/5aadc6ef06807b24de9d0ea236c28978.jpg",    "https://i.pinimg.com/736x/ea/9f/97/ea9f9778de29809187e40b6b12f3ca28.jpg",    "https://i.pinimg.com/736x/c7/ad/93/c7ad937da5f3796492a4ce378db61700.jpg",    "https://i.pinimg.com/736x/d8/af/cc/d8afcc936c977bab53cec723a2f2fc1c.jpg",  ];  const [value, setValue] = useState<number>(0);  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="inverted" />      <View style={styles.header}>        <SeekBar          value={value}          onValueChange={setValue}          showThumb={false}          trackScale={2}          activeHeight={50}        />      </View>    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#0a0a0a",  },  header: {    flexDirection: "row",    justifyContent: "center",    alignItems: "center",    paddingTop: 120,  },  title: {    fontSize: 35,    color: "#fff",    letterSpacing: 2,  },  subtitle: {    fontSize: 15,    color: "#aaa",  },  headerRight: {    width: 40,    height: 40,    borderRadius: 20,    backgroundColor: "#1a1a1a",    justifyContent: "center",    alignItems: "center",  },  card: {    width: "100%",    height: 340,    borderRadius: 24,    overflow: "hidden",  },  cardImage: {    width: 300,    height: 400,    resizeMode: "cover",  },  cardGradient: {    ...StyleSheet.absoluteFillObject,  },  cardContent: {    position: "absolute",    bottom: 0,    left: 0,    right: 0,    padding: 20,    gap: 8,  },  albumName: {    fontSize: 16,    color: "#fff",    letterSpacing: 1,  },  artistRow: {    flexDirection: "row",    alignItems: "center",    gap: 6,  },  artistText: {    fontSize: 13,    color: "rgba(255,255,255,0.7)",  },  dot: {    width: 3,    height: 3,    borderRadius: 1.5,    backgroundColor: "rgba(255,255,255,0.4)",  },  yearText: {    fontSize: 13,    color: "rgba(255,255,255,0.5)",  },  footer: {    paddingHorizontal: 24,    marginTop: 32,    gap: 20,  },  nowPlaying: {    flexDirection: "row",    alignItems: "center",    justifyContent: "space-between",    backgroundColor: "#141414",    padding: 12,    borderRadius: 16,  },  nowPlayingLeft: {    flexDirection: "row",    alignItems: "center",    gap: 12,    flex: 1,  },  nowPlayingImage: {    width: 48,    height: 48,    borderRadius: 10,  },  nowPlayingInfo: {    flex: 1,    gap: 2,  },  nowPlayingTitle: {    fontSize: 14,    fontWeight: "600",    color: "#fff",  },  nowPlayingArtist: {    fontSize: 12,    color: "#666",  },  nowPlayingControls: {    width: 44,    height: 44,    borderRadius: 22,    backgroundColor: "#fff",    justifyContent: "center",    alignItems: "center",  },  dots: {    flexDirection: "row",    justifyContent: "center",    alignItems: "center",    gap: 6,  },  dotIndicator: {    width: 6,    height: 6,    borderRadius: 3,    backgroundColor: "#333",  },  dotIndicatorActive: {    width: 20,    backgroundColor: "#fff",  },});

Props

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