Building a parallax timeline slider

Introduction:
This documentation explains the design and implementation of an advanced "parallax-style" carousel, often used for timelines or premium onboarding flows. Unlike a standard list, this component features a dynamic background that transitions seamlessly as the user swipes, creating an immersive, atmospheric experience.
The key goal is immersion, making the user feel like they are moving through time or space, rather than just scrolling a list of static items.
The Architecture:
To keep the code clean and maintainable, we separate concerns using a standard Parent-Child architecture.

1) The Controller (ParallaxTimelineScreen)
This screen acts as the "Brain" of the operation.
Responsibility: It manages the global state of the screen, specifically the current background image
Why?: The background is a full-screen element. It sits behind everything, including the header and the carousel itself. Therefore, the state must live at the screen level, not inside the carousel component.
Behavior: It listens for "snap" events from the carousel. When the carousel settles on a new item, the screen updates the background.

2) The Viewer (ParallaxCarousel)
This component acts as the "Muscle".
**Responsibility:**It handles the gesture physics (swiping), the layout of the cards, and the internal animations (making the active card "pop").
Why?: Complex animation logic should be encapsulated. The screen shouldn't know about interpolation values or scroll offsets; it just wants to know "Which item is active?".
Behavior: It reports index changes back to the parent via an
onImageChangecallback.
The Parallax Animation
The most striking feature is the way cards breathe, growing when focused and shrinking when inactive. We achieve this using react-native-reanimated for 60fps performance on the UI thread.
The Interpolation Logic
The Interpolation Logic We use a technique called Interpolation to map the scroll position to visual properties. Imagine the scroll position is a number that goes from -1 (Left), to 0 (Center), to 1 (Right).
We map this input to Padding
At 0 (Center): padding is
20px. The card content has lots of space to grow. It looks BIG.At 1 or -1 (Edges): padding is
60px. The card content is squeezed by the padding. It looks SMALL.
This counter-intuitive trick (adding padding to shrink content) is extremely performant because it avoids changing the actual width/height layout properties, which can cause jitter.
The Background Transition Strategy
If we simply swapped the background image source (<Image source={newSource} />), the change would be instant and jarring. We want a cinematic "cross-dissolve".
The Solution: Double Buffering
We don't just change the image; we animate the existence of the image using entering and exiting animations.
We give the
<Animated.Image>akeyprop equal to the current index.When the index changes, React treats it as a new component.
The old image (previous key) unmounts, triggering
exiting={FadeOut}.The new image (new key) mounts, triggering
entering={FadeIn}.Since both happen simultaneously (mostly), they blend perfectly.
Performance Tips
When dealing with full-screen images and animations, performance is key.
Use
resizeMode="cover": This ensures images fill the screen without distortion, but be mindful of using massive 4k images. 800x1200px is usually sufficient for mobile.Animated.ViewoverView: Always use animated components for anything that moves or fades.The "Virtual Loop" Trick: * Carousel loops can be tricky. If you enable
loop={true}immediately, the user might swipe left from the first item and end up at the last item instantly, which is confusing for a timeline. * Fix: Initialize withloop={false}. Once the user swipes to the second item (index > 0), setloop={true}. This enforces a linear start while allowing freedom afterwards.
Complete Demo Code
This is a Drop-In Ready component. You can copy this entire block into a single file (e.g., ParallaxTimelineScreen.tsx) and run it immediately. It uses standard web images so you don't need any local assets.
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
Dimensions,
Image,
StatusBar,
SafeAreaView,
Platform
} from 'react-native';
import Carousel from 'react-native-reanimated-carousel';
import Animated, {
FadeIn,
FadeOut,
useAnimatedStyle,
interpolate
} from 'react-native-reanimated';
import LinearGradient from 'react-native-linear-gradient';
// --- CONSTANTS & CONFIGURATION ---
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
// Mock Data: Replace the data
const DEMO_DATA = [
{
id: '1',
date: 'MARCH 2024',
title: 'The Beginning',
description: 'We started with a simple idea: create a timeline that feels alive.',
image: { uri: 'https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=800&q=80' },
},
{
id: '2',
date: 'JULY 2024',
title: 'Design Phase',
description: 'Drafting the pixel-perfect layouts and choosing the vibrant colors.',
image: { uri: 'https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=800&q=80' },
},
{
id: '3',
date: 'DECEMBER 2024',
title: 'Development',
description: 'Coding the logic, optimizing animations, and squashing bugs.',
image: { uri: 'https://images.unsplash.com/photo-1472214103451-9374bd1c798e?w=800&q=80' },
},
{
id: '4',
date: 'JANUARY 2025',
title: 'Launch Day',
description: 'Releasing the product to the world and watching the users enjoy it.',
image: { uri: 'https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=800&q=80' },
},
];
// --- SUB-COMPONENT: ANIMATED PARALLAX CARD ---
// This component handles the "Zoom/Shrink" effect based on scroll position.
interface AnimatedCardProps {
item: typeof DEMO_DATA[0];
animationValue: Animated.SharedValue<number>;
}
const ParallaxCard: React.FC<AnimatedCardProps> = ({ item, animationValue }) => {
const animatedStyle = useAnimatedStyle(() => {
// PARALLAX MAGIC:
// We interpolate the animation value (scroll progress) to padding.
// Less Padding = Larger Visual Image.
const paddingVertical = interpolate(
animationValue.value,
[-1, -0.5, 0, 0.5, 1],
[60, 40, 20, 40, 60] // 20px means focused (large), 60px means unfocused (small)
);
return {
paddingVertical,
paddingHorizontal: 20,
flex: 1,
};
});
return (
<Animated.View style={animatedStyle}>
<View style={styles.cardContainer}>
<Image source={item.image} style={styles.cardImage} resizeMode="cover" />
</View>
</Animated.View>
);
};
// --- COMPONENT: PARALLAX CAROUSEL ---
// This handles the swiping logic and state communication.
interface ParallaxCarouselProps {
onImageChange: (image: any) => void;
onIndexChange: (index: number) => void;
}
const ParallaxCarousel = ({ onImageChange, onIndexChange }: ParallaxCarouselProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
const [loopEnabled, setLoopEnabled] = useState(false);
return (
<View style={styles.carouselWrapper}>
<View style={styles.carouselContainer}>
<Carousel
loop={loopEnabled}
width={SCREEN_WIDTH}
height={SCREEN_WIDTH * 1.3}
data={DEMO_DATA}
mode="parallax"
scrollAnimationDuration={500}
onSnapToItem={(index) => {
setCurrentIndex(index);
// UX TRICK: Only enable infinite loop after the first swipe
if (!loopEnabled && index > 0) setLoopEnabled(true);
// Notify parent
onIndexChange(index);
onImageChange(DEMO_DATA[index].image);
}}
renderItem={({ item, animationValue }) => (
<ParallaxCard item={item} animationValue={animationValue} />
)}
/>
</View>
{/* Visual Decorator */}
<View style={styles.decoratorContainer}>
<View style={styles.line} />
<View style={styles.diamond} />
</View>
{/* Text Information */}
<View style={styles.textContainer}>
<Text style={styles.dateLabel}>{DEMO_DATA[currentIndex].date}</Text>
<Text style={styles.titleLabel}>{DEMO_DATA[currentIndex].title}</Text>
<Text style={styles.descLabel}>{DEMO_DATA[currentIndex].description}</Text>
</View>
</View>
);
};
// --- MAIN SCREEN: PARALLAX TIMELINE ---
// This orchestrates the background layers and the foreground content.
const ParallaxTimelineScreen = () => {
const [activeImage, setActiveImage] = useState(DEMO_DATA[0].image);
const [currIndex, setCurrIndex] = useState(0);
return (
<View style={styles.root}>
<StatusBar translucent backgroundColor="transparent" barStyle="light-content" />
{/* LAYER 1: The Background */}
{/* We key this by index to force a re-render, triggering the FadeIn/Out animations */}
<Animated.Image
key={currIndex}
entering={FadeIn.duration(800)}
exiting={FadeOut.duration(800)}
source={activeImage}
style={[StyleSheet.absoluteFill, { width: SCREEN_WIDTH, height: SCREEN_HEIGHT }]}
resizeMode="cover"
/>
{/* LAYER 2: The Mood Overlay */}
{/* A gradient ensures text is readable over any image */}
<LinearGradient
colors={['rgba(20,20,30,0.6)', 'rgba(0,0,0,0.9)']}
style={StyleSheet.absoluteFill}
/>
{/* LAYER 3: The Interactive Content */}
<SafeAreaView style={styles.contentContainer}>
{/* Header */}
<View style={styles.header}>
<View style={styles.circleBtn}><Text style={styles.btnText}>←</Text></View>
<Text style={styles.headerTitle}>ROADMAP</Text>
<View style={{ width: 40 }} />
</View>
{/* The Carousel */}
<ParallaxCarousel
onImageChange={setActiveImage}
onIndexChange={setCurrIndex}
/>
{/* Dot Indicators */}
<View style={styles.paginationDots}>
{DEMO_DATA.map((_, i) => (
<View
key={i}
style={[
styles.dot,
i === currIndex ? styles.activeDot : styles.inactiveDot
]}
/>
))}
</View>
</SafeAreaView>
</View>
);
};
// --- STYLING SYSTEM ---
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: '#000', // Fallback color
},
contentContainer: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
marginTop: Platform.OS === 'android' ? 40 : 0,
marginBottom: 10,
},
headerTitle: {
color: 'white',
fontSize: 18,
fontWeight: 'bold',
letterSpacing: 2,
},
circleBtn: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.2)',
alignItems: 'center',
justifyContent: 'center',
},
btnText: {
color: 'white',
fontSize: 20,
},
// Carousel
carouselWrapper: {
alignItems: 'center',
marginTop: 20,
},
carouselContainer: {
width: SCREEN_WIDTH,
alignItems: 'center',
justifyContent: 'center',
},
cardContainer: {
flex: 1,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: '#222',
elevation: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.5,
shadowRadius: 20,
},
cardImage: {
width: '100%',
height: '100%',
},
// Decoration
decoratorContainer: {
width: '60%',
height: 20,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
},
line: {
position: 'absolute',
width: '100%',
height: 1,
backgroundColor: 'rgba(255,255,255,0.3)',
},
diamond: {
width: 12,
height: 12,
backgroundColor: '#FFD700', // Gold color
transform: [{ rotate: '45deg' }],
},
// Text
textContainer: {
paddingHorizontal: 30,
alignItems: 'center',
},
dateLabel: {
color: '#FFD700',
fontSize: 14,
fontWeight: '600',
letterSpacing: 2,
marginBottom: 10,
},
titleLabel: {
color: 'white',
fontSize: 28,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 10,
},
descLabel: {
color: '#ccc',
fontSize: 16,
textAlign: 'center',
lineHeight: 24,
},
// Pagination
paginationDots: {
position: 'absolute',
bottom: 40,
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
},
dot: {
borderRadius: 2,
},
activeDot: {
width: 20,
height: 4,
backgroundColor: 'white',
},
inactiveDot: {
width: 10,
height: 4,
backgroundColor: 'rgba(255,255,255,0.3)',
},
});
export default ParallaxTimelineScreen;
Usage Guide
1) Install the Core Libraries:
npm install react-native-reanimated react-native-reanimated-carousel react-native-gesture-handler react-native-linear-gradient
2) Paste and Play:
Create a new file named ParallaxTimelineScreen.tsx and paste
3) Integrate:
Import the component in your App.tsx to see it in action.
No complex setup is required, the code is self-contained with mock data and internal types.



