Seekbar
An interactive seek bar with drag and tap control
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated react-native-worklets react-native-gesture-handler expo-hapticsCopy 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
