ComponentsDisplay

3D Photo Album

A completely custom-built 3D photo album that simulates a physical hardcover book. It features dynamic spine shading, 3D page bending physics, perfectly mapped double-sided pages, and depth-based shadowing, all powered by React Native Reanimated and Gesture Handler.

v
Updated

Live Preview

Open in Snack
Preview

Installation

1
npm install react-native-reanimated react-native-gesture-handler
2

Copy PhotoAlbum.tsx to your components folder

3

Import and use

Source Code

PhotoAlbum.tsx
import React from 'react';
import {
    View,
    Dimensions,
    StyleSheet,
    Image,
    Text,
} from 'react-native';

import Animated, {
    interpolate,
    Extrapolation,
    useAnimatedStyle,
    useSharedValue,
    useDerivedValue,
    withSpring,
    SharedValue,
} from 'react-native-reanimated';

import {
    Gesture,
    GestureDetector,
    GestureHandlerRootView,
} from 'react-native-gesture-handler';



const { width: SCREEN_WIDTH } = Dimensions.get('window');

const ALBUM_WIDTH = SCREEN_WIDTH - 32;
const ALBUM_HEIGHT = ALBUM_WIDTH * 1.12;

const PAGE_WIDTH = ALBUM_WIDTH / 2;

const SPRING = {
    damping: 24,
    stiffness: 100,
    mass: 0.8,
    restDisplacementThreshold: 0.01,
    restSpeedThreshold: 0.01,
};

interface PhotoAlbumProps {
    pages: React.ReactNode[];
}

const N = 12;
const STRIP_WIDTH = PAGE_WIDTH / N;

interface StripProps {
    stripIndex: number;
    localProgress: SharedValue<number>;
    frontPage: React.ReactNode;
    backPage: React.ReactNode;
    children?: React.ReactNode;
    isFrontCover?: boolean;
    isBackCover?: boolean;
}

const Strip = ({
    stripIndex,
    localProgress,
    frontPage,
    backPage,
    children,
    isFrontCover,
    isBackCover,
}: StripProps) => {
    const isSpine = stripIndex === 0;

    const animatedStyle = useAnimatedStyle(() => {
        const p = localProgress.value;

        if (isSpine) {
            const rotateY = interpolate(p, [0, 1], [0, -180], Extrapolation.CLAMP);
            return {
                transform: [
                    { perspective: 1200 },
                    { translateX: -STRIP_WIDTH / 2 },
                    { rotateY: `${rotateY}deg` },
                    { translateX: STRIP_WIDTH / 2 },
                ]
            };
        } else {
            // Progressive bend: outer strips bend more than inner strips
            // This creates a natural cylindrical curl like a real page turning
            const stripFactor = stripIndex / (N - 1);
            const maxBendForStrip = 32 * (0.15 + 0.85 * stripFactor); // Increased bend for more realistic page curl
            const bend = interpolate(p, [0, 0.5, 1], [0, maxBendForStrip, 0], Extrapolation.CLAMP);
            return {
                transform: [
                    { perspective: 1200 },
                    { translateX: -STRIP_WIDTH / 2 },
                    { rotateY: `${bend}deg` },
                    { translateX: STRIP_WIDTH / 2 },
                ]
            };
        }
    });

    const shift = -stripIndex * STRIP_WIDTH;

    // Use the spine's rotation to determine visibility so the entire page flips at the exact same moment.
    // This prevents a patchwork of front/back faces from rendering simultaneously while the page is bent.
    const spineRotation = useDerivedValue(() => {
        return interpolate(localProgress.value, [0, 1], [0, -180], Extrapolation.CLAMP);
    });

    const frontOpacityStyle = useAnimatedStyle(() => {
        return { opacity: spineRotation.value > -90 ? 1 : 0 };
    });

    const backOpacityStyle = useAnimatedStyle(() => {
        return { opacity: spineRotation.value <= -90 ? 1 : 0 };
    });

    // Cover uses coverContent style (no padding), inner pages use pageContent (with padding)
    const frontStyle = isFrontCover ? [styles.coverContent, styles.coverStyle] : styles.pageContent;
    const backStyle = isBackCover ? [styles.coverContent, styles.coverStyle] : styles.pageContent;

    return (
        <Animated.View style={[styles.strip, { left: isSpine ? 0 : STRIP_WIDTH }, animatedStyle]}>
            {/* FRONT FACE */}
            <Animated.View style={[styles.stripFace, isFrontCover ? styles.coverStripFace : undefined, frontOpacityStyle]}>
                <View style={[frontStyle, { left: shift }]}>
                    {frontPage}
                </View>
            </Animated.View>

            {/* BACK FACE */}
            <Animated.View style={[styles.stripFace, isBackCover ? styles.coverStripFace : undefined, backOpacityStyle]}>
                <View style={[backStyle, { left: shift, transform: [{ scaleX: -1 }] }]}>
                    {backPage}
                </View>
            </Animated.View>

            {children}
        </Animated.View>
    );
};

