Skip to content

Scroll

Scroll provides versatile vertical and horizontal scrolling components with built-in theme integration, and flexible styling options. It supports both regular and animated variants (VScroll, HScroll, AnimatedVScroll, AnimatedHScroll) with customizable animations and scroll behavior.

import { VScroll, HScroll, AnimatedVScroll, AnimatedHScroll } from 'rnc-theme';
<VScroll padding="lg" backgroundColor="surface">
<Text>Content that scrolls vertically</Text>
<Text>More content...</Text>
<Text>Even more content...</Text>
</VScroll>

ScrollProps (VScroll, HScroll, AnimatedVScroll & AnimatedHScroll)

Section titled “ScrollProps (VScroll, HScroll, AnimatedVScroll & AnimatedHScroll)”
PropTypeDefaultDescription
childrenReact.ReactNode-Content to be scrolled
paddingkeyof Theme['spacing']-Internal padding using theme spacing
marginkeyof Theme['spacing']-External margin using theme spacing
backgroundColorkeyof Theme['colors']-Background color from theme
borderRadiuskeyof Theme['components']['borderRadius']-Border radius from theme
themedbooleanfalseApply theme background color
...propsScrollViewProps-All React Native ScrollView props
ComponentDescriptionUse Case
VScrollVertical scrolling containerLists, articles, forms
HScrollHorizontal scrolling containerCarousels, tabs, galleries
AnimatedVScrollAnimated vertical scrolling with ReanimatedComplex animations, parallax effects
AnimatedHScrollAnimated horizontal scrolling with ReanimatedInteractive carousels, gesture-driven UI
const NewsArticles = ({ articles }) => {
return (
<VScroll
padding="lg"
backgroundColor="background"
themed={true}
showsVerticalScrollIndicator={false}
>
{articles.map((article, index) => (
<Card key={index} margin="sm">
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>
{article.title}
</Text>
<Text style={{ marginTop: 8 }}>
{article.excerpt}
</Text>
</Card>
))}
</VScroll>
);
};
const ImageGallery = ({ images }) => {
return (
<HScroll
padding="md"
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 16 }}
>
{images.map((image, index) => (
<View
key={index}
style={{
width: 250,
height: 150,
marginRight: 12,
borderRadius: 8,
overflow: 'hidden'
}}
>
<Image source={{ uri: image.url }} style={{ flex: 1 }} />
</View>
))}
</HScroll>
);
};
import { useSharedValue, useAnimatedScrollHandler, useAnimatedStyle, interpolate } from 'react-native-reanimated';
const ParallaxHeader = ({ data }) => {
const scrollY = useSharedValue(0);
const HEADER_HEIGHT = 200;
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
});
const headerAnimatedStyle = useAnimatedStyle(() => {
const translateY = interpolate(
scrollY.value,
[0, HEADER_HEIGHT],
[0, -HEADER_HEIGHT / 2],
'clamp'
);
const opacity = interpolate(
scrollY.value,
[0, HEADER_HEIGHT],
[1, 0],
'clamp'
);
return {
transform: [{ translateY }],
opacity,
};
});
return (
<View style={{ flex: 1 }}>
<Animated.View
style={[
{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: HEADER_HEIGHT,
backgroundColor: '#007AFF',
zIndex: 1,
},
headerAnimatedStyle,
]}
>
<Text style={{ color: 'white', fontSize: 24, padding: 20 }}>
Parallax Header
</Text>
</Animated.View>
<AnimatedVScroll
onScroll={scrollHandler}
scrollEventThrottle={16}
contentContainerStyle={{ paddingTop: HEADER_HEIGHT }}
>
{data.map((item, index) => (
<View key={index} style={{ padding: 20, backgroundColor: 'white' }}>
<Text style={{ fontSize: 18 }}>{item.title}</Text>
<Text style={{ marginTop: 8, color: '#666' }}>
{item.description}
</Text>
</View>
))}
</AnimatedVScroll>
</View>
);
};
import { useSharedValue, useAnimatedScrollHandler, useAnimatedStyle, interpolate } from 'react-native-reanimated';
const AnimatedCarousel = ({ items }) => {
const scrollX = useSharedValue(0);
const ITEM_WIDTH = 300;
const ITEM_SPACING = 20;
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollX.value = event.contentOffset.x;
},
});
const CarouselItem = ({ item, index }) => {
const animatedStyle = useAnimatedStyle(() => {
const inputRange = [
(index - 1) * (ITEM_WIDTH + ITEM_SPACING),
index * (ITEM_WIDTH + ITEM_SPACING),
(index + 1) * (ITEM_WIDTH + ITEM_SPACING),
];
const scale = interpolate(
scrollX.value,
inputRange,
[0.8, 1, 0.8],
'clamp'
);
const opacity = interpolate(
scrollX.value,
inputRange,
[0.6, 1, 0.6],
'clamp'
);
return {
transform: [{ scale }],
opacity,
};
});
return (
<Animated.View
style={[
{
width: ITEM_WIDTH,
height: 200,
marginHorizontal: ITEM_SPACING / 2,
backgroundColor: '#f0f0f0',
borderRadius: 12,
padding: 20,
justifyContent: 'center',
alignItems: 'center',
},
animatedStyle,
]}
>
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>
{item.title}
</Text>
<Text style={{ marginTop: 8, textAlign: 'center' }}>
{item.description}
</Text>
</Animated.View>
);
};
return (
<AnimatedHScroll
onScroll={scrollHandler}
scrollEventThrottle={16}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: ITEM_SPACING }}
snapToInterval={ITEM_WIDTH + ITEM_SPACING}
decelerationRate="fast"
>
{items.map((item, index) => (
<CarouselItem key={index} item={item} index={index} />
))}
</AnimatedHScroll>
);
};
import { useSharedValue, useAnimatedScrollHandler, useAnimatedStyle, interpolate, runOnJS } from 'react-native-reanimated';
const AnimatedPullToRefresh = ({ data, onRefresh }) => {
const scrollY = useSharedValue(0);
const [refreshing, setRefreshing] = useState(false);
const REFRESH_THRESHOLD = 100;
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
onEndDrag: (event) => {
if (event.contentOffset.y < -REFRESH_THRESHOLD && !refreshing) {
runOnJS(handleRefresh)();
}
},
});
const handleRefresh = async () => {
setRefreshing(true);
await onRefresh();
setRefreshing(false);
};
const refreshIndicatorStyle = useAnimatedStyle(() => {
const opacity = interpolate(
scrollY.value,
[-REFRESH_THRESHOLD, 0],
[1, 0],
'clamp'
);
const scale = interpolate(
scrollY.value,
[-REFRESH_THRESHOLD, 0],
[1, 0.5],
'clamp'
);
return {
opacity,
transform: [{ scale }],
};
});
return (
<View style={{ flex: 1 }}>
<Animated.View
style={[
{
position: 'absolute',
top: 50,
left: 0,
right: 0,
height: 50,
justifyContent: 'center',
alignItems: 'center',
zIndex: 1,
},
refreshIndicatorStyle,
]}
>
<Text style={{ color: '#007AFF', fontSize: 16 }}>
{refreshing ? 'Refreshing...' : 'Pull to refresh'}
</Text>
</Animated.View>
<AnimatedVScroll
onScroll={scrollHandler}
scrollEventThrottle={16}
bounces={true}
contentContainerStyle={{ paddingTop: 20 }}
>
{data.map((item, index) => (
<View key={index} style={{ padding: 16, borderBottomWidth: 1 }}>
<Text style={{ fontSize: 16 }}>{item.title}</Text>
<Text style={{ color: '#666', marginTop: 4 }}>
{item.description}
</Text>
</View>
))}
</AnimatedVScroll>
</View>
);
};
const LongForm = () => {
const [formData, setFormData] = useState({});
return (
<VScroll
padding="lg"
backgroundColor="surface"
themed={true}
borderRadius="md"
margin="md"
keyboardShouldPersistTaps="handled"
>
<Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 20 }}>
Registration Form
</Text>
<TextInput
placeholder="Full Name"
style={{ marginBottom: 16 }}
onChangeText={(text) => setFormData({ ...formData, name: text })}
/>
<TextInput
placeholder="Email"
keyboardType="email-address"
style={{ marginBottom: 16 }}
onChangeText={(text) => setFormData({ ...formData, email: text })}
/>
<TextInput
placeholder="Phone Number"
keyboardType="phone-pad"
style={{ marginBottom: 16 }}
onChangeText={(text) => setFormData({ ...formData, phone: text })}
/>
<TextInput
placeholder="Address"
multiline
numberOfLines={4}
style={{ marginBottom: 16 }}
onChangeText={(text) => setFormData({ ...formData, address: text })}
/>
<TextInput
placeholder="Bio"
multiline
numberOfLines={6}
style={{ marginBottom: 20 }}
onChangeText={(text) => setFormData({ ...formData, bio: text })}
/>
<Button onPress={() => console.log('Form submitted:', formData)}>
<ButtonText>Submit Registration</ButtonText>
</Button>
{/* Extra spacing for keyboard */}
<View style={{ height: 100 }} />
</VScroll>
);
};
const CategoryTabs = ({ categories, activeCategory, onCategoryChange }) => {
return (
<HScroll
padding="sm"
backgroundColor="surface"
themed={true}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 16 }}
>
{categories.map((category, index) => (
<TouchableOpacity
key={index}
onPress={() => onCategoryChange(category)}
style={{
paddingHorizontal: 20,
paddingVertical: 10,
marginRight: 12,
borderRadius: 20,
backgroundColor: activeCategory === category ? '#007AFF' : '#f0f0f0'
}}
>
<Text
style={{
color: activeCategory === category ? 'white' : 'black',
fontWeight: activeCategory === category ? 'bold' : 'normal'
}}
>
{category}
</Text>
</TouchableOpacity>
))}
</HScroll>
);
};
const RefreshableList = ({ data, onRefresh }) => {
const [refreshing, setRefreshing] = useState(false);
const handleRefresh = async () => {
setRefreshing(true);
await onRefresh();
setRefreshing(false);
};
return (
<VScroll
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
colors={['#007AFF']}
/>
}
padding="md"
>
{data.map((item, index) => (
<View key={index} style={{ padding: 16, borderBottomWidth: 1 }}>
<Text>{item.title}</Text>
<Text style={{ color: '#666', marginTop: 4 }}>
{item.description}
</Text>
</View>
))}
</VScroll>
);
};
const InfiniteScrollList = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const loadMore = async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
const newData = await fetchMoreData(data.length);
setData(prev => [...prev, ...newData]);
setHasMore(newData.length > 0);
} finally {
setLoading(false);
}
};
const handleScroll = (event) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const isNearBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - 100;
if (isNearBottom) {
loadMore();
}
};
return (
<VScroll
onScroll={handleScroll}
scrollEventThrottle={400}
padding="md"
>
{data.map((item, index) => (
<View key={index} style={{ padding: 16, borderBottomWidth: 1 }}>
<Text>{item.title}</Text>
</View>
))}
{loading && (
<View style={{ padding: 20, alignItems: 'center' }}>
<ActivityIndicator size="large" color="#007AFF" />
<Text style={{ marginTop: 8 }}>Loading more...</Text>
</View>
)}
{!hasMore && data.length > 0 && (
<View style={{ padding: 20, alignItems: 'center' }}>
<Text style={{ color: '#666' }}>No more items to load</Text>
</View>
)}
</VScroll>
);
};
const NestedScrollExample = () => {
return (
<VScroll padding="lg" backgroundColor="background" themed={true}>
<Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 16 }}>
Categories
</Text>
{/* Horizontal scroll inside vertical scroll */}
<Text style={{ fontSize: 18, marginBottom: 12 }}>
Featured Products
</Text>
<HScroll
style={{ marginBottom: 24 }}
showsHorizontalScrollIndicator={false}
>
{Array.from({ length: 10 }, (_, i) => (
<View
key={i}
style={{
width: 150,
height: 100,
backgroundColor: '#f0f0f0',
marginRight: 12,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center'
}}
>
<Text>Product {i + 1}</Text>
</View>
))}
</HScroll>
<Text style={{ fontSize: 18, marginBottom: 12 }}>
All Products
</Text>
{Array.from({ length: 20 }, (_, i) => (
<View
key={i}
style={{
padding: 16,
backgroundColor: 'white',
marginBottom: 8,
borderRadius: 8
}}
>
<Text>Product {i + 1}</Text>
<Text style={{ color: '#666', marginTop: 4 }}>
Product description here
</Text>
</View>
))}
</VScroll>
);
};
import { useSharedValue, useAnimatedScrollHandler, useAnimatedStyle, interpolate } from 'react-native-reanimated';
const ParallaxBackground = () => {
const scrollY = useSharedValue(0);
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
});
const backgroundStyle = useAnimatedStyle(() => {
const translateY = interpolate(
scrollY.value,
[0, 300],
[0, 150],
'clamp'
);
return {
transform: [{ translateY }],
};
});
return (
<View style={{ flex: 1 }}>
<Animated.View
style={[
{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 300,
backgroundColor: '#007AFF',
},
backgroundStyle,
]}
/>
<AnimatedVScroll
onScroll={scrollHandler}
scrollEventThrottle={16}
contentContainerStyle={{ paddingTop: 200 }}
>
{/* Content */}
</AnimatedVScroll>
</View>
);
};

