Home / Blog

How to Create a Card Shuffle Stack in Articulate Storyline with JavaScript

By Nathan Stoker · April 26, 2026 · 17 min read

How to Create a Card Shuffle Stack in Articulate Storyline with JavaScript

What if your cards could shuffle like a deck? Imagine learners clicking a card and watching it bounce to the front while the rest slide into new positions. In this tutorial, you'll learn how to create a card shuffle stack in Articulate Storyline using JavaScript and GSAP.

This is Part 4, the finale, of a four-part series on creating interactive card experiences in Storyline:

By the end of this tutorial, you'll have a deck of cards that shuffle dynamically, with satisfying scale and glow animations, automatic position recalculation, visited state tracking with smart desaturation timing, and completion detection. Let's build the grand finale.

Check out the demo and download the file in the header to follow along.

Why Card Shuffle Stacks Engage Learners

There's something inherently satisfying about shuffling cards. The card shuffle stack taps into that tactile pleasure by letting learners physically reorganize content. Each click creates movement, providing immediate feedback that their actions matter.

This pattern works exceptionally well for prioritization exercises, exploring options in any order, content that benefits from comparison, and gamified learning experiences. The visual feedback of cards moving and stacking creates a memorable interaction.

What You'll Build

The card shuffle stack interaction displays five cards in a horizontal row, slightly overlapping like a fanned deck. When clicked, a card scales up with a glowing highlight, then slides to the leftmost position while other cards shift right. Each card tracks its visited state and displays grayscale after being clicked.

The first card in the stack starts marked as visited. When all cards have been clicked at least once, a completion variable triggers. The animation uses GSAP timelines to coordinate scale, glow, and position changes into one fluid motion.

Setting Up Your Storyline Project

This interaction requires careful positioning of your initial card layout.

Create Your Card Elements

Import five images for your cards. Position them in a horizontal row with slight overlap, creating a fanned deck appearance. The leftmost card should be fully visible, with each subsequent card peeking out from behind.

The z-index (stacking order) matters. In Storyline's Timeline panel, arrange your cards so the leftmost card is on top (highest in the stacking order) and each card to the right is progressively lower.

Find Your data-model-id Values

Publish your project and collect the data-model-id for each card image. You'll need five IDs, one for each card in your deck.

Create Your Storyline Variables

Create these True/False variables with default values of false:

Variable Name

Purpose

option1

Tracks if Card 1 has been clicked

option2

Tracks if Card 2 has been clicked

option3

Tracks if Card 3 has been clicked

option4

Tracks if Card 4 has been clicked

option5

Tracks if Card 5 has been clicked

3_Changes

Triggers when all cards are complete

The completion variable name 3_Changes is from the original implementation. You can rename it to something like allCardsViewed if you prefer, just update the code to match.

Understanding the Shuffle System

The card shuffle stack uses several interconnected systems to create smooth, coordinated animations.

Position Capture

On initialization, the code captures the horizontal position of each card:

function captureCardPositions() {
  cardPositions = cardElements.map(cardData => {
    const rect = cardData.element.getBoundingClientRect();
    return rect.left;
  });
  cardPositions.sort((a, b) => a - b);
  console.log("Card positions (left to right):", cardPositions);
}

These positions become the "slots" that cards animate between. When a card shuffles to the front, it moves to position 0 (leftmost), and other cards shift to fill the remaining slots.

Stack Order Management

The code maintains an array tracking which card is in which position:

function updateStackOrder(movedCardIndex) {
  const currentIndex = stackOrder.indexOf(movedCardIndex);
  if (currentIndex > -1) {
    stackOrder.splice(currentIndex, 1);
  }
  stackOrder.unshift(movedCardIndex);

  stackOrder.forEach((cardIndex, position) => {
    const z = BASE_Z_INDEX + (stackOrder.length - position);
    cardElements[cardIndex].element.style.zIndex = z;
  });
}

When a card moves to the front, it gets removed from its current position in the array and added to the beginning. The z-index of all cards updates accordingly so the front card displays on top.

The Shuffle Animation

The animation combines multiple effects into a single GSAP timeline:

