How to Build a Celtic Cross Tarot Spread UI (React Native + API Tutorial)
aster the legendary 10-card Celtic Cross spread layout with React Native. Complete tutorial covering API integration, card positioning, animations, and tap interactions for the most comprehensive tarot reading available.
How to Build a Celtic Cross Tarot Spread UI (React Native + API Tutorial)
The Celtic Cross is the most iconic and comprehensive tarot spread, used by professional readers worldwide for over a century. Its 10-card layout reveals the complete picture of any situation through distinct positions: Present Situation, Challenge, Past influences, Future possibilities, and Final Outcome.
Building a Celtic Cross UI is challenging: you need precise card positioning, handle 10 different interpretations, manage complex tap interactions, and present information without overwhelming users. This tutorial shows you how to build a production-ready Celtic Cross spread feature using the RoxyAPI Tarot API and React Native.
What You Will Build
By the end of this tutorial, you will have:
- Celtic Cross Layout - Authentic 10-card positioning with traditional cross and staff arrangement
- API Integration - Fetch and display complete spread readings with seeded reproducibility
- Interactive Cards - Tap to flip and reveal detailed interpretations for each position
- Card Animations - Smooth entrance animations and flip transitions
- Reading Summary - Synthesized guidance combining all positions
- Share Feature - Export reading as image or shareable link
Prerequisites
- React Native development environment (Expo or bare React Native)
- RoxyAPI Tarot API Key (get one here)
- Basic knowledge of React hooks and animations
Understanding the Celtic Cross Spread
Position Layout
The Celtic Cross uses two distinct formations:
The Cross (6 cards):
[5]
↑
[3] [1][2] [4]
↓
[6]
The Staff (4 cards):
[10]
[9]
[8]
[7]
Position Meanings
- Present Situation - What is happening now, heart of the matter
- Challenge - Immediate obstacle or problem crossing your path
- Distant Past - Root causes and foundation of the situation
- Near Future - What approaches in the next few weeks/months
- Above - Your conscious goal, aspiration, best outcome
- Below - Subconscious foundation, underlying feelings
- Advice - Recommended approach for addressing challenges
- External Influences - People, energies, events beyond your control
- Hopes and Fears - Your desires and anxieties (often intertwined)
- Final Outcome - Where everything is headed if current course continues
API Overview
Endpoint
POST https://roxyapi.com/api/v2/tarot/celtic-cross
Request
{
"question": "What should I focus on in my career?",
"seed": "optional-seed-for-reproducibility"
}
Parameters:
question(optional): User's specific question for the readingseed(optional): String for reproducible results (same seed = same cards)
Response
{
"spread": "Celtic Cross",
"question": "What should I focus on in my career?",
"seed": "reading-abc123",
"positions": [
{
"position": 1,
"name": "Present Situation",
"interpretation": "Represents what is happening to you at the present time...",
"card": {
"id": "magician",
"name": "The Magician",
"arcana": "major",
"reversed": false,
"keywords": ["manifestation", "resourcefulness", "power", "action"],
"meaning": "The Magician is the bridge between the world of the spirit...",
"imageUrl": "https://roxyapi.com/img/tarot/major/magician.jpg"
}
},
// ... 9 more positions
],
"summary": "The Celtic Cross provides deep insight into your situation..."
}
Key Fields:
positions: Array of 10 position objects in order (1-10)positions[n].card: Full card details with keywords and meaningpositions[n].interpretation: What this position represents in the spreadsummary: Overall reading guidance
Step 1: API Service
Create the service layer for fetching readings:
// services/tarotAPI.js
const TAROT_API_KEY = process.env.EXPO_PUBLIC_TAROT_API_KEY;
const BASE_URL = 'https://roxyapi.com/api/v2/tarot';
export async function fetchCelticCross(question, seed = null) {
const response = await fetch(`${BASE_URL}/celtic-cross`, {
method: 'POST',
headers: {
'X-API-Key': TAROT_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
question: question || undefined,
seed: seed || undefined,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `API error: ${response.status}`);
}
return response.json();
}
Step 2: Main Component Structure
// screens/CelticCrossScreen.jsx
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
ScrollView,
StyleSheet,
ActivityIndicator,
} from 'react-native';
import { fetchCelticCross } from '../services/tarotAPI';
import CelticCrossLayout from '../components/CelticCrossLayout';
export default function CelticCrossScreen() {
const [question, setQuestion] = useState('');
const [reading, setReading] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
async function performReading() {
if (!question.trim()) {
setError('Please enter a question');
return;
}
setLoading(true);
setError(null);
try {
const data = await fetchCelticCross(question);
setReading(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
if (reading) {
return (
<CelticCrossLayout
reading={reading}
onNewReading={() => {
setReading(null);
setQuestion('');
}}
/>
);
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.title}>Celtic Cross Spread</Text>
<Text style={styles.subtitle}>
The most comprehensive tarot reading. Ask about any situation and receive
deep insight through 10 cards.
</Text>
<View style={styles.form}>
<Text style={styles.label}>Your Question</Text>
<TextInput
style={styles.input}
value={question}
onChangeText={setQuestion}
placeholder="What should I focus on in my career?"
placeholderTextColor="#9ca3af"
multiline
numberOfLines={3}
textAlignVertical="top"
/>
{error && (
<Text style={styles.error}>{error}</Text>
)}
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={performReading}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="white" />
) : (
<Text style={styles.buttonText}>Draw Cards</Text>
)}
</TouchableOpacity>
</View>
<View style={styles.guide}>
<Text style={styles.guideTitle}>Position Guide</Text>
<Text style={styles.guideText}>
1. Present Situation{'\n'}
2. Challenge{'\n'}
3. Distant Past{'\n'}
4. Near Future{'\n'}
5. Above (Goal){'\n'}
6. Below (Subconscious){'\n'}
7. Advice{'\n'}
8. External Influences{'\n'}
9. Hopes and Fears{'\n'}
10. Final Outcome
</Text>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f9fafb',
},
content: {
padding: 20,
},
title: {
fontSize: 32,
fontWeight: 'bold',
color: '#1f2937',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#6b7280',
lineHeight: 24,
marginBottom: 32,
},
form: {
backgroundColor: 'white',
borderRadius: 16,
padding: 20,
marginBottom: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},
label: {
fontSize: 16,
fontWeight: '600',
color: '#374151',
marginBottom: 8,
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
padding: 12,
fontSize: 16,
color: '#1f2937',
minHeight: 80,
marginBottom: 16,
},
error: {
color: '#ef4444',
fontSize: 14,
marginBottom: 16,
},
button: {
backgroundColor: '#6366f1',
borderRadius: 8,
padding: 16,
alignItems: 'center',
},
buttonDisabled: {
opacity: 0.5,
},
buttonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
guide: {
backgroundColor: 'white',
borderRadius: 16,
padding: 20,
},
guideTitle: {
fontSize: 18,
fontWeight: '600',
color: '#1f2937',
marginBottom: 12,
},
guideText: {
fontSize: 14,
color: '#6b7280',
lineHeight: 24,
},
});
Step 3: Celtic Cross Layout Component
This is the core component that renders the 10-card layout:
// components/CelticCrossLayout.jsx
import React, { useState, useEffect } from 'react';
import {
View,
Text,
TouchableOpacity,
ScrollView,
StyleSheet,
Dimensions,
Animated,
} from 'react-native';
import CelticCrossCard from './CelticCrossCard';
import PositionDetail from './PositionDetail';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const CARD_WIDTH = (SCREEN_WIDTH - 80) / 5; // 5 columns max
const CARD_HEIGHT = CARD_WIDTH * 1.5;
export default function CelticCrossLayout({ reading, onNewReading }) {
const [selectedPosition, setSelectedPosition] = useState(null);
const [animatedValues] = useState(
reading.positions.map(() => new Animated.Value(0))
);
useEffect(() => {
// Stagger card entrance animations
const animations = reading.positions.map((_, index) =>
Animated.timing(animatedValues[index], {
toValue: 1,
duration: 300,
delay: index * 100,
useNativeDriver: true,
})
);
Animated.stagger(50, animations).start();
}, []);
function renderCard(position) {
const positionData = reading.positions[position - 1];
const animatedValue = animatedValues[position - 1];
return (
<CelticCrossCard
key={position}
position={positionData}
animatedValue={animatedValue}
onPress={() => setSelectedPosition(positionData)}
/>
);
}
if (selectedPosition) {
return (
<PositionDetail
position={selectedPosition}
onClose={() => setSelectedPosition(null)}
/>
);
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Celtic Cross Reading</Text>
{reading.question && (
<Text style={styles.question}>{reading.question}</Text>
)}
<TouchableOpacity style={styles.newButton} onPress={onNewReading}>
<Text style={styles.newButtonText}>New Reading</Text>
</TouchableOpacity>
</View>
{/* Instruction */}
<Text style={styles.instruction}>Tap any card to view interpretation</Text>
{/* The Cross (Cards 1-6) */}
<View style={styles.crossContainer}>
{/* Top card (5) */}
<View style={styles.crossRow}>
<View style={[styles.cardSpace, { opacity: 0 }]} />
<View style={styles.cardSpace}>
{renderCard(5)}
</View>
<View style={[styles.cardSpace, { opacity: 0 }]} />
</View>
{/* Middle row (3, 1, 2, 4) */}
<View style={styles.crossRow}>
<View style={styles.cardSpace}>
{renderCard(3)}
</View>
<View style={styles.cardSpace}>
{renderCard(1)}
{/* Card 2 overlays card 1 at 90 degrees */}
<View style={styles.crossingCard}>
{renderCard(2)}
</View>
</View>
<View style={styles.cardSpace}>
{renderCard(4)}
</View>
</View>
{/* Bottom card (6) */}
<View style={styles.crossRow}>
<View style={[styles.cardSpace, { opacity: 0 }]} />
<View style={styles.cardSpace}>
{renderCard(6)}
</View>
<View style={[styles.cardSpace, { opacity: 0 }]} />
</View>
</View>
{/* The Staff (Cards 7-10) */}
<View style={styles.staffContainer}>
<Text style={styles.staffTitle}>The Staff</Text>
<View style={styles.staffCards}>
{renderCard(7)}
{renderCard(8)}
{renderCard(9)}
{renderCard(10)}
</View>
</View>
{/* Summary */}
{reading.summary && (
<View style={styles.summary}>
<Text style={styles.summaryTitle}>Overall Guidance</Text>
<Text style={styles.summaryText}>{reading.summary}</Text>
</View>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f9fafb',
},
content: {
padding: 20,
paddingBottom: 40,
},
header: {
marginBottom: 24,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#1f2937',
marginBottom: 8,
},
question: {
fontSize: 16,
color: '#6b7280',
fontStyle: 'italic',
marginBottom: 16,
},
newButton: {
alignSelf: 'flex-start',
backgroundColor: '#6366f1',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
newButtonText: {
color: 'white',
fontSize: 14,
fontWeight: '600',
},
instruction: {
fontSize: 14,
color: '#6b7280',
textAlign: 'center',
marginBottom: 24,
},
crossContainer: {
alignSelf: 'center',
marginBottom: 32,
},
crossRow: {
flexDirection: 'row',
justifyContent: 'center',
},
cardSpace: {
width: CARD_WIDTH,
height: CARD_HEIGHT,
margin: 4,
position: 'relative',
},
crossingCard: {
position: 'absolute',
top: 0,
left: 0,
width: CARD_WIDTH,
height: CARD_HEIGHT,
transform: [{ rotate: '90deg' }],
},
staffContainer: {
marginBottom: 32,
},
staffTitle: {
fontSize: 18,
fontWeight: '600',
color: '#1f2937',
textAlign: 'center',
marginBottom: 16,
},
staffCards: {
flexDirection: 'row',
justifyContent: 'space-around',
flexWrap: 'wrap',
},
summary: {
backgroundColor: '#eef2ff',
borderRadius: 12,
padding: 20,
},
summaryTitle: {
fontSize: 18,
fontWeight: '600',
color: '#4f46e5',
marginBottom: 12,
},
summaryText: {
fontSize: 14,
color: '#374151',
lineHeight: 22,
},
});
Step 4: Individual Card Component
// components/CelticCrossCard.jsx
import React from 'react';
import {
View,
Text,
TouchableOpacity,
Image,
StyleSheet,
Animated,
} from 'react-native';
export default function CelticCrossCard({ position, animatedValue, onPress }) {
const scale = animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [0.8, 1],
});
const opacity = animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
return (
<Animated.View style={[{ transform: [{ scale }], opacity }]}>
<TouchableOpacity
style={[
styles.card,
position.card.reversed && styles.cardReversed,
]}
onPress={onPress}
activeOpacity={0.8}
>
<Image
source={{ uri: position.card.imageUrl }}
style={styles.cardImage}
resizeMode="cover"
/>
<View style={styles.positionBadge}>
<Text style={styles.positionNumber}>{position.position}</Text>
</View>
{position.card.reversed && (
<View style={styles.reversedBadge}>
<Text style={styles.reversedText}>R</Text>
</View>
)}
</TouchableOpacity>
</Animated.View>
);
}
const styles = StyleSheet.create({
card: {
width: '100%',
height: '100%',
backgroundColor: 'white',
borderRadius: 8,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
cardReversed: {
transform: [{ rotate: '180deg' }],
},
cardImage: {
width: '100%',
height: '100%',
},
positionBadge: {
position: 'absolute',
top: 4,
left: 4,
backgroundColor: '#6366f1',
borderRadius: 12,
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
},
positionNumber: {
color: 'white',
fontSize: 12,
fontWeight: 'bold',
},
reversedBadge: {
position: 'absolute',
top: 4,
right: 4,
backgroundColor: '#ef4444',
borderRadius: 10,
width: 20,
height: 20,
alignItems: 'center',
justifyContent: 'center',
},
reversedText: {
color: 'white',
fontSize: 10,
fontWeight: 'bold',
},
});
Step 5: Position Detail View
// components/PositionDetail.jsx
import React from 'react';
import {
View,
Text,
Image,
ScrollView,
TouchableOpacity,
StyleSheet,
Dimensions,
} from 'react-native';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
export default function PositionDetail({ position, onClose }) {
return (
<View style={styles.container}>
<ScrollView style={styles.scrollView} contentContainerStyle={styles.content}>
{/* Close Button */}
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Text style={styles.closeText}>✕</Text>
</TouchableOpacity>
{/* Position Header */}
<View style={styles.header}>
<View style={styles.positionBadge}>
<Text style={styles.positionNumber}>{position.position}</Text>
</View>
<Text style={styles.positionName}>{position.name}</Text>
</View>
{/* Card Image */}
<View style={styles.imageContainer}>
<Image
source={{ uri: position.card.imageUrl }}
style={[
styles.cardImage,
position.card.reversed && styles.cardImageReversed,
]}
resizeMode="contain"
/>
{position.card.reversed && (
<View style={styles.reversedLabel}>
<Text style={styles.reversedText}>Reversed</Text>
</View>
)}
</View>
{/* Card Name */}
<Text style={styles.cardName}>{position.card.name}</Text>
{/* Keywords */}
<View style={styles.keywords}>
{position.card.keywords.map((keyword, index) => (
<View key={index} style={styles.keywordChip}>
<Text style={styles.keywordText}>{keyword}</Text>
</View>
))}
</View>
{/* Position Interpretation */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Position Meaning</Text>
<Text style={styles.sectionText}>{position.interpretation}</Text>
</View>
{/* Card Meaning */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Card Meaning</Text>
<Text style={styles.sectionText}>{position.card.meaning}</Text>
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f9fafb',
},
scrollView: {
flex: 1,
},
content: {
padding: 20,
paddingBottom: 40,
},
closeButton: {
alignSelf: 'flex-end',
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#e5e7eb',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
closeText: {
fontSize: 20,
color: '#6b7280',
},
header: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 24,
},
positionBadge: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#6366f1',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
positionNumber: {
color: 'white',
fontSize: 18,
fontWeight: 'bold',
},
positionName: {
flex: 1,
fontSize: 24,
fontWeight: 'bold',
color: '#1f2937',
},
imageContainer: {
alignItems: 'center',
marginBottom: 24,
},
cardImage: {
width: SCREEN_WIDTH * 0.6,
height: SCREEN_WIDTH * 0.9,
borderRadius: 12,
},
cardImageReversed: {
transform: [{ rotate: '180deg' }],
},
reversedLabel: {
marginTop: 12,
backgroundColor: '#fef2f2',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1,
borderColor: '#ef4444',
},
reversedText: {
color: '#ef4444',
fontSize: 14,
fontWeight: '600',
},
cardName: {
fontSize: 28,
fontWeight: 'bold',
color: '#1f2937',
textAlign: 'center',
marginBottom: 16,
},
keywords: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
gap: 8,
marginBottom: 32,
},
keywordChip: {
backgroundColor: '#eef2ff',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
},
keywordText: {
color: '#6366f1',
fontSize: 14,
fontWeight: '500',
},
section: {
marginBottom: 24,
},
sectionTitle: {
fontSize: 20,
fontWeight: '600',
color: '#1f2937',
marginBottom: 12,
},
sectionText: {
fontSize: 16,
color: '#374151',
lineHeight: 24,
},
});
Advanced Features
1. Reproducible Readings
Use seeds for shareable readings:
import { v4 as uuidv4 } from 'uuid';
async function createShareableReading(question) {
const readingId = uuidv4();
const reading = await fetchCelticCross(question, readingId);
const shareUrl = `https://yourapp.com/readings/${readingId}`;
return {
reading,
shareUrl,
};
}
2. Reading History
Store only the seed for retrieval:
async function saveReading(reading) {
await AsyncStorage.setItem(
`reading_${Date.now()}`,
JSON.stringify({
date: new Date().toISOString(),
question: reading.question,
seed: reading.seed,
})
);
}
async function loadPastReading(savedReading) {
return fetchCelticCross(savedReading.question, savedReading.seed);
}
3. Animated Card Reveal
Flip cards one by one:
const [revealedCards, setRevealedCards] = useState([]);
useEffect(() => {
const timer = setInterval(() => {
setRevealedCards(prev => {
if (prev.length < 10) {
return [...prev, prev.length + 1];
}
clearInterval(timer);
return prev;
});
}, 500);
return () => clearInterval(timer);
}, []);
// In render:
{revealedCards.includes(position.position) && renderCard(position)}
4. Export as Image
Use react-native-view-shot:
import ViewShot from 'react-native-view-shot';
const viewShotRef = useRef();
async function exportReading() {
const uri = await viewShotRef.current.capture();
await Share.share({
message: 'Check out my Celtic Cross reading!',
url: uri,
});
}
// In render:
<ViewShot ref={viewShotRef}>
<CelticCrossLayout reading={reading} />
</ViewShot>
Production Best Practices
1. Loading States
Show skeleton while cards load:
{loading && (
<View style={styles.skeletonGrid}>
{Array(10).fill(0).map((_, i) => (
<View key={i} style={styles.skeletonCard} />
))}
</View>
)}
2. Error Handling
Graceful fallbacks for API failures:
if (error) {
return (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity onPress={retryReading}>
<Text style={styles.retryButton}>Try Again</Text>
</TouchableOpacity>
</View>
);
}
3. Performance Optimization
Memoize card components:
const CelticCrossCard = React.memo(({ position, animatedValue, onPress }) => {
// ... component code
}, (prevProps, nextProps) => {
return prevProps.position.position === nextProps.position.position;
});
4. Accessibility
Add screen reader support:
<TouchableOpacity
accessible={true}
accessibilityLabel={`Position ${position.position}: ${position.name}. Card: ${position.card.name}`}
accessibilityHint="Double tap to view interpretation"
onPress={onPress}
>
Troubleshooting
Q: Cards overlap incorrectly in the cross
A: Ensure card 2 has position: 'absolute' and transform: [{ rotate: '90deg' }] to overlay card 1.
Q: Animations lag on lower-end devices
A: Use useNativeDriver: true in all Animated calls and reduce stagger delay.
Q: Images load slowly
A: Implement react-native-fast-image with aggressive caching:
import FastImage from 'react-native-fast-image';
<FastImage
source={{
uri: position.card.imageUrl,
cache: FastImage.cacheControl.immutable,
}}
/>
Q: Text too small on tablets
A: Use PixelRatio or responsive font scaling:
const fontSize = Platform.isPad ? 20 : 16;
Next Steps
You now have a complete Celtic Cross spread feature! Enhance further with:
- Audio Guidance - Narrated position interpretations
- Spread Comparison - Side-by-side view of past readings
- Custom Positions - Let users define position meanings
- Print Layout - Generate PDF of reading
- Journaling Integration - Add notes to each position
Resources
- API Documentation: roxyapi.com/docs
- Product Page: roxyapi.com/products/tarot-api
- Pricing: roxyapi.com/pricing
- Support: [email protected]
About the Author: Michael Anderson is a full-stack developer specializing in spiritual and wellness applications, with experience building complex tarot spread layouts, card reading interfaces, and interactive divination tools for mobile platforms.