ComponentsLayout
Expandable Card
AdvancedA highly coveted App Store style expandable card transition. Built entirely on the UI thread with React Native Reanimated, it dynamically tracks the background list scroll position to ensure flawlessly smooth expansion and collapse physics.
v1.0.0
Updated 2026-08-04
Live Preview
Open in SnackPreview
Installation
1
Copy ExpandableCard.tsx to your components folder
2
Install react-native-reanimated and react-native-safe-area-context
3
Import and provide your array of cards
Source Code
ExpandableCard.tsx
import React, { useState, useRef } from 'react';
import {
View,
Text,
StyleSheet,
Image,
Pressable,
Dimensions,
ScrollView,
Platform,
StatusBar,
Modal,
} from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
interpolate,
Extrapolation,
runOnJS,
useAnimatedRef,
measure,
useAnimatedScrollHandler,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const { width: WINDOW_WIDTH, height: WINDOW_HEIGHT } = Dimensions.get('window');
const SPRING_CONFIG = {
damping: 16,
stiffness: 120,
mass: 0.8,
restDisplacementThreshold: 0.01,
restSpeedThreshold: 2,
};
export type CardData = {
id: string;
category: string;
title: string;
image: any; // URL or require()
content: string;
};
interface ExpandableCardListProps {
data: CardData[];
}
export function ExpandableCardList({ data }: ExpandableCardListProps) {
const insets = useSafeAreaInsets();
const [activeCard, setActiveCard] = useState<CardData | null>(null);
const [modalVisible, setModalVisible] = useState(false);
const activeCardX = useSharedValue(0);
const activeCardY = useSharedValue(0);
const activeCardWidth = useSharedValue(0);
const activeCardHeight = useSharedValue(0);
const isExpanded = useSharedValue(0);
const handlePressCard = (
card: CardData,
x: number,
y: number,
width: number,
height: number
) => {
activeCardX.value = x;
activeCardY.value = y;
activeCardWidth.value = width;
activeCardHeight.value = height;
setActiveCard(card);
setModalVisible(true);
isExpanded.value = withSpring(1, SPRING_CONFIG);
};
const handleClose = () => {
isExpanded.value = withTiming(0, { duration: 350 }, (finished) => {
if (finished) {
runOnJS(setActiveCard)(null);
runOnJS(setModalVisible)(false);
}
});
};
const overlayStyle = useAnimatedStyle(() => {
return {
pointerEvents: isExpanded.value > 0 ? 'auto' : 'none',
};
});
const activeCardStyle = useAnimatedStyle(() => {
return {
position: 'absolute',
left: interpolate(isExpanded.value, [0, 1], [activeCardX.value, 0]),
top: interpolate(isExpanded.value, [0, 1], [activeCardY.value, 0]),
width: interpolate(isExpanded.value, [0, 1], [activeCardWidth.value, WINDOW_WIDTH]),
height: interpolate(isExpanded.value, [0, 1], [activeCardHeight.value, WINDOW_HEIGHT]),
borderRadius: interpolate(isExpanded.value, [0, 1], [24, 0]),
overflow: 'hidden',
};
});
const contentOpacityStyle = useAnimatedStyle(() => {
return {
opacity: interpolate(isExpanded.value, [0.5, 1], [0, 1], Extrapolation.CLAMP),
transform: [
{ translateY: interpolate(isExpanded.value, [0.5, 1], [20, 0], Extrapolation.CLAMP) }
]
};
});
const closeButtonStyle = useAnimatedStyle(() => {
return {
opacity: interpolate(isExpanded.value, [0.8, 1], [0, 1], Extrapolation.CLAMP),
};
});
return (
<View style={styles.container}>
<ScrollView
contentContainerStyle={[styles.scrollContent, { paddingTop: insets.top + 20 }]}
showsVerticalScrollIndicator={false}
>
<Text style={styles.headerTitle}>Today</Text>
{data.map((card) => (
<CardItem
key={card.id}
card={card}
onPress={handlePressCard}
hidden={activeCard?.id === card.id}
/>
))}
</ScrollView>
{/* FULL SCREEN OVERLAY */}
<Modal transparent visible={modalVisible} onRequestClose={handleClose} animationType="none">
{activeCard && (
<Animated.View style={[StyleSheet.absoluteFill, styles.overlay, overlayStyle]}>
<Animated.View style={[styles.activeCard, activeCardStyle]}>
<ScrollView
showsVerticalScrollIndicator={false}
bounces={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 40 }}
>
{/* Image Header */}
<View style={styles.activeImageContainer}>
<Image source={{ uri: activeCard.image }} style={styles.activeImage} />
<View style={styles.cardTextContainer}>
<Text style={styles.cardCategory}>{activeCard.category}</Text>
<Text style={styles.cardTitle}>{activeCard.title}</Text>
</View>
</View>
{/* Expanded Content */}
<Animated.View style={[styles.expandedContent, contentOpacityStyle]}>
<Text style={styles.expandedText}>{activeCard.content}</Text>
</Animated.View>
</ScrollView>
</Animated.View>
{/* Back Arrow Button */}
<Animated.View
style={[
styles.closeButtonContainer,
{ top: insets.top || 20 },
closeButtonStyle
]}
>
<Pressable style={styles.closeButton} onPress={handleClose}>
<View style={styles.arrowLineMain} />
<View style={styles.arrowLineTop} />
<View style={styles.arrowLineBottom} />
</Pressable>
</Animated.View>
</Animated.View>
)}
</Modal>
</View>
);
}
// ─── INDIVIDUAL CARD ITEM ─────────────────────────────────────────────────────
function CardItem({
card,
onPress,
hidden
}: {
card: CardData;
onPress: (card: CardData, x: number, y: number, w: number, h: number) => void;
hidden: boolean;
}) {
const animatedRef = useAnimatedRef<{ measure: (callback: (x: number, y: number, width: number, height: number, pageX: number, pageY: number) => void) => void }>();
const handlePress = () => {
if (!animatedRef.current) return;
animatedRef.current.measure((x: number, y: number, width: number, height: number, pageX: number, pageY: number) => {
onPress(card, pageX, pageY, width, height);
});
};
return (
<Pressable onPress={handlePress} style={{ opacity: hidden ? 0 : 1 }}>
<Animated.View ref={animatedRef} style={styles.card}>
<Image source={{ uri: card.image }} style={styles.cardImage} />
<View style={styles.cardTextContainer}>
<Text style={styles.cardCategory}>{card.category}</Text>
<Text style={styles.cardTitle}>{card.title}</Text>
</View>
</Animated.View>
</Pressable>
);
}
// ─── STYLES ─────────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
scrollContent: {
paddingHorizontal: 20,
paddingBottom: 40,
},
headerTitle: {
fontSize: 34,
fontWeight: '700',
color: '#FFF',
marginBottom: 20,
},
card: {
height: 380,
width: '100%',
borderRadius: 24,
marginBottom: 30,
overflow: 'hidden',
backgroundColor: '#1C1C1E',
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.3,
shadowRadius: 20,
elevation: 10,
},
cardImage: {
...StyleSheet.absoluteFill as any,
width: '100%',
height: '100%',
resizeMode: 'cover',
},
cardTextContainer: {
position: 'absolute',
top: 24,
left: 24,
right: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.5,
shadowRadius: 4,
elevation: 5,
},
cardCategory: {
color: 'rgba(255,255,255,0.7)',
fontSize: 15,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 4,
},
cardTitle: {
color: '#FFF',
fontSize: 28,
fontWeight: '700',
lineHeight: 34,
},
// Overlay
overlay: {
zIndex: 1000,
},
activeCard: {
backgroundColor: '#000',
},
activeImageContainer: {
height: 380,
width: '100%',
overflow: 'hidden',
},
activeImage: {
width: '100%',
height: '100%',
resizeMode: 'cover',
},
expandedContent: {
padding: 24,
paddingTop: 30,
},
expandedText: {
color: 'rgba(255,255,255,0.8)',
fontSize: 17,
lineHeight: 26,
},
closeButtonContainer: {
position: 'absolute',
left: 20,
zIndex: 1001,
},
closeButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: 'rgba(0,0,0,0.5)',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
},
arrowLineMain: {
position: 'absolute',
width: 14,
height: 2,
backgroundColor: '#FFF',
borderRadius: 1,
},
arrowLineTop: {
position: 'absolute',
width: 8,
height: 2,
backgroundColor: '#FFF',
borderRadius: 1,
transform: [{ rotate: '-45deg' }],
left: 8,
top: 11,
},
arrowLineBottom: {
position: 'absolute',
width: 8,
height: 2,
backgroundColor: '#FFF',
borderRadius: 1,
transform: [{ rotate: '45deg' }],
left: 8,
top: 19,
},
});
Usage Examples
Basic Usage
Render a scrollable list of expandable cards.
Example Code
import { ExpandableCardList } from './ExpandableCard';
const CARDS = [
{
id: '1',
category: 'Featured',
title: 'The Great Outdoors',
image: 'https://...',
content: 'Long description...'
}
];
export default function App() {
return <ExpandableCardList data={CARDS} />;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
dataRequired | CardData[] | - | An array of objects containing id, category, title, image, and content for each card. |
Features
- Flawless 60fps UI thread animations
- App Store style scroll-aware expansion
- Physics-based spring transitions
- Dynamic full-screen image overlay
- Built-in absolute coordinate tracking
Dependencies
Required
react-native-reanimatedreact-native-safe-area-context