const tl = gsap.timeline({
  onComplete: () => {
    isAnimating = false;
    // Desaturation logic here
  }
});

// Scale and glow effect on clicked card
tl.to(element, {
  scale: 1.15,
  filter: glowFilter,
  duration: 0.2,
  ease: "power2.out"
})
.to(element, {
  scale: 1.08,
  duration: 0.3,
  ease: "back.out(1.7)"
})
.to(element, {
  scale: 1.02,
  duration: 0.4,
  ease: "elastic.out(1, 0.5)"
})
.to(element, {
  scale: 1,
  filter: finalFilter,
  duration: 0.2,
  ease: "power2.out"
});

// Simultaneously animate all cards to new positions
stackOrder.forEach((cardIndex, position) => {
  const cardData = cardElements[cardIndex];
  const currentX = cardData.element.getBoundingClientRect().left;
  const targetX = cardPositions[position];
  const moveX = targetX - currentX;

  tl.to(cardData.element, {
    x: gsap.getProperty(cardData.element, "x") + moveX,
    duration: 0.9,
    ease: "power2.inOut"
  }, 0);
});

The clicked card gets a bouncy scale animation with a glowing drop-shadow. At the same time (, 0 at the end starts these animations at timeline position 0), all cards slide to their new positions.

Smart Desaturation Timing

The visited state system includes special logic for the last card:

function shuffleToTop(cardData, cardIndex) {
  // Check if this will be the last card
  const willBeLastCard = visitedCards.size === CARDS.length - 1 && !visitedCards.has(cardIndex);
  
  visitedCards.add(cardIndex);
  
  // In onComplete:
  // Desaturate the previous top card immediately
  if (previousTopCardIndex !== undefined && previousTopCardIndex !== stackOrder[0]) {
    desaturatedCards.add(previousTopCardIndex);
    gsap.to(cardElements[previousTopCardIndex].element, {
      filter: "grayscale(100%)",
      duration: 0.5
    });
  }

  // If this was the last card, delay its desaturation
  if (willBeLastCard && !isCardDesaturated) {
    desaturatedCards.add(cardIndex);
    gsap.to(element, {
      filter: "grayscale(100%)",
      duration: 0.5,
      delay: 1.0  // 1 second delay for the last card
    });
  }
}

When a card leaves the front position, it desaturates immediately. But the very last card (when all others are visited) gets a one-second delay before desaturating. This gives learners a moment to see the final card in full color before the completion state kicks in.

Complete Code

Create a JavaScript trigger that executes when the timeline starts. Make sure GSAP is loaded first using the loader from Part 1. Add this complete code:

