Squiggly Slider
A Skia-powered animated slider with a flowing sine
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated react-native-gesture-handler @shopify/react-native-skia react-native-workletsCopy and paste the following code into your project.
component/molecules/squiggly-slider
import React, { memo, useEffect } from "react";import { View } from "react-native";import { Canvas, Path, RoundedRect } from "@shopify/react-native-skia";import { useSharedValue, useDerivedValue, useFrameCallback, withSpring, type FrameInfo,} from "react-native-reanimated";import { Gesture, GestureDetector } from "react-native-gesture-handler";import type { ISquigglySlider } from "./types";import { TWO_PI, SEGMENTS_PER_WAVELENGTH } from "./const";import { scheduleOnRN } from "react-native-worklets";export const SquigglySlider: React.FC<ISquigglySlider> & React.FunctionComponent<ISquigglySlider> = memo<ISquigglySlider>( ({ value, onValueChange, onSlidingComplete, width, height: heightProp, strokeWidth = 6, wavelength: wavelengthProp, amplitude: amplitudeProp, activeColor = "#FFFFFF", inactiveColor = "rgba(160, 130, 160, 0.5)", thumbColor = "#E04F93", speed = 4, }: React.ComponentProps<typeof SquigglySlider> & ISquigglySlider): | (React.ReactNode & React.JSX.Element & React.ReactElement) | null => { const amplitude = amplitudeProp ?? Math.max(strokeWidth * 2.5, 10); const wavelength = wavelengthProp ?? Math.max(strokeWidth * 10, 50); const height = heightProp ?? (amplitude + strokeWidth) * 2 + 16; const padding = strokeWidth * 2; const trackWidth = width - padding * 2; const centerY = height / 2; const thumbW = Math.max(strokeWidth + 2, 6); const thumbH = Math.max(strokeWidth * 5, 24); const progress = useSharedValue<number>(value); const phase = useSharedValue<number>(0); const animatedAmp = useSharedValue<number>(amplitude); useFrameCallback((frameInfo: FrameInfo) => { "worklet"; const dt = (frameInfo.timeSincePreviousFrame ?? 16) / 1000; phase.value = phase.value + dt / speed; }); useEffect(() => { progress.value = withSpring<number>(value, { damping: 20, stiffness: 200, mass: 0.4, }); }, [value, progress]); const wavePath = useDerivedValue<string>(() => { "worklet"; const halfStroke = strokeWidth / 2; const start = padding + halfStroke; const end = padding + progress.value * trackWidth - halfStroke; if (end <= start) return "M0 0"; const segW = wavelength / SEGMENTS_PER_WAVELENGTH; const count = Math.ceil((end - start) / segW) + 1; let d = ""; for (let i = 0; i < count; i++) { const x = Math.min(start + i * segW, end); const proportion = (x - start) / wavelength; const radians = proportion * TWO_PI + TWO_PI * phase.value; const y = centerY + Math.sin(radians) * animatedAmp.value; if (i === 0) { d = "M" + x.toFixed(2) + " " + y.toFixed(2); } else { d += " L" + x.toFixed(2) + " " + y.toFixed(2); } } return d; }); const inactivePath = useDerivedValue(() => { "worklet"; const x0 = padding + progress.value * trackWidth; const x1 = width - padding; if (x1 <= x0) return "M0 0"; return ( "M" + x0.toFixed(2) + " " + centerY.toFixed(2) + " L" + x1.toFixed(2) + " " + centerY.toFixed(2) ); }); const thumbX = useDerivedValue(() => { "worklet"; return padding + progress.value * trackWidth - thumbW / 2; }); const updateValue = <T extends number>(v: T) => onValueChange<number>(v); const complete = () => onSlidingComplete?.(); const panGesture = Gesture.Pan() .minDistance(0) .onBegin((e) => { "worklet"; animatedAmp.value = withSpring<number>(0, { damping: 18, stiffness: 220, mass: 0.5, }); const v = Math.min(1, Math.max(0, (e.x - padding) / trackWidth)); progress.value = v; scheduleOnRN(onValueChange<number>, v); }) .onUpdate((e) => { "worklet"; const v = Math.min(1, Math.max(0, (e.x - padding) / trackWidth)); progress.value = v; scheduleOnRN(onValueChange<number>, v); }) .onFinalize(() => { "worklet"; animatedAmp.value = withSpring<number>(amplitude, { damping: 12, stiffness: 180, mass: 0.5, }); scheduleOnRN(complete); }); return ( <GestureDetector gesture={panGesture}> <View style={{ width, height }} collapsable={false}> <Canvas style={{ width, height }}> <Path path={inactivePath} style="stroke" strokeWidth={strokeWidth} strokeCap="round" color={inactiveColor} /> <Path path={wavePath} style="stroke" strokeWidth={strokeWidth} strokeCap="round" strokeJoin="round" color={activeColor} /> <RoundedRect x={thumbX} y={centerY - thumbH / 2} width={thumbW} height={thumbH} r={thumbW / 2} color={thumbColor} /> </Canvas> </View> </GestureDetector> ); },);Usage
import { SquigglySlider } from "@/components/molecules/squiggly-slider";import React, { useEffect, useState } from "react";import { useWindowDimensions, View } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { useSafeAreaInsets } from "react-native-safe-area-context";export default () => { const [v, setV] = useState<number>(0); const { width } = useWindowDimensions(); useEffect(() => { const interval = setInterval(() => { setV((prev) => (prev >= 1 ? 0 : prev + 0.001)); }, 100); return () => clearInterval(interval); }, []); const insets = useSafeAreaInsets(); return ( <GestureHandlerRootView style={{ flex: 1, backgroundColor: "#141414", }} > <View style={{ paddingTop: insets.top + 50 }}> <SquigglySlider value={v} onValueChange={setV} width={width} thumbColor="#fff" activeColor="#fff" inactiveColor="rgba(255, 255, 255, 0.3)" amplitude={5} speed={1} /> </View> </GestureHandlerRootView> );};ISquigglySlider
React Native Reanimated
React Native Gesture Handler
React Native Skia
React Native Worklets