interface PageProps {
    index: number;
    progress: SharedValue<number>;
    frontPage: React.ReactNode;
    backPage: React.ReactNode;
    sheetCount: number;
    isFrontCover?: boolean;
    isBackCover?: boolean;
}

const Page = ({
    index,
    progress,
    frontPage,
    backPage,
    sheetCount,
    isFrontCover,
    isBackCover,
}: PageProps) => {
    const localProgress = useDerivedValue(() => {
        const p = progress.value - index;
        return Math.max(0, Math.min(1, p));
    });

    const zIndexStyle = useAnimatedStyle(() => {
        const z = localProgress.value < 0.5 ? sheetCount - index : index;
        return {
            zIndex: Math.round(z * 10),
        };
    });

    // Cast shadow: the turning page casts a shadow onto the page underneath
    const castShadowStyle = useAnimatedStyle(() => {
        const p = localProgress.value;
        // Shadow is strongest when page is mid-turn, gone when flat
        const shadowOpacity = interpolate(p, [0, 0.3, 0.5, 0.7, 1], [0, 0.18, 0.25, 0.18, 0], Extrapolation.CLAMP);
        // Shadow offset moves from right to left as page turns
        const shadowX = interpolate(p, [0, 0.5, 1], [8, 0, -8], Extrapolation.CLAMP);
        return {
            shadowColor: '#000',
            shadowOffset: { width: shadowX, height: 4 },
            shadowOpacity: shadowOpacity,
            shadowRadius: interpolate(p, [0, 0.5, 1], [2, 12, 2], Extrapolation.CLAMP),
            elevation: interpolate(p, [0, 0.5, 1], [0, 8, 0], Extrapolation.CLAMP),
        };
    });

    let content: React.ReactNode = null;
    for (let i = N - 1; i >= 0; i--) {
        content = (
            <Strip
                key={`strip-${i}`}
                stripIndex={i}
                localProgress={localProgress}
                frontPage={frontPage}
                backPage={backPage}
                isFrontCover={isFrontCover}
                isBackCover={isBackCover}
            >
                {content}
            </Strip>
        );
    }

    return (
        <Animated.View style={[styles.pageContainer, zIndexStyle, castShadowStyle]} pointerEvents="none">
            {content}
        </Animated.View>
    );
};

// Book thickness — shows stacked page edges on all sides
const BookThickness = ({ progress, sheetCount }: { progress: SharedValue<number>; sheetCount: number }) => {
    // Right page stack (unturned pages)
    const rightEdgeStyle = useAnimatedStyle(() => {
        const p = progress.value;
        const rightPages = sheetCount - p;
        const thickness = interpolate(rightPages, [0, sheetCount], [0, 5], Extrapolation.CLAMP);
        // Fade out right edge completely when book is closed (p=0)
        return {
            width: thickness,
            right: -thickness,
            opacity: interpolate(p, [0, 0.5, 1], [0, 1, 1], Extrapolation.CLAMP),
        };
    });

    // Left page stack (turned pages)
    const leftEdgeStyle = useAnimatedStyle(() => {
        const p = progress.value;
        const leftPages = p;
        const thickness = interpolate(leftPages, [0, sheetCount], [0, 5], Extrapolation.CLAMP);
        // Fade out left edge completely when book is fully open at the end
        return {
            width: thickness,
            left: -thickness,
            opacity: interpolate(p, [sheetCount - 1, sheetCount - 0.5, sheetCount], [1, 1, 0], Extrapolation.CLAMP),
        };
    });

    // Top page stack
    const topEdgeStyle = useAnimatedStyle(() => {
        const p = progress.value;
        const openness = interpolate(p, [0, 1, sheetCount - 1, sheetCount], [0, 1, 1, 0], Extrapolation.CLAMP);
        return {
            height: 3,
            top: -3,
            opacity: openness,
        };
    });

    // Bottom page stack
    const bottomEdgeStyle = useAnimatedStyle(() => {
        const p = progress.value;
        const openness = interpolate(p, [0, 1, sheetCount - 1, sheetCount], [0, 1, 1, 0], Extrapolation.CLAMP);
        return {
            height: 3,
            bottom: -3,
            opacity: openness,
        };
    });

    return (
        <>
            {/* Right page stack */}
            <Animated.View style={[styles.sideEdge, rightEdgeStyle]} pointerEvents="none" />
            {/* Left page stack */}
            <Animated.View style={[styles.sideEdge, leftEdgeStyle]} pointerEvents="none" />
            {/* Top page stack */}
            <Animated.View style={[styles.topBottomEdge, topEdgeStyle]} pointerEvents="none" />
            {/* Bottom page stack */}
            <Animated.View style={[styles.topBottomEdge, bottomEdgeStyle]} pointerEvents="none" />
        </>
    );
};

