Okay, let's refactor this blog post into a more engaging and comprehensive narrative. We'll focus on telling the story behind the component, diving deeper into the technical decisions and challenges, and making it a valuable read for developers interested in creating and publishing their own React Native components.
---
title: "From Scratch to NPM Star: The Making of `rn-vertical-slider` with React Native Gesture Handler & Reanimated"
publishedAt: "2024-01-29" # Or update if republishing
meta_description: "The complete journey of creating `rn-vertical-slider`, a custom React Native component, from identifying a need to publishing on NPM. A deep dive into Gesture Handler, Reanimated 2, and component design."
summary: "Follow the story of `rn-vertical-slider`, a performant vertical slider for React Native. This post details its conception, technical architecture using Gesture Handler & Reanimated 2, implementation challenges, and the process of publishing to NPM."
keywords: "React Native, Custom Component, NPM Package, Vertical Slider, React Native Gesture Handler, Reanimated 2, UI Development, Open Source, TypeScript"
---
Every so often in a React Native project, you hit a wall: a specific UI element is needed, but existing solutions just don't cut it. That's exactly how **`rn-vertical-slider`** was born. I needed a performant, customizable vertical slider, and the search for a perfect fit came up short. This wasn't just about filling a gap; it was an opportunity to dive deep into the intricacies of gesture handling and animation with React Native's modern power tools: **Gesture Handler** and **Reanimated 2**. This is the story of that journey, from a simple idea to a published NPM package.
## The Genesis: Why Another Slider?
The React Native ecosystem is rich, but when I scoured for a vertical slider, I found most existing options were:
* **Primarily Horizontal:** Vertical orientation often felt like an afterthought.
* **Performance Bottlenecks:** Many relied on the older `Animated` API or PanResponder, which could lead to jank on the JS thread during fast interactions.
* **Limited Customization:** Styling options were often restrictive, making it hard to match app-specific designs.
* **Outdated or Unmaintained:** Some hadn't kept pace with the latest React Native versions or modern animation libraries.
I needed something that was **performant by default, highly customizable, and built with the latest best practices** in mind. This was the spark that ignited the development of `rn-vertical-slider`.
## The Technical Foundation: Choosing the Right Tools
To achieve the desired fluidity and responsiveness, the choice of core dependencies was critical:
1. **`react-native-gesture-handler`:** For handling touch interactions. Its ability to run gesture logic on the native UI thread is paramount for smooth, jank-free performance, especially for continuous gestures like dragging.
2. **`react-native-reanimated` (v2+):** For animations. Reanimated 2's worklet-based architecture allows animation logic to also run on the UI thread, perfectly complementing Gesture Handler. Shared Values (`useSharedValue`) and animation modifiers (`withSpring`, `withTiming`) provide a powerful and declarative way to create complex animations.
```jsx
// Core dependencies that make the magic happen
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'; // Gesture Handler v2+
import Animated, {
useSharedValue,
useAnimatedStyle,
useAnimatedGestureHandler, // Older API, but Gesture.Pan() with .onUpdate etc. is preferred for v2
withSpring,
withTiming,
runOnJS, // To call JS functions from UI thread worklets
} from 'react-native-reanimated';
import { View, StyleSheet, ViewStyle } from 'react-native'; // Basic RN components
Note: While useAnimatedGestureHandler
was prominent in earlier Reanimated 2 versions, the Gesture.Pan().onStart().onUpdate().onEnd()
API from Gesture Handler v2 is now the more common and often preferred approach for declarative gesture definitions that integrate seamlessly with Reanimated shared values.
The Nuts and Bolts: Implementation Deep Dive
Building a slider involves more than just moving a thumb; it's about precise value calculation, respecting boundaries, and providing clear feedback.
1. Core Slider Logic: Responding to Touch
The heart of the slider is how it translates touch gestures into position and value changes.
// Simplified core logic within the VerticalSlider component
// (Assumes SLIDER_HEIGHT is the visible height of the slider track)
export interface VerticalSliderProps {
value?: number;
minimumValue?: number;
maximumValue?: number;
onValueChange?: (value: number) => void;
onSlidingComplete?: (value: number) => void;
step?: number;
disabled?: boolean;
height?: number; // Allow custom height for the track
// Style props
style?: ViewStyle;
trackStyle?: ViewStyle;
thumbStyle?: ViewStyle;
minimumTrackStyle?: ViewStyle; // Style for the filled part of the track
// Color props for easier theming
minimumTrackTintColor?: string;
maximumTrackTintColor?: string;
thumbTintColor?: string;
// Accessibility
accessibilityLabel?: string;
}
export const VerticalSlider: React.FC<VerticalSliderProps> = ({
value: initialValue = 0,
minimumValue = 0,
maximumValue = 100,
onValueChange,
onSlidingComplete,
step = 1,
disabled = false,
height: sliderHeight = 200, // Default slider height
// ... other props
}) => {
const THUMB_HEIGHT = 30; // Example thumb height
// Convert initial value to initial translation
const valueToTranslation = (val: number) => {
'worklet';
const clampedVal = Math.min(Math.max(val, minimumValue), maximumValue);
const percentage = (clampedVal - minimumValue) / (maximumValue - minimumValue);
// Invert for vertical: 0% value = top (max translation), 100% value = bottom (0 translation)
return (1 - percentage) * (sliderHeight - THUMB_HEIGHT);
};
const translationY = useSharedValue(valueToTranslation(initialValue));
const startY = useSharedValue(0); // To store initial Y on gesture start
const currentValue = useSharedValue(initialValue); // To store the current calculated value
// Update translation when initialValue prop changes externally
useEffect(() => {
translationY.value = withTiming(valueToTranslation(initialValue), { duration: 100 });
currentValue.value = initialValue;
}, [initialValue, minimumValue, maximumValue, sliderHeight]);
const panGesture = Gesture.Pan()
.enabled(!disabled)
.onBegin(() => {
startY.value = translationY.value; // Capture current translation
})
.onUpdate((event) => {
let newTranslation = startY.value + event.translationY;
// Clamp translation within bounds (0 to sliderHeight - THUMB_HEIGHT)
newTranslation = Math.max(0, Math.min(newTranslation, sliderHeight - THUMB_HEIGHT));
translationY.value = newTranslation;
// Calculate new value based on translation
const rawValue = calculateValue(
newTranslation,
minimumValue,
maximumValue,
sliderHeight - THUMB_HEIGHT,
THUMB_HEIGHT // Pass thumb height for precise calc
);
const steppedValue = snapToStep(rawValue, step, minimumValue);
currentValue.value = steppedValue; // Update shared value for potential use in worklets
// Notify JS thread if onValueChange is provided
if (onValueChange) {
runOnJS(onValueChange)(steppedValue);
}
})
.onEnd(() => {
// Optional: Add spring effect on release or other end-gesture logic
// translationY.value = withSpring(translationY.value, { damping: 15 });
if (onSlidingComplete) {
runOnJS(onSlidingComplete)(currentValue.value);
}
});
const animatedThumbStyle = useAnimatedStyle(() => {
return {
transform: [{ translateY: translationY.value }],
};
});
// Animated style for the minimum track (filled portion)
const animatedMinimumTrackStyle = useAnimatedStyle(() => {
const trackHeight = sliderHeight - translationY.value - THUMB_HEIGHT / 2;
return {
height: Math.max(0, trackHeight), // Ensure height is not negative
// Position it from the bottom up to the thumb's center
bottom: 0,
};
});
// The GestureDetector wraps the interactive area
return (
<GestureHandlerRootView style={{ flex: 1 }}> {/* Needed for Gesture Handler v2+ */}
<View style={[styles.container, { height: sliderHeight }, style]}>
{/* Maximum Track (the unfilled part) */}
<View style={[styles.track, { backgroundColor: maximumTrackTintColor }]} />
{/* Minimum Track (the filled part) */}
<Animated.View style={[styles.minimumTrack, { backgroundColor: minimumTrackTintColor }, animatedMinimumTrackStyle]} />
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.thumb, { height: THUMB_HEIGHT, width: THUMB_HEIGHT, borderRadius: THUMB_HEIGHT / 2, backgroundColor: thumbTintColor }, thumbStyle, animatedThumbStyle]} testID="slider-thumb" />
</GestureDetector>
</View>
</GestureHandlerRootView>
);
};
useSharedValue
from Reanimated is used to store animatable values liketranslationY
.- The
Gesture.Pan()
from Gesture Handler v2 declaratively defines how the gesture behaves on start, update, and end. - Crucially, all gesture updates and animations happen on the UI thread, ensuring smoothness.
runOnJS
is used to call theonValueChange
prop (which is a JS function) from the UI thread worklet.
2. Precise Value Calculations (Worklets)
Translating pixel movements to meaningful slider values needs to be precise and also run on the UI thread.
// Helper functions defined as worklets (must have 'worklet'; pragma)
function calculateValue(
currentTranslationY: number,
min: number,
max: number,
trackHeight: number, // Effective track height for calculation
thumbHeight: number // Consider thumb height if positioning from its top
) {
'worklet';
// Invert the ratio for vertical slider: 0 translation (top) = max value, max translation (bottom) = min value
const ratio = currentTranslationY / trackHeight;
// Value = max - (ratio * range)
return max - ratio * (max - min);
}
function snapToStep(value: number, step: number, minimumValue: number) {
'worklet';
if (step <= 0) return value; // No snapping if step is invalid
// Adjust value relative to minimumValue before snapping, then add minimumValue back
// This ensures snapping aligns correctly if minimumValue is not a multiple of step
const relativeValue = value - minimumValue;
const snappedRelative = Math.round(relativeValue / step) * step;
let finalValue = minimumValue + snappedRelative;
// Ensure the final value is within min/max bounds (especially after snapping)
// This step should ideally be done after snapping in the calling function
// finalValue = Math.max(minimumValue, Math.min(finalValue, maximumValue)); // Clamp to min/max
return finalValue;
}
- The
'worklet';
directive is essential. It tells Reanimated to prepare these functions to run on the UI thread. - These functions handle converting
translationY
to the slider's actualvalue
and snapping that value to the neareststep
.
3. Customizable Styling
A good component needs to be stylable.
// Default styles (can be overridden by props)
const styles = StyleSheet.create({
container: {
width: 40, // Default width
justifyContent: 'center',
alignItems: 'center',
// backgroundColor: '#E0E0E0', // Default max track color
borderRadius: 20,
},
track: { // This is the maximum track (background)
position: 'absolute',
width: 6, // Default track width
height: '100%',
borderRadius: 3,
// backgroundColor is set by maximumTrackTintColor prop
},
minimumTrack: { // This is the filled portion of the track
position: 'absolute',
width: 6, // Default track width
borderRadius: 3,
// backgroundColor is set by minimumTrackTintColor prop
// height and bottom are animated
},
thumb: {
position: 'absolute', // Thumb is positioned absolutely based on translationY
// width, height, borderRadius, backgroundColor are set by props or defaults
// It's centered horizontally by the container's alignItems: 'center'
},
});
- Props like
style
,trackStyle
,thumbStyle
, and variousTintColor
props allow users to customize the appearance extensively. - The
minimumTrackStyle
(for the filled part of the track) is also animated to show progress.
The Road to NPM: Publishing the Package
Creating the component is one thing; making it easily consumable by others is another.
1. Essential package.json
Configuration
This file is the heart of your NPM package.
{
"name": "rn-vertical-slider",
"version": "1.2.0", // Increment with each release
"description": "A performant, customizable vertical slider component for React Native, built with Reanimated 2 and Gesture Handler 2.",
"main": "lib/commonjs/index.js", // Entry point for CommonJS (Node)
"module": "lib/module/index.js", // Entry point for ES Modules
"types": "lib/typescript/index.d.ts", // TypeScript definitions
"react-native": "src/index.ts", // Source entry point for React Native bundler
"source": "src/index.ts", // Original source
"files": [ // Files to include in the NPM package
"src",
"lib",
"android", // If you have native code
"ios", // If you have native code
"cpp", // If you have C++ code
"*.podspec", // For iOS native modules
"README.md",
"LICENSE"
],
"scripts": {
"typescript": "tsc --noEmit",
"lint": "eslint \"**/*.{js,ts,tsx}\"",
"test": "jest",
"build": "bob build", // Using react-native-builder-bob for building
"release": "release-it", // For automating releases
"example": "yarn --cwd example",
"pods": "cd example && pod-install --quiet",
"bootstrap": "yarn example && yarn && yarn pods"
},
"keywords": [
"react-native",
"slider",
"vertical-slider",
"reanimated",
"gesture-handler",
"ui-component"
],
"repository": {
"type": "git",
"url": "git+https://github.com/your-username/rn-vertical-slider.git"
},
"author": "Your Name <your.email@example.com> (https://your-website.com)",
"license": "MIT",
"bugs": {
"url": "https://github.com/your-username/rn-vertical-slider/issues"
},
"homepage": "https://github.com/your-username/rn-vertical-slider#readme",
"publishConfig": {
"registry": "https://registry.npmjs.org/"
},
"peerDependencies": { // Crucial: specify what your component relies on
"react": ">=17.0.0",
"react-native": ">=0.64.0",
"react-native-gesture-handler": ">=2.0.0",
"react-native-reanimated": ">=2.0.0"
},
"devDependencies": { // Tools for development and building
"@commitlint/config-conventional": "^17.0.2",
"@release-it/conventional-changelog": "^5.0.0",
"commitlint": "^17.0.2",
"eslint": "^8.4.1",
// ... other dev dependencies like TypeScript, Jest, @testing-library/react-native
"react-native-builder-bob": "^0.20.0",
"release-it": "^15.0.0"
},
"react-native-builder-bob": { // Configuration for react-native-builder-bob
"source": "src",
"output": "lib",
"targets": [
"commonjs",
"module",
["typescript", { "project": "tsconfig.build.json" }]
]
}
}
main
,module
,types
: Point to the compiled output for different module systems and TypeScript definitions.react-native
,source
: Help the React Native bundler (Metro) resolve the source code directly, which can be beneficial for development.files
: Specifies what gets published to NPM.scripts
: Includes commands for building, testing, linting, and releasing. I highly recommendreact-native-builder-bob
for compiling TypeScript/ESNext code into consumable formats.peerDependencies
: This is VITAL. It tells users of your package which versions of React, React Native, Gesture Handler, and Reanimated your component is compatible with. They need to have these installed in their own project.
2. Robust TypeScript Support
Providing type definitions is a must for modern React Native libraries.
// src/index.ts or a dedicated types.ts file
import type { ViewStyle } from 'react-native';
export interface VerticalSliderProps {
/** Initial value of the slider. Defaults to `minimumValue`. */
value?: number;
/** Minimum value of the slider. Defaults to 0. */
minimumValue?: number;
/** Maximum value of the slider. Defaults to 100. */
maximumValue?: number;
/** Callback continuously called while the value changes. */
onValueChange?: (value: number) => void;
/** Callback called when the user finishes dragging the slider. */
onSlidingComplete?: (value: number) => void;
/** Step value of the slider. Changes will snap to the closest step. Defaults to 1. Set to 0 to disable stepping. */
step?: number;
/** If true, the slider will be disabled and unresponsive to gestures. Defaults to false. */
disabled?: boolean;
/** The height of the slider track. Defaults to 200. */
height?: number;
/** Style for the main container view. */
style?: ViewStyle;
/** Style for the track view (the background bar). */
trackStyle?: ViewStyle;
/** Style for the filled portion of the track (below/after the thumb). */
minimumTrackStyle?: ViewStyle;
/** Style for the thumb (the draggable part). */
thumbStyle?: ViewStyle;
/** Color of the filled portion of the track. */
minimumTrackTintColor?: string;
/** Color of the unfilled portion of the track. */
maximumTrackTintColor?: string;
/** Color of the thumb. */
thumbTintColor?: string;
/** Accessibility label for the slider. */
accessibilityLabel?: string;
/** Test ID for automation. */
testID?: string;
}
- Clear JSDoc comments for each prop enhance developer experience significantly.
Ensuring Quality: Testing and Validation
A reliable component needs thorough testing.
// __tests__/VerticalSlider.test.tsx
import React from 'react';
import { render, fireEvent // Import from @testing-library/react-native
} from '@testing-library/react-native';
import { VerticalSlider, VerticalSliderProps } from '../src/index'; // Adjust path
import { GestureHandlerRootView } from 'react-native-gesture-handler';
// Mock Reanimated and Gesture Handler for Jest environment
// This is often complex and requires careful setup.
// See official docs for Jest setup with these libraries.
// jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock'));
// jest.mock('react-native-gesture-handler', () => {
// const View = require('react-native/Libraries/Components/View/View');
// return {
// GestureHandlerRootView: View,
// GestureDetector: View, // Mocking GestureDetector
// Gesture: { Pan: () => ({ onBegin: jest.fn(), onUpdate: jest.fn(), onEnd: jest.fn() }) }, // Mocking Pan gesture methods
// };
// });
const renderSlider = (props?: Partial<VerticalSliderProps>) => {
// GestureHandlerRootView is required to wrap components using Gesture Handler
return render(
<GestureHandlerRootView style={{ flex: 1 }}>
<VerticalSlider
testID="vertical-slider"
minimumValue={0}
maximumValue={100}
height={200}
{...props}
/>
</GestureHandlerRootView>
);
};
describe('VerticalSlider', () => {
it('renders correctly with default values', () => {
const { getByTestId } = renderSlider();
expect(getByTestId('slider-thumb')).toBeTruthy(); // Check if thumb is rendered
});
it('calls onValueChange when dragged (conceptual test)', () => {
const onValueChangeMock = jest.fn();
renderSlider({ onValueChange: onValueChangeMock, value: 50 });
// Simulating gestures in Jest with Reanimated/Gesture Handler is non-trivial.
// This is a conceptual placeholder.
// You'd typically mock the gesture events or use a higher-level testing approach.
// For unit tests, you might test the `calculateValue` and `snapToStep` worklets directly.
// Example: Test initial value setting through onValueChange
// This test needs to be adapted based on how you can trigger or mock gesture events
// in your testing setup for Reanimated/Gesture Handler.
// For now, let's test if the component initializes with the correct value
// and if onValueChange is called if the `value` prop changes.
// (This specific test logic needs to be adapted to a real testing scenario for gestures)
// A better unit test might be to directly test the worklet functions:
// expect(calculateValueWorklet(0, 0, 100, 200, 30)).toBe(100); // Top position means max value
// expect(calculateValueWorklet(200 - 30, 0, 100, 200 - 30, 30)).toBe(0); // Bottom position means min value
// For now, we'll just check if the mock function is present.
expect(onValueChangeMock).not.toHaveBeenCalled(); // Initially not called
});
it('respects minimum and maximum value boundaries', () => {
// This would also require more involved gesture simulation or direct worklet testing
});
it('snaps to step values correctly', () => {
// Test the snapToStep worklet directly
// expect(snapToStepWorklet(53, 10, 0)).toBe(50);
// expect(snapToStepWorklet(57, 10, 0)).toBe(60);
});
});
- Testing gesture-based components with Reanimated in Jest can be tricky. It often involves mocking parts of the libraries or focusing unit tests on the core logic (worklets) and using E2E tests (like Detox or Maestro) for actual interaction testing.
@testing-library/react-native
is excellent for component rendering and basic event assertions.
Key Lessons From the Trenches
This project was a significant learning experience:
- Plan Your API Thoughtfully: Define props and callbacks clearly from the outset. Think about how developers will use your component. Good JSDoc/TSDoc comments are invaluable.
- Comprehensive Documentation is King: A clear README with installation instructions, usage examples, and prop descriptions is essential for adoption. An example app within the repository is even better.
- Prioritize TypeScript Support: In 2024, it's almost a must-have for any serious library.
- Write Meaningful Tests: Cover core logic and common use cases.
- Automate Your Build & Release Process: Tools like
react-native-builder-bob
andrelease-it
(with conventional changelogs) save a ton of time and reduce errors. - Understand Peer Dependencies: Get this right to avoid a world of pain for your users.
- Embrace the UI Thread: For smooth gestures and animations, keep logic on the UI thread with Reanimated worklets and Gesture Handler.
runOnJS
should be used sparingly for callbacks to the JS thread.
Seeing It in Action: Usage Example
// App.js
import React, { useState } from 'react';
import { SafeAreaView, View, Text, StyleSheet } from 'react-native';
import { VerticalSlider } from 'rn-vertical-slider'; // Assuming you've published or linked it
import { GestureHandlerRootView } from 'react-native-gesture-handler';
const App = () => {
const [sliderValue, setSliderValue] = useState(25);
return (
// GestureHandlerRootView should be at the root of your app or screen
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaView style={styles.safeArea}>
<View style={styles.container}>
<Text style={styles.valueText}>Value: {sliderValue.toFixed(0)}</Text>
<VerticalSlider
value={sliderValue}
onValueChange={setSliderValue}
minimumValue={0}
maximumValue={100}
step={1}
height={300} // Custom height
thumbTintColor="#FF6347"
minimumTrackTintColor="#FFD700"
maximumTrackTintColor="#D3D3D3"
style={styles.sliderStyle} // Custom container style
// thumbStyle={{ width: 40, height: 40, borderRadius: 20 }} // Custom thumb style
/>
</View>
</SafeAreaView>
</GestureHandlerRootView>
);
};
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#f5f5f5',
},
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
valueText: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 30,
},
sliderStyle: {
width: 60, // Make the slider touch area wider
},
});
export default App;
The Reward: A Community Resource
rn-vertical-slider
is now out in the wild, being used in various apps, and has garnered positive feedback from the React Native community. The journey from identifying a personal need to publishing a shareable, open-source solution was incredibly rewarding. It underscored the power of modern React Native animation and gesture libraries and reinforced the value of contributing back to the ecosystem.
This experience has taught me invaluable lessons in component API design, performance optimization for interactive elements, the intricacies of the NPM publishing process, and the importance of clear documentation.
Have you built and published your own React Native components? What were your biggest challenges and learnings? Share your story in the comments below!