Performance

  • Use scrollEventThrottle to limit scroll event frequency (default is 16ms)
  • Implement virtualization for large lists using FlatList or SectionList when appropriate
  • Avoid complex calculations in scroll handlers
  • Use useAnimatedScrollHandler for better performance in animated scenarios

User Experience

  • Provide clear visual feedback for scroll interactions
  • Use appropriate scroll indicators for content overflow
  • Consider accessibility with proper content descriptions
  • Test animated interactions on different devices for performance

Theming

  • Leverage theme spacing and colors for consistent design
  • Use themed prop to automatically apply background colors
  • Combine with other themed components for cohesive UI

Animation Performance

  • Use runOnUI for complex calculations in animated scroll handlers
  • Prefer useAnimatedScrollHandler over regular onScroll for animations
  • Keep animated operations lightweight to maintain 60fps
  • Test on lower-end devices to ensure smooth performance

If you’re migrating from regular scroll components to animated ones:

// Before
<VScroll onScroll={handleScroll}>
{content}
</VScroll>
// After
import { useAnimatedScrollHandler } from 'react-native-reanimated';
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
// Handle scroll with better performance
scrollY.value = event.contentOffset.y;
},
});
<AnimatedVScroll onScroll={scrollHandler}>
{content}
</AnimatedVScroll>