// Spine gutter — deep V-shaped shadow like a real book binding
const SpineGutter = ({ progress, sheetCount }: { progress: SharedValue<number>; sheetCount: number }) => {
    const gutterOpacity = useAnimatedStyle(() => {
        const p = progress.value;
        const openness = interpolate(p, [0, 0.3, sheetCount - 0.3, sheetCount], [0, 1, 1, 0], Extrapolation.CLAMP);
        return { opacity: openness };
    });

    return (
        <Animated.View style={[styles.gutterContainer, gutterOpacity]} pointerEvents="none">
            {/* Deep V-shaped shadow layers — perfectly centered on PAGE_WIDTH for smooth falloff */}
            <View style={[styles.gutterLayer, { left: PAGE_WIDTH - 30, width: 60, backgroundColor: 'rgba(0,0,0,0.02)' }]} />
            <View style={[styles.gutterLayer, { left: PAGE_WIDTH - 20, width: 40, backgroundColor: 'rgba(0,0,0,0.04)' }]} />
            <View style={[styles.gutterLayer, { left: PAGE_WIDTH - 12, width: 24, backgroundColor: 'rgba(0,0,0,0.08)' }]} />
            <View style={[styles.gutterLayer, { left: PAGE_WIDTH - 6, width: 12, backgroundColor: 'rgba(0,0,0,0.15)' }]} />
            <View style={[styles.gutterLayer, { left: PAGE_WIDTH - 2, width: 4, backgroundColor: 'rgba(0,0,0,0.30)' }]} />
        </Animated.View>
    );
};

// Book spine — visible binding strip, always shown when open
const BookSpine = ({ progress, sheetCount }: { progress: SharedValue<number>; sheetCount: number }) => {
    const spineStyle = useAnimatedStyle(() => {
        const p = progress.value;
        // When closed: wide, fully opaque
        const closedFront = interpolate(p, [0, 0.5], [1, 0], Extrapolation.CLAMP);
        const closedBack = interpolate(p, [sheetCount - 0.5, sheetCount], [0, 1], Extrapolation.CLAMP);
        const closedness = Math.max(closedFront, closedBack);
        // When open: thin, still visible
        const openness = interpolate(p, [0.3, 1, sheetCount - 1, sheetCount - 0.3], [0, 1, 1, 0], Extrapolation.CLAMP);

        const width = closedness > 0
            ? interpolate(closedness, [0, 1], [0, 8], Extrapolation.CLAMP)
            : interpolate(openness, [0, 1], [0, 3], Extrapolation.CLAMP);

        const opacity = Math.max(closedness, openness * 0.85);

        // When closed, spine shadow should be inside the left edge (0 to 8).
        // When open, it should be centered between the pages (-width/2).
        const leftPos = closedness > 0
            ? interpolate(closedness, [0, 1], [-width / 2, 0], Extrapolation.CLAMP)
            : -width / 2;

        return {
            width,
            left: leftPos,
            opacity,
        };
    });

    return (
        <Animated.View style={[styles.bookSpine, spineStyle]} pointerEvents="none" />
    );
};

