ExpandableCard
Card with smooth expand/collapse animation, rotating arrow, and customizable content.
9:41
signal_cellular_altwifibattery_full
cabin
Nordic Cabin
Weekend getaway
keyboard_arrow_down
Experience serene mountain views and cozy interiors.
Wi-FiParking
$120/night
Preview Controls
Animation
Smooth expand/collapse with rotating arrow
This is a CSS-based preview of the animation. The actual React Native component uses physics-based springs for a more natural feel.
Installation
$
Usage
Example.tsx
import { View, Text, Image } from 'react-native';import { ExpandableCard } from './components/ExpandableCard';export default function App() {return (<View style={{ flex: 1, padding: 16 }}><ExpandableCardtitle="Nordic Cabin"subtitle="Weekend getaway in Oslo"thumbnail={require('./assets/cabin.jpg')}onToggle={(expanded) => console.log('Expanded:', expanded)}><Text style={{ color: '#1A1A1A', marginBottom: 12 }}>Experience the vibrant colors of fall in a serene mountain getaway.</Text><View style={{ flexDirection: 'row', gap: 8 }}><Text style={{ backgroundColor: '#F5F5F5', paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4, fontSize: 12 }}>Wi-Fi</Text><Text style={{ backgroundColor: '#F5F5F5', paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4, fontSize: 12 }}>Parking</Text></View></ExpandableCard></View>);}
Full Component Code
ExpandableCard.tsx
1import React, { useState, useCallback } from 'react';2import { View, Text, StyleSheet, TouchableOpacity, Image, ViewStyle, StyleProp, ImageSourcePropType } from 'react-native';3import Animated, { useSharedValue, useAnimatedStyle, withTiming, Easing, interpolate, Extrapolation } from 'react-native-reanimated';45interface ExpandableCardProps {6 title: string;7 subtitle?: string;8 thumbnail?: ImageSourcePropType;9 children: React.ReactNode;10 duration?: number;11 initialExpanded?: boolean;12 onToggle?: (expanded: boolean) => void;13 style?: StyleProp<ViewStyle>;14}1516export const ExpandableCard: React.FC<ExpandableCardProps> = ({17 title, subtitle, thumbnail, children,18 duration = 200, initialExpanded = false, onToggle, style,19}) => {20 const [expanded, setExpanded] = useState(initialExpanded);21 const [contentHeight, setContentHeight] = useState(0);22 const progress = useSharedValue(initialExpanded ? 1 : 0);23 const rotation = useSharedValue(initialExpanded ? 180 : 0);2425 const timingConfig = { duration, easing: Easing.bezier(0.25, 0.1, 0.25, 1) };2627 const handleToggle = useCallback(() => {28 const newExpanded = !expanded;29 setExpanded(newExpanded);30 progress.value = withTiming(newExpanded ? 1 : 0, timingConfig);31 rotation.value = withTiming(newExpanded ? 180 : 0, timingConfig);32 onToggle?.(newExpanded);33 }, [expanded, onToggle]);3435 const animatedContentStyle = useAnimatedStyle(() => ({36 height: interpolate(progress.value, [0, 1], [0, contentHeight || 200], Extrapolation.CLAMP),37 opacity: interpolate(progress.value, [0, 0.5, 1], [0, 0, 1]),38 overflow: 'hidden',39 }));4041 const animatedArrowStyle = useAnimatedStyle(() => ({42 transform: [{ rotate: \`\${rotation.value}deg\` }],43 }));4445 return (46 <Animated.View style={[styles.container, style]}>47 <TouchableOpacity onPress={handleToggle} activeOpacity={0.9}>48 <View style={styles.header}>49 {thumbnail && <Image source={thumbnail} style={styles.thumbnail} />}50 <View style={styles.textContainer}>51 <Text style={styles.title}>{title}</Text>52 {subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}53 </View>54 <Animated.View style={[styles.toggleButton, { backgroundColor: expanded ? '#E16C2D' : 'rgba(225,108,45,0.1)' }]}>55 <Animated.Text style={[styles.arrow, animatedArrowStyle, { color: expanded ? '#FFF' : '#E16C2D' }]}>▼</Animated.Text>56 </Animated.View>57 </View>58 </TouchableOpacity>59 <Animated.View style={animatedContentStyle}>60 <View onLayout={(e) => setContentHeight(e.nativeEvent.layout.height)}>61 <View style={styles.divider} />62 <View style={styles.content}>{children}</View>63 </View>64 </Animated.View>65 </Animated.View>66 );67};6869const styles = StyleSheet.create({70 container: { backgroundColor: '#FFF', borderRadius: 12, borderWidth: 1, borderColor: '#E5E5E5', overflow: 'hidden' },71 header: { flexDirection: 'row', alignItems: 'center', padding: 16, gap: 12 },72 thumbnail: { width: 56, height: 56, borderRadius: 8 },73 textContainer: { flex: 1 },74 title: { fontSize: 16, fontWeight: '700', color: '#1A1A1A' },75 subtitle: { fontSize: 14, color: '#6B6B6B' },76 toggleButton: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' },77 arrow: { fontSize: 12 },78 divider: { height: 1, backgroundColor: '#E5E5E5' },79 content: { padding: 16 },80});
Props
| Name | Type | Default | Description |
|---|---|---|---|
| title | string | - | Card title |
| subtitle | string | - | Card subtitle |
| thumbnail | ImageSourcePropType | - | Thumbnail image source |
| children | React.ReactNode | - | Expanded content |
| duration | number | 200 | Animation duration in ms |
| initialExpanded | boolean | false | Initial expanded state |
| onToggle | (expanded: boolean) => void | - | Callback when expanded state changes |