Make sure you have the required dependencies:

Terminal window
npm install react-native-reanimated
# or
yarn add react-native-reanimated

For iOS, you’ll also need to run:

Terminal window
cd ios && pod install
const AnimatedSearchHeader = () => {
const scrollY = useSharedValue(0);
const [searchQuery, setSearchQuery] = useState('');
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
});
const headerStyle = useAnimatedStyle(() => {
const height = interpolate(
scrollY.value,
[0, 100],
[120, 80],
'clamp'
);
return {
height,
};
});
const searchBarStyle = useAnimatedStyle(() => {
const opacity = interpolate(
scrollY.value,
[0, 50],
[1, 0],
'clamp'
);
return {
opacity,
};
});
return (
<View style={{ flex: 1 }}>
<Animated.View
style={[
{
position: 'absolute',
top: 0,
left: 0,
right: 0,
backgroundColor: 'white',
zIndex: 1,
paddingTop: 50,
paddingHorizontal: 16,
},
headerStyle,
]}
>
<Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 8 }}>
My App
</Text>
<Animated.View style={searchBarStyle}>
<TextInput
placeholder="Search..."
value={searchQuery}
onChangeText={setSearchQuery}
style={{
backgroundColor: '#f0f0f0',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 8,
}}
/>
</Animated.View>
</Animated.View>
<AnimatedVScroll
onScroll={scrollHandler}
scrollEventThrottle={16}
contentContainerStyle={{ paddingTop: 120 }}
>
{/* Content */}
</AnimatedVScroll>
</View>
);
};
const AnimatedTabIndicator = ({ tabs, activeTab, onTabChange }) => {
const scrollX = useSharedValue(0);
const TAB_WIDTH = 100;
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollX.value = event.contentOffset.x;
},
});
const indicatorStyle = useAnimatedStyle(() => {
const translateX = interpolate(
scrollX.value,
tabs.map((_, index) => index * TAB_WIDTH),
tabs.map((_, index) => index * TAB_WIDTH),
'clamp'
);
return {
transform: [{ translateX }],
};
});
return (
<View>
<View style={{ flexDirection: 'row', backgroundColor: '#f0f0f0' }}>
{tabs.map((tab, index) => (
<TouchableOpacity
key={index}
onPress={() => onTabChange(index)}
style={{
width: TAB_WIDTH,
padding: 16,
alignItems: 'center',
}}
>
<Text style={{ fontWeight: activeTab === index ? 'bold' : 'normal' }}>
{tab.title}
</Text>
</TouchableOpacity>
))}
<Animated.View
style={[
{
position: 'absolute',
bottom: 0,
height: 3,
width: TAB_WIDTH,
backgroundColor: '#007AFF',
},
indicatorStyle,
]}
/>
</View>
<AnimatedHScroll
onScroll={scrollHandler}
scrollEventThrottle={16}
showsHorizontalScrollIndicator={false}
pagingEnabled
>
{tabs.map((tab, index) => (
<View key={index} style={{ width: TAB_WIDTH }}>
{tab.content}
</View>
))}
</AnimatedHScroll>
</View>
);
};

The components are fully typed and support TypeScript:

import { ScrollProps } from 'rnc-theme';
interface CustomScrollProps extends ScrollProps {
customProp?: string;
}
const CustomScroll: React.FC<CustomScrollProps> = ({ customProp, ...props }) => {
return <VScroll {...props} />;
};
  1. Performance Problems: Use scrollEventThrottle to limit scroll event frequency
  2. Animation Jank: Avoid heavy computations in scroll handlers
  3. Nested Scroll Issues: Test on both iOS and Android, use nestedScrollEnabled when needed
  4. Memory Leaks: Properly clean up animated values and listeners
// Add logging to scroll handlers
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
console.log('Scroll Y:', event.contentOffset.y);
scrollY.value = event.contentOffset.y;
},
});
// Monitor performance
import { enableScreens } from 'react-native-screens';
enableScreens();