const PhotoAlbum = ({ pages }: PhotoAlbumProps) => {
    const progress = useSharedValue(0);
    const context = useSharedValue(0);
    const sheetCount = Math.ceil(pages.length / 2);

    const gesture = Gesture.Pan()
        .onBegin(() => {
            context.value = progress.value;
        })
        .onUpdate((e) => {
            const deltaProgress = -e.translationX / PAGE_WIDTH;
            progress.value = Math.max(0, Math.min(sheetCount, context.value + deltaProgress));
        })
        .onEnd((e) => {
            const velocity = -e.velocityX / PAGE_WIDTH;
            const currentResting = Math.round(progress.value);

            let target = Math.round(progress.value + velocity * 0.2);
            target = Math.max(currentResting - 1, Math.min(currentResting + 1, target));
            target = Math.max(0, Math.min(sheetCount, target));

            progress.value = withSpring(target, SPRING);
        });

    const albumAnimatedStyle = useAnimatedStyle(() => {
        const p = progress.value;

        let translateX = 0;

        if (p < 1) {
            translateX = interpolate(p, [0, 1], [-PAGE_WIDTH / 2, 0], Extrapolation.CLAMP);
        } else if (p > sheetCount - 1) {
            translateX = interpolate(p, [sheetCount - 1, sheetCount], [0, PAGE_WIDTH / 2], Extrapolation.CLAMP);
        }

        return {
            transform: [{ translateX }],
        };
    });

    // Dynamic book shadow
    const bookShadowStyle = useAnimatedStyle(() => {
        const p = progress.value;
        const openness = interpolate(p, [0, 1, sheetCount - 1, sheetCount], [0, 1, 1, 0], Extrapolation.CLAMP);
        return {
            shadowColor: '#000',
            shadowOffset: {
                width: 0,
                height: interpolate(openness, [0, 1], [6, 16], Extrapolation.CLAMP),
            },
            shadowOpacity: interpolate(openness, [0, 1], [0.15, 0.28], Extrapolation.CLAMP),
            shadowRadius: interpolate(openness, [0, 1], [10, 30], Extrapolation.CLAMP),
            elevation: interpolate(openness, [0, 1], [6, 20], Extrapolation.CLAMP),
        };
    });

    return (
        <GestureHandlerRootView style={styles.container}>
            <GestureDetector gesture={gesture}>
                <Animated.View style={[styles.albumWrapper, albumAnimatedStyle, bookShadowStyle]}>

                    {Array.from({ length: sheetCount }).map((_, index) => {
                        const frontPage = pages[index * 2];
                        const backPage = pages[index * 2 + 1];

                        const isFrontCover = index === 0;
                        const isBackCover = index === sheetCount - 1;

                        return (
                            <Page
                                key={index}
                                index={index}
                                progress={progress}
                                frontPage={frontPage}
                                backPage={backPage}
                                sheetCount={sheetCount}
                                isFrontCover={isFrontCover}
                                isBackCover={isBackCover}
                            />
                        );
                    })}

                    {/* Book page thickness on edges */}
                    <BookThickness progress={progress} sheetCount={sheetCount} />

                    {/* Spine gutter shadow */}
                    <SpineGutter progress={progress} sheetCount={sheetCount} />

                    {/* Book spine */}
                    <BookSpine progress={progress} sheetCount={sheetCount} />
                </Animated.View>
            </GestureDetector>
        </GestureHandlerRootView>
    );
};

export default PhotoAlbum;

const ALBUM_RENDER_HEIGHT = ALBUM_WIDTH * 0.75;

