How to Create Expanding Card Overlays in Articulate Storyline with JavaScript
By Nathan Stoker · April 11, 2026 · 18 min read

What if your cards could do more than flip? Imagine learners clicking an image and watching a sleek overlay expand from the bottom, revealing a title that transforms into a full description. In this tutorial, you'll learn how to create expanding card overlays in Articulate Storyline using JavaScript and GSAP.
This is Part 2 of a four-part series on creating interactive card experiences in Storyline:
Part 1: 3D Flip Cards
Part 2: Expanding Card Overlays (you are here)
Part 3: Click-to-Expand Cards
Part 4: Card Shuffle Stack
By the end of this tutorial, you'll have cards with animated overlays that expand on click, responsive typography that scales across devices, and completion tracking that reports to Storyline. Let's build it.
Check out the demo and download the file in the header to follow along.
Why Expanding Overlays Work for Learning
Traditional card interactions show content all at once. Expanding overlays create a two-stage reveal: learners first see a title that hints at the content, then click to expand and see the full description. This progressive disclosure keeps learners engaged and prevents cognitive overload.
The overlay approach also preserves your visual design. Your images remain fully visible until learners choose to explore deeper. And because the overlay animates smoothly from a teaser state to full expansion, the interaction feels polished and intentional.
What You'll Build
The expanding card overlay interaction includes four image cards with semi-transparent blue overlays anchored to the bottom. Each overlay starts in a "teaser" state showing just a title. When clicked, the overlay expands to cover the full card and crossfades from title to description. Clicking again collapses the overlay back to teaser state.
The system automatically calculates optimal font sizes so text fits perfectly across all cards. It detects device type and adjusts sizing for desktop, tablet, and phone. When learners interact with each card, Storyline variables track their progress.
Setting Up Your Storyline Project
This interaction builds on images with SVG-based shapes, which is how Storyline renders most visual elements in published output.
Create Your Card Images
Import four images into Storyline and arrange them in a grid or row. These become the backgrounds for your expanding overlays. The code will automatically detect each image's dimensions and create appropriately sized overlays.
For best results, use images with similar aspect ratios. The overlay height calculates as a percentage of the card height, so dramatically different proportions may look inconsistent.
Find Your data-model-id Values
As with all JavaScript interactions in Storyline, you need each element's data-model-id from the published output. Publish your project for Web, open the story_content folder, and locate the HTML for your slide.
Search for your image elements and note the data-model-id attribute for each. You'll add these to the configuration object in the JavaScript.
Create Your Storyline Variables
Create the following 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 |
slideComplete | Triggers when all cards are complete |
The individual option variables let you create conditional content. The slideComplete variable is perfect for enabling a Next button or showing completion feedback.
Understanding the Overlay Architecture
Before diving into the code, let's understand how the overlay system works.
The Configuration Object
Each card is defined with its content and associated variable:
const CARD_CONFIG = {
'6fENrSX3HQj': {
title: 'Precision under the hood',
description: 'A skilled mechanic works intently on a car engine...',
variable: 'option1'
},
// ... more cards
};
The key is the data-model-id of the image. The value contains the title shown in teaser state, the description revealed on expansion, and the Storyline variable to update on click.
Dynamic Overlay Creation
Rather than creating overlays in Storyline, the code generates them dynamically. This approach gives you complete control over styling and ensures consistent appearance across all cards:
function makeOverlay(shape, cfg) {
const overlay = document.createElement('div');
overlay.className = 'storyline-card-overlay-' + shape.dataset.modelId;
apply(overlay, OVERLAY_STYLES);
const title = document.createElement('div');
title.className = 'overlay-title';
title.textContent = cfg.title;
apply(title, TITLE_STYLES);
const desc = document.createElement('div');
desc.className = 'overlay-description';
desc.textContent = cfg.description;
apply(desc, DESC_STYLES);
overlay.appendChild(title);
overlay.appendChild(desc);
return overlay;
}
Responsive Font Sizing
One of the most sophisticated features is automatic font size calculation. The code measures all overlays and finds the largest font size that fits text within every card:
function calculateGlobalFontSizes() {
let titleFontSize = titleConfig.max;
let descFontSize = descConfig.max;
while ((titleFontSize >= titleConfig.min || descFontSize >= descConfig.min) && !allFit) {
allFit = true;
for (const overlay of allOverlays) {
// Check if text fits at current size
if (title.scrollHeight > availableHeight || title.scrollWidth > availableWidth) {
allFit = false;
titleFontSize--;
}
// ... similar check for description
}
}
}
This ensures visual consistency. All cards use the same font sizes, even if one card has longer text than others.
Device Detection
The code detects whether learners are on desktop, tablet, or phone and adjusts accordingly:
const getDeviceType = () => {
const width = window.innerWidth;
const userAgent = navigator.userAgent;
const isIPad = /iPad/i.test(userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
if (width <= 480 || /iPhone|iPod|Android.*Mobile/i.test(userAgent)) {
return 'phone';
}
if (width <= 768 || isIPad || /Android(?!.*Mobile)/i.test(userAgent)) {
return 'tablet';
}
return 'desktop';
};
Each device type has different font size ranges and padding values, ensuring readability across screen sizes.
The Expand/Collapse Animation
When learners click a card, the overlay smoothly expands while crossfading between title and description:
const click = e => {
e.stopPropagation();
e.preventDefault();
if (!gsapLoaded) return;
setVar(cfg.variable, true);
updateSlideComplete();
const open = overlay.dataset.isExpanded === 'true';
const tgtH = open ? currentTeaserH : currentRect.height;
gsap.to(overlay, {
height: tgtH,
duration: 0.4,
ease: 'power2.out'
});
if (open) {
// Collapsing: fade out description, fade in title
gsap.to(desc, { opacity: 0, duration: 0.125 });
gsap.to(title, { opacity: 1, duration: 0.25, delay: 0.25 });
} else {
// Expanding: fade out title, fade in description
gsap.to(title, { opacity: 0, duration: 0.25 });
gsap.to(desc, { opacity: 1, duration: 0.15, delay: 0.2 });
}
overlay.dataset.isExpanded = open ? 'false' : 'true';
};
The timing is carefully choreographed. When expanding, the title fades out first, then the description fades in as the overlay reaches full height. When collapsing, the sequence reverses.
Complete Code
Create a JavaScript trigger that executes when the timeline starts on your slide. First, you don't need to load GSAP separately for this interaction because the code handles it automatically. Add this complete code:
(function () {
'use strict';
if (typeof gsap === "undefined") {
console.error("❌ GSAP not loaded");
return;
}
const CLICKABLE_ITEMS = [
{ id: "67d3OtRRUPU", name: "Item1", option: "card1", textBoxId: "5u5sAYHe5W7" },
{ id: "6mDAD7cBido", name: "Item2", option: "card2", textBoxId: "5cOyq6m12q4" },
{ id: "5z9Txh2aA4O", name: "Item3", option: "card3", textBoxId: "5YELrDafkoS" },
{ id: "6SiUitJ3LSZ", name: "Item4", option: "card4", textBoxId: "5Zu4qjxn2ir" }
];
const TARGET_ID = "5s1xcA91C2o";
const DONE_VAR = "done"; // completion latch
// Visited state controls
const VISITED_DESATURATION = 80; // Percentage of grayscale for visited cards
const STATE = {
wrapper: null,
clone: null,
overlay: null,
original: null,
sourceElement: null,
originalVisibility: null,
activeCardId: null,
clickListeners: new Map(),
resizeObserver: null,
mutationObserver: null,
targetElement: null,
visitedCards: new Set()
};
function px(n) {
return n + "px";
}
function rect(el) {
const r = el.getBoundingClientRect();
return {
top: r.top + window.scrollY,
left: r.left + window.scrollX,
width: r.width,
height: r.height
};
}
function getPlayer() {
return typeof GetPlayer === "function" ? GetPlayer() : null;
}
function ensureTarget() {
const target = document.querySelector('[data-model-id="' + TARGET_ID + '"]');
if (!target) console.error("❌ Placeholder not found: " + TARGET_ID);
return target;
}
function setStorylineVar(name, value) {
const player = getPlayer();
if (player) {
try {
player.SetVar(name, value);
console.log("✅ Set " + name + " = " + value);
setTimeout(function () {
const verifyValue = player.GetVar(name);
console.log("🔍 Verification: " + name + " is now " + verifyValue);
}, 100);
} catch (e) {
console.error("❌ Failed to set Storyline variable " + name + ":", e);
}
} else {
console.error("❌ Player not available when trying to set " + name);
}
}
function getStorylineVar(name) {
const player = getPlayer();
if (player) {
try {
const value = player.GetVar(name);
console.log("🔍 Getting " + name + " = " + value + " (type: " + (typeof value) + ")");
return value;
} catch (e) {
console.error("❌ Failed to get Storyline variable " + name + ":", e);
return false;
}
} else {
console.error("❌ Player not available when trying to get " + name);
return false;
}
}
function isTruthyStorylineValue(v) {
return v === true || v === "True" || v === "true" || v === 1 || v === "1";
}
function createCompletionChecker(cfg) {
const items = cfg.items || [];
const doneVar = cfg.doneVar;
const getVar = cfg.getVar;
const setVar = cfg.setVar;
const optionKey = cfg.optionKey || "option";
return function checkCompletion() {
console.log("🔍 Starting completion check...");
const optionNames = [];
for (let i = 0; i < items.length; i++) {
const opt = items[i] && items[i][optionKey];
if (opt && optionNames.indexOf(opt) === -1) optionNames.push(opt);
}
const varStates = {};
for (let j = 0; j < optionNames.length; j++) {
const name = optionNames[j];
const value = getVar(name);
const complete = isTruthyStorylineValue(value);
varStates[name] = complete;
console.log("🔍 " + name + " = " + value + " (" + (typeof value) + ") -> complete: " + complete);
}
let allComplete = true;
for (let k = 0; k < optionNames.length; k++) {
if (!varStates[optionNames[k]]) {
allComplete = false;
break;
}
}
console.log("🔍 All variables complete: " + allComplete);
console.log("🔍 Variable states:", varStates);
if (!doneVar) {
console.warn("⚠️ No doneVar configured; completion check will not set a done flag.");
return;
}
if (allComplete) {
const alreadyDone = getVar(doneVar);
if (!isTruthyStorylineValue(alreadyDone)) {
setVar(doneVar, true);
console.log("🎉 Completion reached! " + doneVar + " is now true.");
} else {
console.log("ℹ️ " + doneVar + " already true — no update needed");
}
} else {
console.log("⏳ Not all items completed yet");
}
};
}
const checkDone = createCompletionChecker({
items: CLICKABLE_ITEMS,
doneVar: DONE_VAR,
getVar: getStorylineVar,
setVar: setStorylineVar,
optionKey: "option"
});
function updateClonePosition() {
if (!STATE.wrapper || !STATE.targetElement) return;
const targetRect = rect(STATE.targetElement);
const scaleX = targetRect.width / STATE.original.width;
const scaleY = targetRect.height / STATE.original.height;
gsap.set(STATE.wrapper, {
top: px(targetRect.top),
left: px(targetRect.left),
scaleX: scaleX,
scaleY: scaleY
});
console.log("✅ Updated clone position: top=" + targetRect.top + ", left=" + targetRect.left + ", scaleX=" + scaleX + ", scaleY=" + scaleY);
}
function startPositionTracking() {
if (!STATE.targetElement) return;
if (window.ResizeObserver) {
STATE.resizeObserver = new ResizeObserver(function () {
updateClonePosition();
});
STATE.resizeObserver.observe(STATE.targetElement);
STATE.resizeObserver.observe(document.body);
}
window.addEventListener('resize', updateClonePosition);
window.addEventListener('scroll', updateClonePosition, true);
if (window.MutationObserver) {
const mutationObserver = new MutationObserver(function () {
updateClonePosition();
});
mutationObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class']
});
STATE.mutationObserver = mutationObserver;
}
console.log("✅ Started position tracking for clone");
}
function stopPositionTracking() {
if (STATE.resizeObserver) {
STATE.resizeObserver.disconnect();
STATE.resizeObserver = null;
}
if (STATE.mutationObserver) {
STATE.mutationObserver.disconnect();
STATE.mutationObserver = null;
}
window.removeEventListener('resize', updateClonePosition);
window.removeEventListener('scroll', updateClonePosition, true);
console.log("✅ Stopped position tracking for clone");
}
function fadeTextBox(textBoxId, fadeOut) {
const textBox = document.querySelector('[data-model-id="' + textBoxId + '"]');
if (!textBox) {
console.log("⚠️ Text box not found: " + textBoxId);
return;
}
const targetOpacity = fadeOut ? 0 : 1;
const duration = 0.4;
gsap.to(textBox, {
opacity: targetOpacity,
duration: duration,
ease: "power2.out",
onComplete: function () {
console.log("✅ Text box " + textBoxId + " " + (fadeOut ? "faded out" : "faded in"));
}
});
}
function updateTextBoxVisibility(activeCardId) {
CLICKABLE_ITEMS.forEach(function (item) {
const isCurrentlyActive = item.id === activeCardId;
fadeTextBox(item.textBoxId, isCurrentlyActive);
});
}
function applyVisitedState(cardId) {
const el = document.querySelector('[data-model-id="' + cardId + '"]');
if (!el) return;
el.classList.add('card-visited');
gsap.to(el, {
filter: "grayscale(" + VISITED_DESATURATION + "%)",
duration: 0.3,
ease: "power2.out"
});
const item = CLICKABLE_ITEMS.find(function (it) { return it.id === cardId; });
if (item && item.textBoxId) {
const textBox = document.querySelector('[data-model-id="' + item.textBoxId + '"]');
if (textBox) {
gsap.to(textBox, {
filter: "grayscale(" + VISITED_DESATURATION + "%)",
duration: 0.3,
ease: "power2.out"
});
console.log("✅ Applied visited state to text box: " + item.textBoxId);
}
}
console.log("✅ Applied visited state to card: " + cardId);
}
function removeVisitedStateTemporarily(cardId) {
const el = document.querySelector('[data-model-id="' + cardId + '"]');
if (!el) return;
gsap.to(el, {
filter: "grayscale(0%)",
duration: 0.3,
ease: "power2.out"
});
const item = CLICKABLE_ITEMS.find(function (it) { return it.id === cardId; });
if (item && item.textBoxId) {
const textBox = document.querySelector('[data-model-id="' + item.textBoxId + '"]');
if (textBox) {
gsap.to(textBox, {
filter: "grayscale(0%)",
duration: 0.3,
ease: "power2.out"
});
console.log("✅ Temporarily removed visited state from text box: " + item.textBoxId);
}
}
console.log("✅ Temporarily removed visited state for active card: " + cardId);
}
function updateVisitedStates() {
CLICKABLE_ITEMS.forEach(function (item) {
if (STATE.visitedCards.has(item.id)) {
applyVisitedState(item.id);
}
});
}
function updatePointerEvents() {
CLICKABLE_ITEMS.forEach(function (item) {
const el = document.querySelector('[data-model-id="' + item.id + '"]');
if (!el) {
console.warn("⚠️ Clickable element not found: " + item.id);
return;
}
el.style.setProperty('pointer-events', STATE.activeCardId ? 'none' : 'auto', 'important');
el.style.setProperty('cursor', STATE.activeCardId ? 'default' : 'pointer', 'important');
const descendants = el.querySelectorAll('*');
descendants.forEach(function (desc) {
desc.style.setProperty('pointer-events', STATE.activeCardId ? 'none' : 'auto', 'important');
desc.style.setProperty('cursor', STATE.activeCardId ? 'default' : 'pointer', 'important');
});
if (STATE.activeCardId && !STATE.clickListeners.has(item.id)) {
const listener = function () { expand(el, item.name, item.option, item.textBoxId); };
STATE.clickListeners.set(item.id, listener);
el.removeEventListener('click', listener);
} else if (!STATE.activeCardId && STATE.clickListeners.has(item.id)) {
const listener2 = STATE.clickListeners.get(item.id);
el.addEventListener('click', listener2);
}
});
}
function expand(sourceEl, itemName, optionVar, textBoxId) {
if (STATE.clone) return;
console.log("🚀 Expanding card: " + itemName + ", setting variable: " + optionVar);
const targetEl = ensureTarget();
if (!targetEl) return;
const fromRect = rect(sourceEl);
const toRect = rect(targetEl);
STATE.targetElement = targetEl;
if (optionVar) {
console.log("🎯 Setting variable " + optionVar + " to true");
setStorylineVar(optionVar, true);
setTimeout(function () {
const checkValue = getStorylineVar(optionVar);
console.log("🔍 Double-check: " + optionVar + " = " + checkValue);
}, 200);
}
const clickedItem = CLICKABLE_ITEMS.find(function (it) { return it.option === optionVar; });
if (clickedItem) {
STATE.activeCardId = clickedItem.id;
if (STATE.visitedCards.has(clickedItem.id)) {
removeVisitedStateTemporarily(clickedItem.id);
}
updateTextBoxVisibility(STATE.activeCardId);
updatePointerEvents();
setTimeout(checkDone, 300);
}
STATE.sourceElement = sourceEl;
STATE.originalVisibility = sourceEl.style.visibility;
if (clickedItem && STATE.visitedCards.has(clickedItem.id)) {
gsap.set(sourceEl, { filter: "grayscale(0%)" });
}
sourceEl.style.visibility = "hidden";
const clone = sourceEl.cloneNode(true);
const wrapper = document.createElement("div");
wrapper.style.position = "fixed";
wrapper.style.top = px(fromRect.top);
wrapper.style.left = px(fromRect.left);
wrapper.style.width = px(fromRect.width);
wrapper.style.height = px(fromRect.height);
wrapper.style.transformOrigin = "top left";
wrapper.style.zIndex = "99999";
wrapper.style.pointerEvents = "none";
wrapper.style.overflow = "hidden";
wrapper.classList.add("slide-container");
const captionContainer = document.querySelector('.caption-container');
if (captionContainer) {
captionContainer.style.setProperty('z-index', '999999', 'important');
console.log('✅ Updated caption container z-index to appear above clone');
}
clone.style.width = "100%";
clone.style.height = "100%";
clone.style.transform = "none";
clone.style.pointerEvents = "auto";
clone.style.visibility = "visible";
clone.style.position = "relative";
clone.style.setProperty('cursor', 'default', 'important');
clone.style.setProperty('filter', 'none', 'important');
const images = clone.querySelectorAll('img');
images.forEach(function (img) {
img.style.maxWidth = "100%";
img.style.maxHeight = "100%";
img.style.width = "100%";
img.style.height = "auto";
img.style.objectFit = "contain";
img.style.display = "block";
});
const internalSlideContainer = clone.querySelector('.slide-container');
if (internalSlideContainer) {
internalSlideContainer.style.overflow = "visible";
}
wrapper.appendChild(clone);
document.body.appendChild(wrapper);
const scaleX = toRect.width / fromRect.width;
const scaleY = toRect.height / fromRect.height;
gsap.to(wrapper, {
top: px(toRect.top),
left: px(toRect.left),
scaleX: scaleX,
scaleY: scaleY,
duration: 0.6,
ease: "power3.out",
onComplete: function () {
startPositionTracking();
}
});
STATE.wrapper = wrapper;
STATE.clone = clone;
STATE.overlay = null;
STATE.original = {
top: fromRect.top,
left: fromRect.left,
width: fromRect.width,
height: fromRect.height,
scaleX: scaleX,
scaleY: scaleY
};
}
function collapse() {
if (!STATE.wrapper || !STATE.original) return;
stopPositionTracking();
if (STATE.activeCardId) {
STATE.visitedCards.add(STATE.activeCardId);
console.log("✅ Marked card as visited: " + STATE.activeCardId);
}
const top = STATE.original.top;
const left = STATE.original.left;
gsap.to(STATE.wrapper, {
top: px(top),
left: px(left),
scaleX: 1,
scaleY: 1,
duration: 0.5,
ease: "power2.inOut",
onComplete: function () {
try {
STATE.wrapper.remove();
if (STATE.sourceElement) {
STATE.sourceElement.style.visibility = STATE.originalVisibility || "";
}
} catch (_) { }
const collapsedCardId = STATE.activeCardId;
if (collapsedCardId) {
applyVisitedState(collapsedCardId);
}
STATE.wrapper = null;
STATE.clone = null;
STATE.overlay = null;
STATE.original = null;
STATE.sourceElement = null;
STATE.originalVisibility = null;
STATE.activeCardId = null;
STATE.targetElement = null;
const captionContainer = document.querySelector('.caption-container');
if (captionContainer) {
captionContainer.style.zIndex = '2';
}
CLICKABLE_ITEMS.forEach(function (item) {
fadeTextBox(item.textBoxId, false);
});
updatePointerEvents();
updateVisitedStates();
}
});
}
function wire() {
CLICKABLE_ITEMS.forEach(function (item) {
const el = document.querySelector('[data-model-id="' + item.id + '"]');
if (!el) {
console.warn("⚠️ Clickable element not found: " + item.id);
return;
}
el.style.setProperty('cursor', 'pointer', 'important');
const listener = function () {
console.log("✅ Clicked item: " + item.id + " -> will set " + item.option);
expand(el, item.name, item.option, item.textBoxId);
};
el.addEventListener('click', listener);
STATE.clickListeners.set(item.id, listener);
});
window.debugStorylineVars = function () {
console.log("🔍 Debug: Checking all variables");
CLICKABLE_ITEMS.forEach(function (item) {
const value = getStorylineVar(item.option);
console.log(item.option + " = " + value);
});
const done = getStorylineVar("done");
console.log("done = " + done);
};
}
function waitUntilReady() {
const allIds = CLICKABLE_ITEMS.map(function (item) { return item.id; });
const allTextBoxIds = CLICKABLE_ITEMS.map(function (item) { return item.textBoxId; });
const all = allIds.concat(allTextBoxIds).concat([TARGET_ID]);
const interval = setInterval(function () {
const ready = all.every(function (id) {
const el = document.querySelector('[data-model-id="' + id + '"]');
if (!el) console.warn("⚠️ Element not found during ready check: " + id);
return !!el;
});
if (ready) {
clearInterval(interval);
console.log("✅ All elements ready, wiring events");
const player = getPlayer();
if (player) console.log("✅ Storyline player is available");
else console.error("❌ Storyline player not available - variables will not work!");
wire();
initializeTextBoxes();
}
}, 200);
}
function initializeTextBoxes() {
CLICKABLE_ITEMS.forEach(function (item) {
fadeTextBox(item.textBoxId, false);
});
}
window.closeExpandedCard = function () {
console.log("✅ closeExpandedCard called");
collapse();
};
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") {
console.log("✅ Escape key pressed, collapsing card");
collapse();
}
});
console.log("🚀 Script loading, waiting for elements...");
waitUntilReady();
})();Configuring the Code for Your Project
After pasting the code, customize these sections for your content.
Update the CARD_CONFIG Object
Replace the data-model-id keys and content with your own:
const CARD_CONFIG = {
'YOUR_CARD_1_ID': {
title: 'Your Title Here',
description: 'Your full description text goes here.',
variable: 'option1'
},
// Add more cards as needed
};
Keep titles short since they display in the compact teaser state. Descriptions can be longer but should fit within the expanded overlay area.
Adjust the Overlay Color
The default semi-transparent blue works well with many designs, but you can customize it:
backgroundColor: 'rgba(47,142,255,.8)', // Change these RGBA values
For dark images, try a lighter overlay color. For light images, a darker overlay creates better contrast.
Modify Font Size Ranges
If your text consistently appears too large or too small, adjust the min/max values in FONT_CONFIG:
const FONT_CONFIG = {
title: {
min: 16, max: 60, // Desktop title range
tablet: { min: 14, max: 40 },
phone: { min: 10, max: 24 }
},
// ...
};
Troubleshooting Common Issues
Here are solutions to problems you might encounter.
Overlays Don't Appear
Check that your data-model-id values are correct in CARD_CONFIG. Open the browser console (F12) and look for any error messages. The code logs progress, so you should see messages about finding and initializing cards.
Text Gets Cut Off
The automatic font sizing should prevent this, but if text still gets truncated, your descriptions may be too long for the card size. Try shortening the text or adjusting the min font size values lower.
Overlays Appear Behind Other Elements
If overlays display under other Storyline elements, the z-index needs adjustment. Find this line in OVERLAY_STYLES and increase the value:
zIndex: '1000', // Try 10000 or higher
Click Events Not Registering
On touch devices, make sure you're not accidentally blocking touch events elsewhere on the page. The code handles both click and touchend events, but other JavaScript on the page could interfere.
Extending the Interaction
Once your basic overlays work, consider these enhancements.
Add Custom Animations
Instead of the default crossfade, try sliding the description in from the bottom:
// Replace the description fade with a slide
gsap.fromTo(desc,
{ y: 20, opacity: 0 },
{ y: 0, opacity: 1, duration: 0.3, delay: 0.2 }
);
Include Images in Descriptions
Modify the makeOverlay function to include an image alongside the description text. You could show an icon, diagram, or related graphic.
Create Sequential Reveals
Unlock cards progressively by checking Storyline variables before allowing expansion. This creates a guided discovery experience.
Accessibility Considerations
The overlay text uses high-contrast white on blue, which meets WCAG guidelines for readability. However, the click-to-reveal pattern can be challenging for screen reader users.
Consider adding ARIA attributes to indicate the interactive nature of each card. Adding role="button" and appropriate aria-expanded states helps assistive technologies communicate the interaction.
For keyboard accessibility, the code could be extended to handle Enter and Space key events on focused cards.
What's Next
You've created expanding card overlays that respond to different devices and track learner progress. In Part 3, we'll explore click-to-expand cards that animate to a target location, creating a modal-like experience within Storyline.
The responsive design patterns and device detection you learned here apply to many other JavaScript interactions. Understanding how to calculate and apply dynamic sizing opens up possibilities beyond card interactions.