Skip to main content

Overview

Luz de Arcanos features smooth 3D card flip animations that reveal tarot cards one by one with a 700ms stagger. The animations use pure CSS transforms with preserve-3d for realistic depth.

3D card flip mechanics

The card flip effect uses CSS 3D transforms to create a realistic turning animation:

HTML structure

<div class="card" id="card-0">
  <div class="card-inner">
    <div class="card-face card-back">
      <div class="card-back-pattern"></div>
    </div>
    <div class="card-face card-front" id="card-front-0"></div>
  </div>
</div>
  • .card: Outer container with perspective
  • .card-inner: Transform container with preserve-3d
  • .card-back: Visible face before flip
  • .card-front: Hidden face that appears after flip (rotated 180°)

CSS 3D transforms

Perspective and preservation

.card {
  width: 260px;
  height: 430px;
  perspective: 2400px;
  cursor: default;
}

.card-inner {
  width: 100%;
  height: 100%;
  position: relative;
  transform-style: preserve-3d;
  transition: transform 1.2s cubic-bezier(0.45, 0.05, 0.55, 0.95);
}
transform-style: preserve-3d is critical - it ensures child elements maintain their 3D position in space rather than being flattened.

Flip transformation

.card.flipped .card-inner {
  transform: rotateY(180deg);
}
When the .flipped class is added, the inner container rotates 180 degrees around the Y-axis over 1.2 seconds.

Card face positioning

Backface visibility

.card-face {
  position: absolute;
  inset: 0;
  border-radius: var(--radius-card);
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  overflow: hidden;
}
backface-visibility: hidden prevents the back of each face from showing through during the flip animation.

Card back (initial state)

.card-back {
  background: var(--surface);
  border: 2px solid var(--gold-dim);
  display: flex;
  align-items: center;
  justify-content: center;
}

.card-back-pattern {
  width: 80%;
  height: 80%;
  border: 1px solid var(--gold-dim);
  border-radius: 8px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 2.5rem;
  opacity: 0.5;
  background:
    repeating-linear-gradient(
      45deg,
      transparent,
      transparent 6px,
      rgba(201, 168, 76, 0.05) 6px,
      rgba(201, 168, 76, 0.05) 12px
    );
}

Card front (revealed state)

.card-front {
  transform: rotateY(180deg);
  border: 2px solid var(--gold);
  background: var(--bg);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
}
The front is pre-rotated 180° so when the container flips, it becomes visible.

Reversed cards

When a card is drawn reversed, an additional 180° rotation is applied:
.card-front.reversed-card {
  transform: rotateY(180deg) rotate(180deg);
}
This combines the base flip rotation with a full card rotation to display it upside-down.

Sequential reveal timing

Cards flip one at a time with 700ms intervals:
function flipCards() {
  [0, 1, 2].forEach((i) => {
    setTimeout(() => {
      document.getElementById(`card-${i}`)?.classList.add('flipped');
    }, i * 700);
  });
}
  • t=0ms: Card 0 (Past) flips
  • t=700ms: Card 1 (Present) flips
  • t=1400ms: Card 2 (Future) flips

Card population

Before flipping, card content is dynamically injected:
function populateCard(index: number, card: TarotCard) {
  const front = document.getElementById(`card-front-${index}`)!;

  if (card.reversed) {
    front.classList.add('reversed-card');
  }

  front.innerHTML = `
    <img
      src="/cards/${card.image}"
      alt="${card.name}"
      class="card-image"
      loading="lazy"
    />
    <span class="card-name">${card.name}</span>
  `;
}

Card image styling

.card-image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center top;
}

.card-name {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  font-family: 'Cinzel', serif;
  font-size: 0.75rem;
  letter-spacing: 0.08em;
  color: var(--gold);
  text-transform: uppercase;
  line-height: 1.3;
  padding: 0.6rem 0.4rem 0.5rem;
  background: linear-gradient(to top, rgba(13, 10, 26, 0.95) 70%, transparent);
  z-index: 1;
}
The card name appears at the bottom with a gradient background to ensure readability.

Video background

The application features a video background with overlay:
.video-bg {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  z-index: -2;
  pointer-events: none;
}

.video-overlay {
  position: fixed;
  inset: 0;
  background: rgba(13, 10, 26, 0.75);
  z-index: -1;
  pointer-events: none;
}
The overlay provides a 75% opacity dark layer that ensures text readability while maintaining atmospheric depth.

Loading spinner

While the AI generates the reading, a spinning mystical symbol appears:
.loading-spinner {
  font-size: 3.5rem;
  display: block;
  animation: spin-pulse 2s ease-in-out infinite;
  margin-bottom: 1.5rem;
}

@keyframes spin-pulse {
  0%   { transform: rotate(0deg) scale(1);   opacity: 1; }
  50%  { transform: rotate(180deg) scale(1.15); opacity: 0.7; }
  100% { transform: rotate(360deg) scale(1); opacity: 1; }
}
The animation combines rotation with pulsing scale and opacity for a mystical effect.

Reading text fade-in

Each paragraph of the reading fades in sequentially:
.reading-box p {
  line-height: 1.85;
  margin-bottom: 1.25rem;
  color: var(--text);
  font-size: 1.2rem;
  opacity: 0;
  animation: fade-in-up 0.6s ease forwards;
}

@keyframes fade-in-up {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}
Paragraphs are rendered with staggered delays:
function renderReading(text: string) {
  const paragraphs = text
    .split(/\n{2,}/)
    .map((p) => p.trim())
    .filter(Boolean);

  readingBox.innerHTML = paragraphs
    .map((p, i) => `<p style="animation-delay:${i * 0.25}s">${p.replace(/\n/g, ' ')}</p>`)
    .join('');
}
Each paragraph delays by 250ms (0.25s × index).

Complete animation sequence

  1. User submits form (t=0ms)
  2. Card content populated immediately
  3. View switches to reading section
  4. 200ms delay for DOM paint
  5. Past card flips (t=200ms)
  6. Present card flips (t=900ms)
  7. Future card flips (t=1600ms)
  8. AI reading completes (variable time)
  9. Paragraphs fade in (250ms stagger)

Reset animation

When starting a new reading, cards are reset:
function resetCards() {
  [0, 1, 2].forEach((i) => {
    const card  = document.getElementById(`card-${i}`);
    const front = document.getElementById(`card-front-${i}`);
    card?.classList.remove('flipped');
    front?.classList.remove('reversed-card');
    if (front) front.innerHTML = '';
  });
}
This removes flip classes, clears reversed states, and empties the card content.
Always reset cards before populating new ones to prevent visual glitches from previous readings.