const styles = StyleSheet.create({
    container: {
        flex: 1,
        alignItems: 'center',
        justifyContent: 'center',
        backgroundColor: '#e6e5e1',
    },
    albumWrapper: {
        width: ALBUM_WIDTH,
        height: ALBUM_RENDER_HEIGHT,
        position: 'relative',
    },
    pageContainer: {
        position: 'absolute',
        width: PAGE_WIDTH,
        height: '100%',
        right: 0,
        top: 0,
    },
    strip: {
        position: 'absolute',
        top: 0,
        bottom: 0,
        width: STRIP_WIDTH,
    },
    coverStyle: {
        backgroundColor: '#CFC7BB',
        borderTopRightRadius: 4,
        borderBottomRightRadius: 4,
    },
    coverContent: {
        position: 'absolute',
        top: 0,
        width: PAGE_WIDTH,
        height: '100%',
        backgroundColor: 'transparent',
        // No padding — cover fills edge to edge
    },
    coverStripFace: {
        backgroundColor: 'transparent',
    },
    stripFace: {
        position: 'absolute',
        top: 0,
        left: 0,
        width: STRIP_WIDTH,
        height: '100%',
        overflow: 'hidden',
        backgroundColor: 'transparent',
    },
    pageContent: {
        position: 'absolute',
        top: 0,
        width: PAGE_WIDTH,
        height: '100%',
        backgroundColor: '#faf9f5',
        padding: 10,
    },
    overlay: {
        ...StyleSheet.absoluteFill,
    },
    gloss: {
        backgroundColor: 'rgba(255, 255, 255, 0.5)',
    },

    // Spine edge line
    spineEdgeLine: {
        position: 'absolute',
        left: 0,
        top: 0,
        bottom: 0,
        width: 1,
        backgroundColor: 'rgba(255,255,255,0.2)',
    },

    // Book spine — subtle binding band visible when closed
    bookSpine: {
        position: 'absolute',
        top: -1,
        bottom: -1,
        backgroundColor: 'rgba(0,0,0,0.08)',
        zIndex: 1000,
        borderRadius: 2,
    },

    // Spine gutter — overlapping centered shadow layers
    gutterContainer: {
        position: 'absolute',
        left: 0,
        right: 0,
        top: 0,
        bottom: 0,
        zIndex: 997,
        alignItems: 'center',
        justifyContent: 'center',
    },
    gutterLayer: {
        position: 'absolute',
        left: PAGE_WIDTH - 14,  // Will be offset by width/2
        top: 0,
        bottom: 0,
        // width is set inline
    },
    innerPageShadow: {
        position: 'absolute',
        top: 0,
        bottom: 0,
        backgroundColor: 'rgba(0,0,0,0.03)',
    },
    innerPageShadowRight: {
        position: 'absolute',
        top: 0,
        bottom: 0,
        backgroundColor: 'rgba(0,0,0,0.03)',
    },

    // Page stack edges — side
    sideEdge: {
        position: 'absolute',
        top: 1,
        bottom: 1,
        backgroundColor: '#eee9e2',
        borderTopWidth: StyleSheet.hairlineWidth,
        borderBottomWidth: StyleSheet.hairlineWidth,
        borderColor: 'rgba(0,0,0,0.06)',
        zIndex: -1,
    },

    // Page stack edges — top/bottom
    topBottomEdge: {
        position: 'absolute',
        left: 1,
        right: 1,
        backgroundColor: '#eee9e2',
        borderLeftWidth: StyleSheet.hairlineWidth,
        borderRightWidth: StyleSheet.hairlineWidth,
        borderColor: 'rgba(0,0,0,0.06)',
        zIndex: -1,
    },
});

Usage Examples

Basic Usage

Pass an array of components (e.g., Images or custom Views) to the PhotoAlbum. It automatically handles the 3D rendering and gesture interactions.

Example Code
import PhotoAlbum from '@/components/PhotoAlbum';
import { View, Text, Image } from 'react-native';

const pages = [
  <View key="cover" style={{ flex: 1, backgroundColor: '#CFC7BB', padding: 20 }}>
    <Text>My Album Cover</Text>
  </View>,
  <Image key="1" source={{ uri: 'https://picsum.photos/800' }} style={{ flex: 1 }} />,
  <Image key="2" source={{ uri: 'https://picsum.photos/801' }} style={{ flex: 1 }} />,
  <View key="back" style={{ flex: 1, backgroundColor: '#CFC7BB' }} />
];

export default function App() {
  return (
    <View style={{ flex: 1 }}>
      <PhotoAlbum pages={pages} />
    </View>
  );
}

Props

PropTypeDefaultDescription
pagesRequired
React.ReactNode[]-An array of React nodes to render as pages. The first node is the front cover, the last node is the back cover.

Features

  • Interactive pan gesture to turn pages smoothly
  • Realistic 3D bending physics using matrix transforms
  • Dynamic drop shadows and spine creases
  • Double-sided pages with synchronized opacity toggles
  • Zero external 3D engine dependencies (built on Reanimated)

Dependencies

Required

react-native-reanimatedreact-native-gesture-handler