(function () {
  if (typeof gsap === "undefined") {
    console.error("GSAP missing");
    return;
  }

  // ─────── CONFIG ───────
  // Replace these data-model-id values with your own
  const BASE_Z_INDEX = 10;
  const doneVariable = "3_Changes"; // Completion variable name

  const CARDS = [
    { front: "6FVdeoPrwmD", varName: "option1" },
    { front: "6kaT33WMQb2", varName: "option2" },
    { front: "5l4QlrxlQBJ", varName: "option3" },
    { front: "66ID5TxDUcb", varName: "option4" },
    { front: "6UM7uYwce5F", varName: "option5" }
  ];

  // ─────── STATE ───────
  let cardElements = [];
  let stackOrder = [];
  let cardPositions = [];
  let isAnimating = false;
  let visitedCards = new Set();
  let desaturatedCards = new Set();

  // ─────── POSITION CAPTURE ───────
  function captureCardPositions() {
    cardPositions = cardElements.map(cardData => {
      const rect = cardData.element.getBoundingClientRect();
      return rect.left;
    });
    cardPositions.sort((a, b) => a - b);
    console.log("Card positions (left to right):", cardPositions);
  }

  // ─────── STACK ORDER MANAGEMENT ───────
  function updateStackOrder(movedCardIndex) {
    const currentIndex = stackOrder.indexOf(movedCardIndex);
    if (currentIndex > -1) {
      stackOrder.splice(currentIndex, 1);
    }
    stackOrder.unshift(movedCardIndex);

    stackOrder.forEach((cardIndex, position) => {
      const z = BASE_Z_INDEX + (stackOrder.length - position);
      cardElements[cardIndex].element.style.zIndex = z;
    });

    console.log("Updated stack:", stackOrder.map(i => cardElements[i].varName));
  }

  // ─────── COMPLETION CHECK ───────
  function updateMileage() {
    const player = typeof GetPlayer === "function" ? GetPlayer() : null;
    if (!player) return;

    const allTrue = CARDS.every(card => player.GetVar(card.varName) === true);

    if (allTrue) {
      player.SetVar(doneVariable, true);
      console.log(`✅ ${doneVariable} set to true`);
    }
  }

  // ─────── SHUFFLE ANIMATION ───────
  function shuffleToTop(cardData, cardIndex) {
    const { element, varName } = cardData;
    console.log(`Shuffling ${varName} to top`);

    // Set Storyline variable
    const player = typeof GetPlayer === "function" ? GetPlayer() : null;
    if (player) {
      player.SetVar(varName, true);
      console.log(`Set Storyline var ${varName} = true`);
    }

    // Check if this will be the last card before marking as visited
    const willBeLastCard = visitedCards.size === CARDS.length - 1 && !visitedCards.has(cardIndex);
    
    console.log(`Card ${varName} clicked. Visited: ${visitedCards.size}/${CARDS.length}. Will be last: ${willBeLastCard}`);

    visitedCards.add(cardIndex);

    // Prevent interactions during animation
    isAnimating = true;
    cardElements.forEach(card => {
      card.element.style.pointerEvents = "none";
    });

    // Store previous top card before updating
    const previousTopCardIndex = stackOrder[0];

    updateStackOrder(cardIndex);

    const isCardDesaturated = desaturatedCards.has(cardIndex);

    // Create animation timeline
    const tl = gsap.timeline({
      onComplete: () => {
        isAnimating = false;
        cardElements.forEach(card => {
          card.element.style.pointerEvents = "auto";
        });

        // Desaturate the previous top card
        if (previousTopCardIndex !== undefined && previousTopCardIndex !== stackOrder[0]) {
          desaturatedCards.add(previousTopCardIndex);
          gsap.to(cardElements[previousTopCardIndex].element, {
            filter: "grayscale(100%)",
            duration: 0.5,
            ease: "power2.out"
          });
        }

        // If this was the last card, delay its desaturation
        if (willBeLastCard && !isCardDesaturated) {
          desaturatedCards.add(cardIndex);
          gsap.to(element, {
            filter: "grayscale(100%)",
            duration: 0.5,
            ease: "power2.out",
            delay: 1.0
          });
          console.log(`Last card ${varName} will be desaturated with 1s delay`);
        }
      }
    });

    // Determine filter values based on desaturation state
    const baseFilter = isCardDesaturated ? "grayscale(100%)" : "";
    const glowFilter = isCardDesaturated 
      ? "grayscale(100%) drop-shadow(0 0 20px rgba(255, 255, 255, 0.3))" 
      : "drop-shadow(0 0 20px rgba(255, 255, 255, 0.3))";
    const finalFilter = isCardDesaturated ? "grayscale(100%)" : "";

    // Scale and glow animation
    tl.to(element, {
      scale: 1.15,
      filter: glowFilter,
      duration: 0.2,
      ease: "power2.out"
    })
    .to(element, {
      scale: 1.08,
      duration: 0.3,
      ease: "back.out(1.7)"
    })
    .to(element, {
      scale: 1.02,
      duration: 0.4,
      ease: "elastic.out(1, 0.5)"
    })
    .to(element, {
      scale: 1,
      filter: finalFilter,
      duration: 0.2,
      ease: "power2.out"
    });

    // Position animations for all cards (start at timeline position 0)
    stackOrder.forEach((cardIdx, position) => {
      const card = cardElements[cardIdx];
      const currentX = card.element.getBoundingClientRect().left;
      const targetX = cardPositions[position];
      const moveX = targetX - currentX;

      tl.to(card.element, {
        x: gsap.getProperty(card.element, "x") + moveX,
        duration: 0.9,
        ease: "power2.inOut"
      }, 0);
    });

    updateMileage();
  }

  // ─────── HOVER EFFECTS ───────
  function addHoverEffects(element) {
    let isHovering = false;
    const enter = () => {
      if (!isAnimating && !isHovering) {
        isHovering = true;
        gsap.to(element, { scale: 1.03, duration: 0.3, ease: "power2.out" });
      }
    };
    const leave = () => {
      if (!isAnimating && isHovering) {
        isHovering = false;
        gsap.to(element, { scale: 1, duration: 0.3, ease: "power2.out" });
      }
    };
    element.addEventListener("mouseenter", enter);
    element.addEventListener("mouseleave", leave);
    return { enter, leave };
  }

  // ─────── CARD INITIALIZATION ───────
  function initCard({ front, varName }) {
    const element = document.querySelector(`[data-model-id="${front}"]`);
    if (!element) {
      console.warn("Missing element for", varName);
      return null;
    }

    // Set up 3D context
    const parent = element.parentElement;
    if (parent) {
      parent.style.perspective = "1000px";
      parent.style.transformStyle = "preserve-3d";
    }

    element.style.transformStyle = "preserve-3d";
    element.style.transformOrigin = "center center";
    element.style.cursor = "pointer";
    element.style.transition = "none";

    const hoverHandlers = addHoverEffects(element);

    const cardData = { element, varName, hoverHandlers };
    const cardIndex = cardElements.length;
    element.addEventListener("click", () => shuffleToTop(cardData, cardIndex));

    return cardData;
  }

  // ─────── INITIALIZATION ───────
  function initializeCards() {
    let initialized = 0;

    CARDS.forEach((card, index) => {
      const cardData = initCard(card);
      if (cardData) {
        cardElements.push(cardData);
        stackOrder.push(index);
        cardData.element.style.zIndex = BASE_Z_INDEX + (CARDS.length - 1 - index);
        initialized++;
      }
    });

    if (initialized < CARDS.length) {
      setTimeout(initializeCards, 500);
    } else {
      // Mark first card as visited on load
      const player = typeof GetPlayer === "function" ? GetPlayer() : null;
      if (player) {
        player.SetVar("option1", true);
        console.log("Set Storyline var option1 = true (initial)");
      }
      visitedCards.add(0);
      
      setTimeout(() => {
        captureCardPositions();
        console.log("Cards initialized and positions recorded.");
      }, 100);
    }
  }

  // ─────── START ───────
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initializeCards);
  } else {
    initializeCards();
  }

  // ─────── PUBLIC API ───────
  window.cardShuffleSystem = {
    cardElements,
    stackOrder,
    shuffleToTop: index => {
      if (cardElements[index]) {
        shuffleToTop(cardElements[index], index);
      }
    },
    getStackOrder: () => stackOrder.map(i => cardElements[i].varName),
    resetAll: () => {
      cardElements.forEach((cardData, index) => {
        gsap.set(cardData.element, { x: 0 });
        cardData.element.style.zIndex = BASE_Z_INDEX + (CARDS.length - 1 - index);
        cardData.element.style.filter = "none";
      });
      stackOrder = cardElements.map((_, i) => i);
      visitedCards.clear();
      desaturatedCards.clear();
      console.log("Cards reset to initial stack order.");
    }
  };
})();

