DEV Community

Karan Desai
Karan Desai

Posted on

How to Build a Tarot Card Reading Widget in JavaScript

The problem

A client's wellness blog wanted a "card of the day" tarot widget on the homepage. Embedded, no full-page navigation, click-to-draw. They wanted me to ship it before their Monday newsletter went out. I had a Friday afternoon.

Here is the working widget, in vanilla HTML, CSS, and JS, that ended up on their site.

What I used

  • DivineAPI for the daily tarot endpoint
  • Vanilla JavaScript (no framework, drops into any site)
  • ~80 lines of HTML + CSS + JS total
  • 20 minutes of actual work, plus 30 minutes I lost to a stupid bug (more on that)

Step 1: Get your API key

Sign up at divineapi.com, 14-day free trial, credit card required. From the dashboard you grab two things:

  1. The api_key value (goes in the request body)
  2. An Authorization token (goes in the request header as Bearer {token})

Both are required. The brief I was working from had only the api_key, which is why I lost half an hour debugging 401s. Don't be me.

Step 2: The HTML structure

Minimal markup, semantic enough that you can style it however you want.

<div class="tarot-widget" id="tarot-widget">
  <h2 class="tarot-widget__title">Your Card of the Day</h2>

  <div class="tarot-widget__card" id="tarot-card" hidden>
    <img class="tarot-widget__image" id="tarot-image" alt="" />
    <div class="tarot-widget__meta">
      <h3 class="tarot-widget__name" id="tarot-name"></h3>
      <span class="tarot-widget__orientation" id="tarot-orientation"></span>
    </div>
    <div class="tarot-widget__interpretation" id="tarot-interpretation"></div>
    <div class="tarot-widget__tabs">
      <button data-area="love">Love</button>
      <button data-area="career">Career</button>
      <button data-area="finance">Finance</button>
    </div>
  </div>

  <button class="tarot-widget__draw" id="tarot-draw">Draw a Card</button>
  <p class="tarot-widget__hint" id="tarot-hint">Click the button above to see today's card.</p>
</div>
Enter fullscreen mode Exit fullscreen mode

Single container, image on top, name + orientation, interpretation text that switches between three life areas (love, career, finance) on tab click.

Step 3: The JavaScript

Here's the full widget script. It's a single self-contained block, no dependencies.

const API_URL = 'https://astroapi-5.divineapi.com/api/v2/daily-tarot';
const AUTH_TOKEN = 'YOUR_AUTH_TOKEN';   // from your DivineAPI dashboard
const API_KEY = 'YOUR_API_KEY';

const drawBtn = document.getElementById('tarot-draw');
const card = document.getElementById('tarot-card');
const hint = document.getElementById('tarot-hint');
const image = document.getElementById('tarot-image');
const nameEl = document.getElementById('tarot-name');
const orientationEl = document.getElementById('tarot-orientation');
const interpretationEl = document.getElementById('tarot-interpretation');
const tabs = document.querySelectorAll('.tarot-widget__tabs button');

let currentReading = null;

