How to Create 3D Flip Cards in Articulate Storyline with JavaScript and GSAP
By Nathan Stoker · April 5, 2026 · 15 min read

Want to add that satisfying card-flip effect to your e-learning courses? The kind where learners click a card and it rotates in 3D to reveal information on the back? In this tutorial, you'll learn how to create 3D flip cards in Articulate Storyline using JavaScript and the GSAP animation library.
This is Part 1 of a four-part series on creating interactive card experiences in Storyline:
Part 1: 3D Flip Cards (you are here)
Part 2: Expanding Card Overlays
Part 3: Click-to-Expand Cards
Part 4: Card Shuffle Stack
By the end of this tutorial, you'll have flip cards that rotate smoothly in 3D space, track which cards learners have visited, and report completion to Storyline variables. Let's dive in.
Check out the demo and download the file links in the header to follow along.
Why Use JavaScript for Flip Cards?
Storyline's built-in states and animations can create basic flip effects, but they have limitations. JavaScript with GSAP gives you true 3D rotation with realistic perspective, smooth easing that feels natural, precise control over timing and sequencing, and the ability to add visited states and completion tracking.
The GSAP library handles the complex math behind 3D transforms, so you can focus on the learning experience rather than wrestling with CSS.
What You'll Build
The flip card interaction includes four cards that flip from front to back when clicked. Each card tracks whether it's been visited using Storyline variables. Visited cards display a subtle grayscale effect so learners can see their progress. When all cards have been flipped at least once, a master completion variable triggers.
This pattern works perfectly for vocabulary terms, concept explanations, before/after comparisons, or any content where you want learners to discover information through interaction.
Setting Up Your Storyline Project
Before writing any code, you need to set up your Storyline file with the right elements and variables.
Create Your Card Elements
You'll need two images for each card: a front and a back. For this tutorial, we're using four card pairs, giving us eight total images. Import your images into Storyline and position each front/back pair so they overlap exactly. The front image should be visible initially, with the back image hidden behind it.
Name your images descriptively in the Timeline panel. While the names don't affect the code directly, clear naming like "Card1_Front" and "Card1_Back" helps you stay organized.
Find Your data-model-id Values
Here's where Storyline's published output becomes important. Every element in your project gets a unique data-model-id attribute when published. This is how our JavaScript will find and manipulate each card.
To find these IDs, publish your project for Web and open the published story_content folder. Look in the HTML files for your slide, and search for your image elements. Each will have a data-model-id attribute like data-model-id="695E1jGDiIp".
You'll need to collect the data-model-id for each front and back image. Write these down because you'll add them to the JavaScript configuration.
Create Your Storyline Variables
In Storyline, create the following True/False variables with default values of false:
Variable Name | Purpose |
|---|---|
card1 | Tracks if Card 1 has been flipped |
card2 | Tracks if Card 2 has been flipped |
card3 | Tracks if Card 3 has been flipped |
card4 | Tracks if Card 4 has been flipped |
allCardsFlipped | Triggers when all cards are complete |
These variables let you create conditional navigation, show completion feedback, or unlock content after learners explore all cards.
Adding the GSAP Library
GSAP (GreenSock Animation Platform) is a professional-grade animation library that handles our 3D transforms. You need to load it before our flip card code runs.
Create a new JavaScript trigger that executes when the timeline starts on your slide. Add this code to load GSAP from a CDN:
var head = document.getElementsByTagName('head')[0];
var script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js';
script.type = 'text/javascript';
head.appendChild(script);
This dynamically loads the GSAP library when learners reach your slide. The library is lightweight and loads quickly from Cloudflare's CDN.
Understanding the Flip Card Code
Let's break down how the flip card system works before looking at the complete code.
The Configuration Object
The code starts with a configuration array that maps your Storyline elements to their variables:
const CARDS = [
{ front: "695E1jGDiIp", back: "5x1vEu5rdRU", varName: "card1" },
{ front: "5dlrByAbR38", back: "6Wua08W4HZN", varName: "card2" },
{ front: "5gZ5DGlFOcl", back: "69XsR3KNEH0", varName: "card3" },
{ front: "6qoWeD59IaM", back: "5hdIUXWQBFD", varName: "card4" }
];
Each object contains the data-model-id for the front image, the data-model-id for the back image, and the Storyline variable name to update when flipped.
The 3D Setup
For 3D flips to work correctly, both the front and back elements need specific CSS properties. The code sets backface-visibility: hidden so you only see the front of each surface. It also sets transform-style: preserve-3d to maintain the 3D space during rotation.
The back card starts rotated 180 degrees on the Y-axis, so it's initially facing away from the viewer:
gsap.set(frontEl, { rotateY: 0 });
gsap.set(backEl, { rotateY: -180 });
The Flip Animation
When a learner clicks a card, the code creates a GSAP timeline that animates both elements simultaneously:
const flipTimeline = gsap.timeline({
onComplete: () => {
flipped = !flipped;
applyVisitedState(cardIndex);
checkAllCardsComplete();
}
});
flipTimeline
.to(frontEl, {
rotateY: frontAngle,
duration: FLIP_DURATION,
ease: "power2.inOut"
}, 0)
.to(backEl, {
rotateY: backAngle,
duration: FLIP_DURATION,
ease: "power2.inOut"
}, 0);
The 0 at the end of each .to() call tells GSAP to start both animations at the same time, creating the synchronized flip effect.
Visited State Tracking
Once a card has been flipped, it gets marked as "visited" with a grayscale filter. This provides visual feedback so learners know which cards they've already explored:
function applyVisitedState(cardIndex) {
const cardData = cardElements[cardIndex];
if (!cardData) return;
const { frontEl, backEl } = cardData;
[frontEl, backEl].forEach(el => {
el.classList.add('card-visited');
gsap.to(el, {
filter: `grayscale(${VISITED_DESATURATION}%)`,
duration: 0.3,
ease: "power2.out"
});
});
}
The desaturation percentage is configurable. A value of 80% creates a noticeable but not harsh grayscale effect.
Complete Code
Create a second JavaScript trigger that executes when the timeline starts, but set it to run after the GSAP loader (you can add a slight delay or use a trigger that fires later in the timeline). Add this complete code:
(function () {
if (typeof gsap === "undefined") {
console.error("❌ GSAP missing");
return;
}
// Dummy fallback to suppress undefined error
window.registerGSAPAnimationEffects = window.registerGSAPAnimationEffects || function () {
console.warn("⚠️ registerGSAPAnimationEffects is not defined — using dummy function.");
};
// ─────── CONFIG ───────
// Replace these data-model-id values with your own from published output
const CARDS = [
{ front: "695E1jGDiIp", back: "5x1vEu5rdRU", varName: "card1" },
{ front: "5dlrByAbR38", back: "6Wua08W4HZN", varName: "card2" },
{ front: "5gZ5DGlFOcl", back: "69XsR3KNEH0", varName: "card3" },
{ front: "6qoWeD59IaM", back: "5hdIUXWQBFD", varName: "card4" }
];
const FLIP_DURATION = 0.6; // Duration of flip animation in seconds
const VISITED_DESATURATION = 80; // Percentage of grayscale for visited cards
const ALL_CARDS_DONE_VAR = "allCardsFlipped"; // Storyline variable for completion
// ─────── STATE ───────
let cardElements = [];
let visitedCards = new Set(); // Track which cards have been flipped
let isAnimating = false; // Prevent multiple simultaneous animations
// ─────── UTILITY FUNCTIONS ───────
function getPlayer() {
return typeof GetPlayer === "function" ? GetPlayer() : null;
}
function setStorylineVar(name, value) {
const player = getPlayer();
if (player) {
try {
player.SetVar(name, value);
console.log(`✅ Set Storyline var ${name} = ${value}`);
} catch (e) {
console.error(`❌ Failed to set Storyline variable ${name}:`, e);
}
}
}
function checkAllCardsComplete() {
const allFlipped = CARDS.every((_, index) => visitedCards.has(index));
if (allFlipped) {
setStorylineVar(ALL_CARDS_DONE_VAR, true);
console.log(`🎉 All cards flipped! Set ${ALL_CARDS_DONE_VAR} = true`);
}
console.log(`📊 Progress: ${visitedCards.size}/${CARDS.length} cards flipped`);
}
function applyVisitedState(cardIndex) {
const cardData = cardElements[cardIndex];
if (!cardData) return;
const { frontEl, backEl } = cardData;
// Apply desaturation to both front and back
[frontEl, backEl].forEach(el => {
el.classList.add('card-visited');
gsap.to(el, {
filter: `grayscale(${VISITED_DESATURATION}%)`,
duration: 0.3,
ease: "power2.out"
});
});
console.log(`✅ Applied visited state to card ${cardIndex}`);
}
function addHoverEffects(frontEl, backEl, cardIndex) {
let isHovering = false;
const enterHover = () => {
if (!isAnimating && !isHovering) {
isHovering = true;
const currentEl = cardElements[cardIndex].flipped ? backEl : frontEl;
gsap.to(currentEl, {
scale: 1.05,
duration: 0.3,
ease: "power2.out"
});
}
};
const leaveHover = () => {
if (!isAnimating && isHovering) {
isHovering = false;
[frontEl, backEl].forEach(el => {
gsap.to(el, {
scale: 1,
duration: 0.3,
ease: "power2.out"
});
});
}
};
[frontEl, backEl].forEach(el => {
el.addEventListener("mouseenter", enterHover);
el.addEventListener("mouseleave", leaveHover);
});
return { enterHover, leaveHover };
}
// ─────── SVG HELPER FUNCTIONS ───────
function logSVGDetails(el, label) {
const svg = el.querySelector("svg");
if (!svg) return;
const data = {
label,
viewBox: svg.getAttribute("viewBox"),
svg_transform: svg.getAttribute("transform"),
svg_style: svg.getAttribute("style"),
};
const g = svg.querySelector("g");
const use = svg.querySelector("use");
data.g_transform = g?.getAttribute("transform") || "none";
data.g_style = g?.getAttribute("style") || "none";
data.use_transform = use?.getAttribute("transform") || "none";
data.use_style = use?.getAttribute("style") || "none";
console.table([data]);
}
function normalizeSVG(el) {
const svg = el.querySelector("svg");
if (!svg) return;
svg.removeAttribute("transform");
svg.removeAttribute("style");
const g = svg.querySelector("g");
if (g) {
g.removeAttribute("transform");
g.removeAttribute("style");
}
const use = svg.querySelector("use");
if (use) {
use.removeAttribute("transform");
use.removeAttribute("style");
}
}
// ─────── CARD INITIALIZATION ───────
function initCard({ front, back, varName }, cardIndex) {
const frontEl = document.querySelector(`[data-model-id="${front}"]`);
const backEl = document.querySelector(`[data-model-id="${back}"]`);
if (!frontEl || !backEl) {
console.warn("❌ Missing element:", front, back);
return null;
}
console.log(`🃏 BEFORE processing ${front}:`);
console.log('Front transform:', window.getComputedStyle(frontEl).transform);
console.log('Back transform:', window.getComputedStyle(backEl).transform);
console.log('Front bounds:', frontEl.getBoundingClientRect());
console.log('Back bounds:', backEl.getBoundingClientRect());
// Log all SVG attributes for debugging
logSVGDetails(frontEl, `Front: ${front}`);
logSVGDetails(backEl, `Back: ${back}`);
// Remove rogue SVG transforms/styles that can interfere with animations
normalizeSVG(frontEl);
normalizeSVG(backEl);
// Set up 3D transform properties on both elements
[frontEl, backEl].forEach(el => {
el.style.position = "absolute";
el.style.top = "0";
el.style.left = "0";
el.style.transformStyle = "preserve-3d";
el.style.backfaceVisibility = "hidden";
el.style.transformOrigin = "center center";
el.style.margin = "0";
el.style.padding = "0";
el.style.boxSizing = "border-box";
el.style.cursor = "pointer";
});
// Check for size mismatches between front and back
const frontBounds = frontEl.getBoundingClientRect();
const backBounds = backEl.getBoundingClientRect();
if (
frontBounds.width !== backBounds.width ||
frontBounds.height !== backBounds.height
) {
console.warn("⚠️ Mismatched sizes on card pair:", { frontBounds, backBounds });
}
// Normalize sizes to prevent visual glitches
const matchedWidth = Math.min(frontBounds.width, backBounds.width);
const matchedHeight = Math.min(frontBounds.height, backBounds.height);
[frontEl, backEl].forEach(el => {
el.style.width = matchedWidth + "px";
el.style.height = matchedHeight + "px";
});
// Initialize GSAP transforms
gsap.set(frontEl, {
rotateY: 0,
rotateX: 0,
rotateZ: 0,
scale: 1,
transformOrigin: "center center",
force3D: true,
z: 0
});
gsap.set(backEl, {
rotateY: 0,
rotateX: 0,
rotateZ: 0,
scale: 1,
transformOrigin: "center center",
force3D: true,
z: 0
});
// Set initial rotation states
gsap.set(frontEl, { rotateY: 0 });
gsap.set(backEl, { rotateY: -180 });
console.log(`🃏 AFTER processing ${front}:`);
console.log('Front transform:', window.getComputedStyle(frontEl).transform);
console.log('Back transform:', window.getComputedStyle(backEl).transform);
console.log('Front bounds:', frontEl.getBoundingClientRect());
console.log('Back bounds:', backEl.getBoundingClientRect());
let flipped = false;
function flipCard() {
if (isAnimating) return; // Prevent multiple simultaneous flips
console.log(`🔄 Flipping card ${cardIndex}: ${varName}`);
// Mark card as visited on first flip
const wasAlreadyVisited = visitedCards.has(cardIndex);
if (!wasAlreadyVisited) {
visitedCards.add(cardIndex);
setStorylineVar(varName, true);
}
isAnimating = true;
// Calculate target angles based on current state
const frontAngle = flipped ? 0 : 180;
const backAngle = flipped ? -180 : 0;
const flipTimeline = gsap.timeline({
onComplete: () => {
isAnimating = false;
flipped = !flipped;
// Only apply visited state to newly flipped cards
if (!wasAlreadyVisited) {
applyVisitedState(cardIndex);
}
checkAllCardsComplete();
}
});
// For visited cards, maintain desaturation during flip
const currentFilter = wasAlreadyVisited ? `grayscale(${VISITED_DESATURATION}%)` : "none";
// Animate both sides simultaneously
flipTimeline
.to(frontEl, {
rotateY: frontAngle,
duration: FLIP_DURATION,
ease: "power2.inOut",
filter: currentFilter
}, 0)
.to(backEl, {
rotateY: backAngle,
duration: FLIP_DURATION,
ease: "power2.inOut",
filter: currentFilter
}, 0);
}
// Add hover effects for visual feedback
const hoverHandlers = addHoverEffects(frontEl, backEl, cardIndex);
// Attach click handlers to both sides
frontEl.addEventListener("click", flipCard);
backEl.addEventListener("click", flipCard);
const cardData = {
frontEl,
backEl,
varName,
flipped,
hoverHandlers
};
return cardData;
}
// ─────── INITIALIZATION ───────
function initializeCards() {
let initialized = 0;
CARDS.forEach((card, index) => {
const cardData = initCard(card, index);
if (cardData) {
cardElements.push(cardData);
initialized++;
}
});
if (initialized < CARDS.length) {
console.warn(`⚠️ Only ${initialized}/${CARDS.length} cards initialized. Retrying...`);
setTimeout(initializeCards, 500);
} else {
console.log(`✅ All ${initialized} cards initialized successfully`);
}
}
// ─────── PUBLIC API ───────
// Expose functions for debugging and external control
window.cardFlipSystem = {
getVisitedCards: () => Array.from(visitedCards),
getProgress: () => ({ visited: visitedCards.size, total: CARDS.length }),
resetAll: () => {
visitedCards.clear();
isAnimating = false;
cardElements.forEach((cardData, index) => {
const { frontEl, backEl } = cardData;
// Reset visual state
[frontEl, backEl].forEach(el => {
el.classList.remove('card-visited');
gsap.set(el, {
rotateY: index === 0 ? 0 : (el === frontEl ? 0 : -180),
scale: 1,
filter: "none"
});
});
cardData.flipped = false;
});
// Reset Storyline variables
CARDS.forEach(card => {
setStorylineVar(card.varName, false);
});
setStorylineVar(ALL_CARDS_DONE_VAR, false);
console.log("🔄 All cards reset to initial state");
},
flipCard: (index) => {
if (cardElements[index]) {
const { frontEl } = cardElements[index];
frontEl.click();
}
}
};
// ─────── START ───────
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeCards);
} else {
initializeCards();
}
})();
Configuring the Code for Your Project
After pasting the code, you need to customize three sections.
Update the CARDS Array
Replace the data-model-id values with the ones you collected from your published output:
const CARDS = [
{ front: "YOUR_FRONT_1_ID", back: "YOUR_BACK_1_ID", varName: "card1" },
{ front: "YOUR_FRONT_2_ID", back: "YOUR_BACK_2_ID", varName: "card2" },
{ front: "YOUR_FRONT_3_ID", back: "YOUR_BACK_3_ID", varName: "card3" },
{ front: "YOUR_FRONT_4_ID", back: "YOUR_BACK_4_ID", varName: "card4" }
];
Adjust Animation Settings
Customize the feel of your flip cards by modifying these constants:
const FLIP_DURATION = 0.6; // Increase for slower, more dramatic flips
const VISITED_DESATURATION = 80; // Lower for subtler visited state
Match Variable Names
Ensure the varName values match the Storyline variables you created earlier. The ALL_CARDS_DONE_VAR should match your completion variable name.
Troubleshooting Common Issues
If your cards aren't flipping correctly, check these common problems.
Cards Don't Appear or Flip
Open your browser's developer console (F12) and look for error messages. The most common issue is incorrect data-model-id values. Remember that these IDs change every time you republish, so you'll need to update them if you make changes to your Storyline file.
Cards Flip But Look Wrong
If the cards appear distorted or don't show the back correctly, check that your front and back images are exactly the same size in Storyline. Even a one-pixel difference can cause visual glitches. Also verify that both images are positioned at the exact same location on the slide.
Storyline Variables Not Updating
Make sure your variable names in the code exactly match the names in Storyline, including capitalization. Also verify that you're testing in Preview in Browser mode or the published output, as some JavaScript features don't work in Storyline's built-in preview.
GSAP Not Loading
If you see "GSAP missing" in the console, the library didn't load in time. Add a slight delay before the main code runs, or use a trigger that fires later in the timeline.
Extending the Interaction
Once you have the basic flip cards working, consider these enhancements.
Add Sound Effects
Trigger a Storyline audio clip when cards flip by adding a variable change that Storyline watches:
setStorylineVar("playFlipSound", true);
Then create a Storyline trigger that plays audio when playFlipSound changes to true.
Create Card Groups
For longer content, organize cards into groups that unlock sequentially. Modify the completion check to enable the next group of cards.
Add Flip Counters
Track how many times each card has been flipped for analytics:
let flipCounts = {};
// In flipCard function:
flipCounts[varName] = (flipCounts[varName] || 0) + 1;
Accessibility Considerations
While this interaction relies on visual animation, you can improve accessibility by ensuring your card content is also available through screen reader-accessible text. Consider adding a text alternative layer that screen readers can access, or provide a "View All" option that displays content without requiring the flip interaction.
The hover effects provide visual feedback for mouse users, but touch device users and keyboard users may need additional cues. Consider adding a visual indicator like a small icon to show that cards are interactive.
What's Next
You've now created a professional 3D flip card interaction that tracks progress and reports to Storyline. In Part 2 of this series, we'll explore expanding card overlays that reveal descriptions with smooth animations.
The techniques you learned here, including using data-model-id for element targeting, integrating GSAP for animations, and communicating with Storyline through GetPlayer(), will apply to all the card interactions in this series.
Have questions or want to share what you've built? Drop a comment below or connect with me on LinkedIn.