Configuring the Code for Your Project

After pasting the code, customize these sections.

Update the CARDS Array

Replace the data-model-id values with your own:

const CARDS = [
  { front: "YOUR_CARD_1_ID", varName: "option1" },
  { front: "YOUR_CARD_2_ID", varName: "option2" },
  { front: "YOUR_CARD_3_ID", varName: "option3" },
  { front: "YOUR_CARD_4_ID", varName: "option4" },
  { front: "YOUR_CARD_5_ID", varName: "option5" }
];

The order in this array should match the visual order of your cards from left to right.

Adjust the Completion Variable

If you renamed your completion variable, update this line:

const doneVariable = "allCardsViewed"; // Your variable name

Customize the Animation Feel

Modify the timing and easing to match your desired feel:

// Scale animation durations
duration: 0.2,  // Initial pop
duration: 0.3,  // Settle back
duration: 0.4,  // Elastic bounce
duration: 0.2,  // Final settle

// Position animation
duration: 0.9,  // Card sliding duration
ease: "power2.inOut"  // Smooth acceleration/deceleration

The elastic easing (elastic.out) creates the bouncy feel. Reduce the duration for snappier animations or increase it for more dramatic movement.

Troubleshooting Common Issues

Here are solutions to problems you might encounter.

Cards Don't Move to Correct Positions