async function drawCard() {
  drawBtn.disabled = true;
  drawBtn.textContent = 'Drawing...';

  // The endpoint expects multipart/form-data, not JSON.
  // Send via FormData. (yes, i found this out the hard way)
  const form = new FormData();
  form.append('api_key', API_KEY);
  form.append('lan', 'en');           // optional. 25 languages supported

  try {
    const response = await fetch(API_URL, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${AUTH_TOKEN}`
        // do NOT set Content-Type yourself when using FormData,
        // the browser sets the boundary for you
      },
      body: form
    });

    if (!response.ok) {
      throw new Error(`API returned ${response.status}`);
    }

    const json = await response.json();
    currentReading = json.data;
    renderCard(currentReading);
  } catch (err) {
    hint.textContent = `Could not draw a card: ${err.message}`;
    console.error(err);
  } finally {
    drawBtn.disabled = false;
    drawBtn.textContent = 'Draw Again';
  }
}

function renderCard(data) {
  hint.hidden = true;
  card.hidden = false;

  image.src = data.image;
  image.alt = data.card;
  nameEl.textContent = data.card;
  orientationEl.textContent = data.category;   // "Upright" or "Reverse"
  interpretationEl.textContent = data.love;    // default to love panel
  // mark love tab as active
  tabs.forEach(t => t.classList.toggle('is-active', t.dataset.area === 'love'));
}

tabs.forEach(tab => {
  tab.addEventListener('click', () => {
    if (!currentReading) return;
    const area = tab.dataset.area;
    interpretationEl.textContent = currentReading[area];
    tabs.forEach(t => t.classList.toggle('is-active', t === tab));
  });
});

drawBtn.addEventListener('click', drawCard);
Enter fullscreen mode Exit fullscreen mode

What comes back from the endpoint:

{
  "success": 1,
  "data": {
    "card": "SIX OF WANDS",
    "category": "Reverse",
    "career": "This reversed card solely depicts your failure...",
    "love": "This card depicts that you are not stringy mentally...",
    "finance": "This card reversed depicts a complete failure in your life...",
    "image": "https://divineapi.com/admin/uploads/daily_tarot/14_2.jpg",
    "image2": "https://divineapi.com/admin/uploads/daily_tarot/14.jpg"
  }
}
Enter fullscreen mode Exit fullscreen mode

Three interpretation strings (career, love, finance), a card name, an upright-or-reversed flag, and an image URL hosted on DivineAPI's CDN. Perfect for a single-card widget without any extra asset hosting.

Step 4: The CSS

Just enough to look clean. Drop it in a <style> tag or your stylesheet.

.tarot-widget {
  font-family: system-ui, -apple-system, sans-serif;
  max-width: 360px;
  padding: 1.5rem;
  border-radius: 16px;
  background: linear-gradient(160deg, #1a1530 0%, #2d1b4e 100%);
  color: #f3eefb;
  text-align: center;
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
}

.tarot-widget__title {
  font-size: 1.1rem;
  margin: 0 0 1rem;
  letter-spacing: 0.05em;
  color: #d4b8ff;
}

.tarot-widget__image {
  width: 140px;
  height: auto;
  border-radius: 8px;
  margin: 0 auto 0.75rem;
  display: block;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}

.tarot-widget__name {
  font-size: 1.25rem;
  margin: 0;
}

.tarot-widget__orientation {
  display: inline-block;
  margin-top: 0.25rem;
  padding: 2px 10px;
  font-size: 0.75rem;
  background: rgba(255, 255, 255, 0.12);
  border-radius: 999px;
}

.tarot-widget__interpretation {
  margin: 1rem 0;
  font-size: 0.9rem;
  line-height: 1.5;
  text-align: left;
  min-height: 6rem;
}

.tarot-widget__tabs {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1rem;
}

.tarot-widget__tabs button {
  flex: 1;
  padding: 0.5rem;
  background: transparent;
  color: #f3eefb;
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: 6px;
  cursor: pointer;
  font-size: 0.85rem;
}

.tarot-widget__tabs button.is-active {
  background: rgba(212, 184, 255, 0.15);
  border-color: #d4b8ff;
}

.tarot-widget__draw {
  width: 100%;
  padding: 0.75rem;
  background: #d4b8ff;
  color: #1a1530;
  border: none;
  border-radius: 8px;
  font-weight: 600;
  cursor: pointer;
  font-size: 0.95rem;
}

.tarot-widget__draw:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}
Enter fullscreen mode Exit fullscreen mode

That's the whole thing. ~80 lines of code, drops into any HTML page.

The part where I got stuck

Two stupid bugs cost me 30 minutes.

Bug 1: Content-Type: application/json instead of FormData. I tried sending the request with JSON.stringify({api_key: 'YOUR_KEY'}) and Content-Type: application/json because that is what every modern API does. The endpoint returned a 422. I read the error twice, swore mildly, and finally checked the docs. The endpoint expects multipart/form-data, not JSON. Switching to FormData fixed it instantly.

The other catch with FormData: do NOT manually set Content-Type: multipart/form-data on the request. The browser needs to add a boundary string, and if you set the header yourself you'll override that and it breaks. Just pass the FormData object as the body and let the browser handle the headers.

Bug 2: missing Authorization header. The first version of my code only included the api_key in the body. Got 401s every time. The endpoint needs both: api_key in the body AND Authorization: Bearer {token} in the header. The dashboard gives you two separate values. Make sure you're using the right one in each place.

Lesson learned: when an API gives you two different credentials, it's because both are required. Always.

What I'd do differently

A few things I'd refactor if I were doing this for real production rather than a Friday afternoon ship:

  1. Move the API call to your backend. I have the auth token in the frontend code above for tutorial clarity. In production, never expose that to the client. Proxy through your own backend so the token stays server-side.
  2. Cache the response per-day. The daily tarot card changes once every 24 hours. No reason to hit the API on every page load. Cache server-side, serve the same response to every user that day.
  3. Add a loading skeleton. The "Drawing..." text is functional but a card-shaped shimmer would feel nicer.
  4. Internationalise the tab labels. I hardcoded "Love", "Career", "Finance" in English. The API supports 25 languages via the lan parameter; the UI should follow.

Wrapping up

That is a self-contained tarot card widget in vanilla JavaScript, 20 minutes of work once you know which endpoints to hit and which content type to send.

Worth knowing: DivineAPI ships 27 different tarot reading endpoints beyond just daily tarot, including yes/no, love triangle, past-present-future, ex-flame reading, fortune cookie, Egyptian prediction, and more. Same auth pattern, same FormData approach, different response shapes. So once you have the daily tarot working, the rest are quick to add as additional widgets.

If you don't want to write any code at all, they also ship pre-built tarot widgets at divineapi.com/widgets/tarot that you can embed directly. Useful if your client just wants the feature live without dev time.

Full docs at developers.divineapi.com, with the live "Run In Postman" button on every endpoint page so you can poke at the request and response before writing any code.

If you ship something with this, drop a comment. Always curious to see what people build.

Top comments (0)