Skip to main content

Command Palette

Search for a command to run...

Building a parallax timeline slider

Published
7 min read
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 onImageChange callback.

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.

  1. We give the <Animated.Image> a key prop equal to the current index.

  2. When the index changes, React treats it as a new component.

  3. The old image (previous key) unmounts, triggering exiting={FadeOut}.

  4. The new image (new key) mounts, triggering entering={FadeIn}.

  5. Since both happen simultaneously (mostly), they blend perfectly.

Performance Tips

When dealing with full-screen images and animations, performance is key.

  1. 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.

  2. Animated.View over View: Always use animated components for anything that moves or fades.

  3. 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 with loop={false}. Once the user swipes to the second item (index > 0), set loop={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.