RoxyAPI

Menu

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

14 min read
By Michael Anderson
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.

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:

  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/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}/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.