The position capture must happen after cards are fully rendered. If cards appear in the wrong spots, the initial position calculation may have run too early. Increase the delay in initialization:

setTimeout(() => {
  captureCardPositions();
}, 200); // Try 200ms or higher

Z-Index Issues

If cards don't stack correctly after shuffling, check that your BASE_Z_INDEX is high enough to override other elements on the slide. Try increasing it:

const BASE_Z_INDEX = 100; // Higher starting point

Hover Effects Interfere with Animation

The code disables pointer events during animation, but if you still see issues, verify that isAnimating is being set and cleared correctly. Check the console for animation completion messages.

Cards Jump Instead of Sliding

This usually indicates a problem with the GSAP x property calculation. The code uses gsap.getProperty() to get the current x transform before adding the movement delta. Ensure GSAP is fully loaded before initialization.

Extending the Interaction

Once your basic shuffle works, consider these enhancements.

Add Sound Effects

Trigger a card shuffle sound through Storyline:

// After shuffleToTop is called
const player = GetPlayer();
if (player) {
  player.SetVar("playShuffleSound", true);
}

Create a Storyline trigger that plays audio when this variable changes.

Show Card Content

Display information about the top card using Storyline layers or text boxes:

// In shuffleToTop, after updating stack
const topCardName = cardElements[stackOrder[0]].varName;
player.SetVar("currentCard", topCardName);

Use Storyline triggers to show different content based on the currentCard variable.

Auto-Shuffle Demo

Create an automatic demonstration that shuffles through all cards:

window.autoShuffleDemo = async function() {
  for (let i = 1; i < cardElements.length; i++) {
    await new Promise(resolve => setTimeout(resolve, 1500));
    shuffleToTop(cardElements[i], i);
  }
};

Call window.autoShuffleDemo() from a Storyline button to show learners how the interaction works.

Accessibility Considerations

Card shuffle interactions present significant accessibility challenges. The visual-spatial nature of the shuffling metaphor may not translate well to screen reader users.

Consider providing an alternative list-based view that presents the same content without requiring visual interaction. Add ARIA live regions to announce when cards change position:

// Add to the page
const announcer = document.createElement('div');
announcer.setAttribute('aria-live', 'polite');
announcer.setAttribute('aria-atomic', 'true');
announcer.style.position = 'absolute';
announcer.style.left = '-9999px';
document.body.appendChild(announcer);

// In shuffleToTop
announcer.textContent = `${varName} moved to front of stack`;

Keyboard support would require implementing arrow key navigation to select cards and Enter to shuffle the selected card to the front.

Series Conclusion

Congratulations! You've completed all four parts of the card interactions series:

  1. 3D Flip Cards taught you the fundamentals of GSAP animation and 3D transforms in Storyline

  2. Expanding Card Overlays introduced responsive design and device detection

  3. Click-to-Expand Cards covered element cloning and position tracking

  4. Card Shuffle Stack brought it all together with complex state management and coordinated animations

Each tutorial built on the previous ones, introducing new techniques while reinforcing core patterns. The skills you've learned apply far beyond card interactions. Element targeting with data-model-id, GSAP animation timelines, state management, and Storyline variable integration form the foundation for virtually any advanced JavaScript interaction.

What's Next for You

Now that you understand these patterns, consider combining techniques from multiple tutorials. Create flip cards that expand when clicked. Build shuffle stacks with overlay descriptions. The possibilities multiply when you mix and match.

Share what you build! I'd love to see how you adapt these interactions for your learning experiences. Drop a comment below or connect with me on LinkedIn to show off your creations.

Thank you for following along with this series. Happy developing!