How to Build a Celtic Cross Tarot Spread UI (React Native + API Tutorial)

14 min read
Brett Calloway
TarotReact NativeCeltic CrossSpreads

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.

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:

  1. Celtic Cross Layout - Authentic 10-card positioning with traditional cross and staff arrangement
  2. API Integration - Fetch and display complete spread readings with seeded reproducibility
  3. Interactive Cards - Tap to flip and reveal detailed interpretations for each position
  4. Card Animations - Smooth entrance animations and flip transitions
  5. Reading Summary - Synthesized guidance combining all positions
  6. 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

  1. Present Situation - What is happening now, heart of the matter
  2. Challenge - Immediate obstacle or problem crossing your path
  3. Distant Past - Root causes and foundation of the situation
  4. Near Future - What approaches in the next few weeks/months
  5. Above - Your conscious goal, aspiration, best outcome
  6. Below - Subconscious foundation, underlying feelings
  7. Advice - Recommended approach for addressing challenges
  8. External Influences - People, energies, events beyond your control
  9. Hopes and Fears - Your desires and anxieties (often intertwined)
  10. Final Outcome - Where everything is headed if current course continues

API Overview

Endpoint

POST https://roxyapi.com/api/v2/tarot/spreads/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 reading
  • seed (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 meaning
  • positions[n].interpretation: What this position represents in the spread
  • summary: 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}/spreads/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:

  1. Audio Guidance - Narrated position interpretations
  2. Spread Comparison - Side-by-side view of past readings
  3. Custom Positions - Let users define position meanings
  4. Print Layout - Generate PDF of reading
  5. Journaling Integration - Add notes to each position

Resources


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.