How to Create Click-to-Expand Cards in Articulate Storyline with JavaScript
By Nathan Stoker · April 19, 2026 · 20 min read

How to Create Click-to-Expand Cards in Articulate Storyline with JavaScript and GSAP
What if clicking a card could make it fly across the screen and expand into a detailed view? That's exactly what click-to-expand cards deliver. Learners click a thumbnail, and it animates smoothly to fill a designated content area. In this tutorial, you'll learn how to create click-to-expand cards in Articulate Storyline using JavaScript and GSAP.
This is Part 3 of a four-part series on creating interactive card experiences in Storyline:
Part 1: 3D Flip Cards
Part 2: Expanding Card Overlays
Part 4: Card Shuffle Stack
By the end of this tutorial, you'll have cards that clone and animate to a target location, associated text that fades in sync with the animation, visited state tracking, and a completion system that reports to Storyline. Let's build it.
Check out the demo and download the file in the header to follow along.
Why Click-to-Expand Cards Work
Sometimes you need more screen real estate than a flip or overlay can provide. Click-to-expand cards solve this by moving content from a compact grid to a dedicated display area. The animation creates a clear visual connection between the thumbnail and the expanded view.
This pattern works beautifully for image galleries with detailed captions, process steps with extended explanations, product features with specifications, or any content where you want learners to explore at their own pace while maintaining context.
What You'll Build
The click-to-expand card interaction includes four clickable thumbnail cards arranged in a row or grid. When clicked, a card clones itself and animates to fill a larger target placeholder. Each card opens a Storyline layer containing a fade overlay, detailed content, and a close button. The clone appears above the fade so it remains visible and prominent.
Associated text boxes fade out during expansion to focus attention on the content. Pressing Escape or clicking the close button returns the card to its original position with a smooth collapse animation.
Visited cards display a grayscale effect so learners track their progress. When all cards have been expanded at least once, a completion variable triggers.
Setting Up Your Storyline Project
This interaction requires more elements than the previous tutorials because you need both the source cards and a target display area, plus a layer for each card's expanded content.
Create Your Card Elements
Import four images that will serve as your clickable thumbnails. Arrange them in a row or grid layout. These are the cards learners will click to expand.
For each card, create a text box positioned nearby that contains additional context or a label. These text boxes will fade when their associated card expands, keeping the interface clean.
Create the Target Placeholder
Add a larger shape (like a rectangle) that defines where expanded cards will appear. This placeholder should be positioned prominently, typically to the side of or above your thumbnail grid. Size it to accommodate your content comfortably.
The JavaScript will scale and position expanded cards to exactly match this placeholder, so its size and position matter.
Create Your Layers
For each card, create a Storyline layer that contains a semi-transparent fade rectangle (covering the full slide), your detailed content text, and a close button. Each layer should be triggered to show when its corresponding variable becomes true.
The close button on each layer should call window.closeExpandedCard() via a JavaScript trigger. This is important because the collapse animation needs to play before the layer hides.
Find Your data-model-id Values
Publish your project and collect the data-model-id for each thumbnail card, each associated text box, and the target placeholder. You'll have nine IDs total for a four-card interaction: four cards, four text boxes, and one target.
Create Your Storyline Variables
Create these True/False variables with default values of false:
Variable Name | Purpose |
|---|---|
card1 | Tracks if Card 1 has been expanded |
card2 | Tracks if Card 2 has been expanded |
card3 | Tracks if Card 3 has been expanded |
card4 | Tracks if Card 4 has been expanded |
done | Triggers when all cards are complete |
Understanding the Architecture
This interaction is more complex than the previous tutorials because it involves cloning elements across Storyline's layer system, tracking positions through the Advanced JS API, and coordinating multiple animations.
Why Layers Matter for Stacking
Storyline layers are separate DOM containers. Each layer is a .slide-layer div, and they stack on top of each other in DOM order. The base layer is always at the bottom. When you show a layer (like your fade overlay), everything on that layer sits above everything on the base layer, regardless of z-index values.
This means you can't just clone a card and append it to document.body or the base slide-layer. It would always render behind the fade. The clone needs to be placed inside the active layer's DOM container.
Why We Use the Advanced JS API for Positioning
Storyline 360's Advanced JavaScript API (available since March 2025, Build 3.98+) provides object properties like x, y, width, and height in the slide's native coordinate system. These values are immune to the CSS transforms that Storyline's modern player applies for responsive scaling.
Older approaches using getBoundingClientRect() can produce incorrect coordinates because the player wraps slide content in nested CSS transforms. The API abstracts all of that away.
The Expand Flow
Here's what happens when a learner clicks a card:
The script reads the card's position and the target's position from the API (slide coordinates).
It sets the Storyline variable, triggering the layer to open (fade + content + close button).
After a short delay for the layer to mount in the DOM, it finds the active layer container.
It clones the card and appends the clone inside that layer, positioned at the card's original coordinates.
On the same frame, it hides the original card (preventing flicker).
GSAP animates the clone from source position/size to target position/size using
transform: scale().
The Collapse Flow
The close button calls
window.closeExpandedCard().Before Storyline hides the layer, the script moves the clone from the layer container to the base slide-layer. This keeps it visible during the animation.
GSAP animates the clone back to its original position and scale.
On the animation's final frame, the original card's opacity is restored before the clone is removed (preventing flicker).
Element Cloning and Scaling
When a card is clicked, the code creates a clone that animates to the target position while the original becomes invisible:
var clone = sourceEl.cloneNode(true);
var wrapper = doc.createElement("div");
// Wrapper at original card size
wrapper.style.position = "absolute";
wrapper.style.left = src.x + "px";
wrapper.style.top = src.y + "px";
wrapper.style.width = src.w + "px";
wrapper.style.height = src.h + "px";
wrapper.style.transformOrigin = "top left";
// Clone at original size (not 100%)
clone.style.width = src.w + "px";
clone.style.height = src.h + "px";
An important detail: we use transform: scale() to resize the clone, not width/height animation. Storyline elements have fixed pixel dimensions baked into inline styles on every child element (SVGs, images, inner containers). Animating a wrapper's width and height only resizes the wrapper container. The children stay at their original size. Using scaleX/scaleY with transformOrigin: top left scales the entire subtree proportionally.
var scaleX = tgt.w / src.w;
var scaleY = tgt.h / src.h;
gsap.to(wrapper, {
left: tgt.x + "px",
top: tgt.y + "px",
scaleX: scaleX,
scaleY: scaleY,
duration: 0.6,
ease: "power3.out"
});
Text Box Synchronization
Associated text boxes fade out when their card expands and fade back in when it collapses. The API's opacity property (0 to 100) handles this cleanly:
function fadeTextBox(textBoxId, hide) {
try {
object(textBoxId).opacity = hide ? 0 : 100;
} catch(e) {
// Fallback to DOM if API unavailable
var el = getElement(textBoxId);
if (el) el.style.opacity = hide ? "0" : "1";
}
}
function updateTextBoxVisibility(activeCardId) {
CLICKABLE_ITEMS.forEach(function(item) {
fadeTextBox(item.textBoxId, item.id === activeCardId);
});
}
This creates a clean visual hierarchy where only the active content is prominent.
The Visited State System
This interaction tracks visited cards and applies visual feedback, similar to Part 1.
Marking Cards as Visited
When a card collapses, it gets marked as visited and receives a grayscale filter:
// In the collapse onComplete callback
STATE.visitedCards[cardItem.id] = true;
applyVisitedState(cardItem.id);
Visual Feedback
Visited cards and their associated text boxes receive a grayscale filter through the API's style property:
function applyVisitedState(cardId) {
try {
object(cardId).style.filter = "grayscale(" + VISITED_DESATURATION + "%)";
} catch(e) {
var el = getElement(cardId);
if (el) el.style.filter = "grayscale(" + VISITED_DESATURATION + "%)";
}
var item = CLICKABLE_ITEMS.find(function(it) { return it.id === cardId; });
if (item && item.textBoxId) {
try {
object(item.textBoxId).style.filter = "grayscale(" + VISITED_DESATURATION + "%)";
} catch(e) {}
}
}
The grayscale removes temporarily when a card is active, then reapplies after collapse.
Complete Code
Create a JavaScript trigger that executes when the timeline starts. Make sure GSAP is loaded first using the loader from Part 1, or the code will fail. Note that the Advanced JS API functions (object(), setVar(), getVar()) are injected into the trigger's execution scope, so do not wrap the API portion in an IIFE.
Add this complete code:
// Card Expand — Clone-Into-Layer with Advanced JS API
//
// Requirements:
// - GSAP loaded
// - Storyline 360 Build 3.98+ (Advanced JS API)
// - Each card triggers a Storyline layer (fade + content + close button)
var CLICKABLE_ITEMS = [
{ id: "67d3OtRRUPU", option: "card1", textBoxId: "5u5sAYHe5W7" },
{ id: "6mDAD7cBido", option: "card2", textBoxId: "5cOyq6m12q4" },
{ id: "5z9Txh2aA4O", option: "card3", textBoxId: "5YELrDafkoS" },
{ id: "6SiUitJ3LSZ", option: "card4", textBoxId: "5Zu4qjxn2ir" }
];
var TARGET_ID = "5s1xcA91C2o";
var DONE_VAR = "done";
var VISITED_DESATURATION = 80;
// --- API check ---
var hasAPI = (typeof object === "function");
if (!hasAPI) console.error("Advanced JS API not available");
// --- Variable helpers ---
function slSetVar(name, value) {
if (typeof setVar === "function") setVar(name, value);
else {
var p = (typeof GetPlayer === "function") ? GetPlayer() : null;
if (p) try { p.SetVar(name, value); } catch(e){}
}
}
function slGetVar(name) {
if (typeof getVar === "function") return getVar(name);
var p = (typeof GetPlayer === "function") ? GetPlayer() : null;
if (p) try { return p.GetVar(name); } catch(e){ return false; }
return false;
}
function isTruthy(v) {
return v === true || v === "True" || v === "true" || v === 1 || v === "1";
}
function checkDone() {
var allComplete = true;
for (var i = 0; i < CLICKABLE_ITEMS.length; i++) {
if (!isTruthy(slGetVar(CLICKABLE_ITEMS[i].option))) { allComplete = false; break; }
}
if (allComplete && !isTruthy(slGetVar(DONE_VAR))) {
slSetVar(DONE_VAR, true);
console.log("Completion reached!");
}
}
// --- DOM helpers ---
function getElement(id) {
var el = document.querySelector('[data-model-id="' + id + '"]');
if (el) return el;
var iframes = document.querySelectorAll('iframe');
for (var i = 0; i < iframes.length; i++) {
try {
var d = iframes[i].contentDocument || iframes[i].contentWindow.document;
if (d) { el = d.querySelector('[data-model-id="' + id + '"]'); if (el) return el; }
} catch(e){}
}
return null;
}
// Find the active Storyline layer container
function findActiveLayerContainer() {
var el = getElement(CLICKABLE_ITEMS[0].id);
if (!el) return null;
var doc = el.ownerDocument;
var layers = doc.querySelectorAll('.slide-layer');
for (var i = layers.length - 1; i >= 0; i--) {
var style = (doc.defaultView || window).getComputedStyle(layers[i]);
if (style.display !== 'none' && style.visibility !== 'hidden' && i > 0) {
return layers[i];
}
}
return layers[0];
}
// --- State ---
var STATE = {
activeCardId: null,
visitedCards: {},
animating: false,
expanded: false,
wrapper: null,
clone: null,
sourceElement: null,
activeItem: null,
srcSnapshot: null
};
// --- Text box visibility ---
function fadeTextBox(textBoxId, hide) {
try {
object(textBoxId).opacity = hide ? 0 : 100;
} catch(e) {
var el = getElement(textBoxId);
if (el) el.style.opacity = hide ? "0" : "1";
}
}
function updateTextBoxVisibility(activeCardId) {
CLICKABLE_ITEMS.forEach(function(item) {
fadeTextBox(item.textBoxId, item.id === activeCardId);
});
}
// --- Visited state ---
function applyVisitedState(cardId) {
try { object(cardId).style.filter = "grayscale(" + VISITED_DESATURATION + "%)"; } catch(e) {
var el = getElement(cardId); if (el) el.style.filter = "grayscale(" + VISITED_DESATURATION + "%)";
}
var item = CLICKABLE_ITEMS.find(function(it) { return it.id === cardId; });
if (item && item.textBoxId) {
try { object(item.textBoxId).style.filter = "grayscale(" + VISITED_DESATURATION + "%)"; } catch(e2) {
var tb = getElement(item.textBoxId); if (tb) tb.style.filter = "grayscale(" + VISITED_DESATURATION + "%)";
}
}
}
function clearFilter(cardId) {
try { object(cardId).style.filter = "none"; } catch(e) {
var el = getElement(cardId); if (el) el.style.filter = "none";
}
var item = CLICKABLE_ITEMS.find(function(it) { return it.id === cardId; });
if (item && item.textBoxId) {
try { object(item.textBoxId).style.filter = "none"; } catch(e2) {
var tb = getElement(item.textBoxId); if (tb) tb.style.filter = "none";
}
}
}
function applyAllVisitedStates() {
CLICKABLE_ITEMS.forEach(function(item) {
if (STATE.visitedCards[item.id]) applyVisitedState(item.id);
});
}
// --- Pointer blocking ---
function updatePointerEvents() {
CLICKABLE_ITEMS.forEach(function(item) {
var el = getElement(item.id);
if (!el) return;
var blocked = !!STATE.activeCardId;
el.style.setProperty('pointer-events', blocked ? 'none' : 'auto', 'important');
el.style.setProperty('cursor', blocked ? 'default' : 'pointer', 'important');
});
}
// --- EXPAND ---
function expand(cardItem) {
if (STATE.expanded || STATE.animating) return;
// Read positions from API (slide coordinate space)
var srcObj, tgtObj;
try {
srcObj = object(cardItem.id);
tgtObj = object(TARGET_ID);
} catch(e) {
console.error("API object() failed:", e);
return;
}
var src = { x: srcObj.x, y: srcObj.y, w: srcObj.width, h: srcObj.height };
var tgt = { x: tgtObj.x, y: tgtObj.y, w: tgtObj.width, h: tgtObj.height };
if (src.w === 0 || src.h === 0 || tgt.w === 0 || tgt.h === 0) {
console.error("Zero-sized element, aborting");
return;
}
// Set Storyline variable FIRST — triggers the layer to open
slSetVar(cardItem.option, true);
STATE.activeCardId = cardItem.id;
STATE.activeItem = cardItem;
STATE.srcSnapshot = src;
STATE.animating = true;
if (STATE.visitedCards[cardItem.id]) clearFilter(cardItem.id);
updateTextBoxVisibility(STATE.activeCardId);
updatePointerEvents();
setTimeout(checkDone, 300);
// Wait for the layer to open, then clone into it
setTimeout(function() {
var layerContainer = findActiveLayerContainer();
if (!layerContainer) {
var testEl = getElement(CLICKABLE_ITEMS[0].id);
layerContainer = testEl ? testEl.ownerDocument.querySelector('.slide-layer') : document.body;
}
var sourceEl = getElement(cardItem.id);
if (!sourceEl) return;
STATE.sourceElement = sourceEl;
var ownerDoc = layerContainer.ownerDocument || document;
// Clone the card
var clone = sourceEl.cloneNode(true);
var wrapper = ownerDoc.createElement("div");
// Wrapper at original card size, using scale() to grow
wrapper.style.position = "absolute";
wrapper.style.left = src.x + "px";
wrapper.style.top = src.y + "px";
wrapper.style.width = src.w + "px";
wrapper.style.height = src.h + "px";
wrapper.style.transformOrigin = "top left";
wrapper.style.zIndex = "99999";
wrapper.style.pointerEvents = "none";
wrapper.style.overflow = "visible";
clone.style.width = src.w + "px";
clone.style.height = src.h + "px";
clone.style.transform = "none";
clone.style.pointerEvents = "auto";
clone.style.visibility = "visible";
clone.style.opacity = "1";
clone.style.position = "absolute";
clone.style.top = "0";
clone.style.left = "0";
clone.style.setProperty('cursor', 'default', 'important');
clone.style.setProperty('filter', 'none', 'important');
wrapper.appendChild(clone);
// Append to the LAYER container — sits above the fade
layerContainer.appendChild(wrapper);
layerContainer.style.overflow = "visible";
// Hide original on the same frame (no flicker)
srcObj.opacity = 0;
STATE.wrapper = wrapper;
STATE.clone = clone;
// Animate using scale (children have hardcoded pixel sizes)
var scaleX = tgt.w / src.w;
var scaleY = tgt.h / src.h;
gsap.to(wrapper, {
left: tgt.x + "px",
top: tgt.y + "px",
scaleX: scaleX,
scaleY: scaleY,
duration: 0.6,
ease: "power3.out",
onComplete: function() {
STATE.animating = false;
STATE.expanded = true;
}
});
}, 150);
}
// --- COLLAPSE ---
function collapse() {
if (!STATE.wrapper || !STATE.srcSnapshot) return;
if (STATE.animating) return;
STATE.animating = true;
var snap = STATE.srcSnapshot;
// Reparent clone to base layer before the layer hides
var el = getElement(CLICKABLE_ITEMS[0].id);
if (el) {
var doc = el.ownerDocument;
var layers = doc.querySelectorAll('.slide-layer');
var baseLayer = layers[0];
if (baseLayer && STATE.wrapper.parentElement !== baseLayer) {
baseLayer.appendChild(STATE.wrapper);
baseLayer.style.overflow = "visible";
}
}
gsap.to(STATE.wrapper, {
left: snap.x + "px",
top: snap.y + "px",
scaleX: 1,
scaleY: 1,
duration: 0.5,
ease: "power2.inOut",
onComplete: function() {
// Restore original FIRST, then remove clone (no flicker)
var cardItem = STATE.activeItem;
if (cardItem) {
try { object(cardItem.id).opacity = 100; } catch(e){}
STATE.visitedCards[cardItem.id] = true;
}
try { STATE.wrapper.remove(); } catch(e){}
if (cardItem) applyVisitedState(cardItem.id);
STATE.wrapper = null;
STATE.clone = null;
STATE.sourceElement = null;
STATE.srcSnapshot = null;
STATE.activeItem = null;
STATE.activeCardId = null;
STATE.animating = false;
STATE.expanded = false;
CLICKABLE_ITEMS.forEach(function(item) {
fadeTextBox(item.textBoxId, false);
});
updatePointerEvents();
applyAllVisitedStates();
}
});
}
// --- Wire click handlers ---
function wire() {
CLICKABLE_ITEMS.forEach(function(item) {
try {
var obj = object(item.id);
obj.click(function() {
expand(item);
});
obj.style.cursor = "pointer";
} catch(e) {
var el = getElement(item.id);
if (el) {
el.style.setProperty('cursor', 'pointer', 'important');
el.addEventListener('click', function() { expand(item); });
}
}
});
}
// --- Close handler ---
window.closeExpandedCard = function() {
collapse();
};
document.addEventListener("keydown", function(e) {
if (e.key === "Escape") collapse();
});
// --- Init ---
function init() {
CLICKABLE_ITEMS.forEach(function(item) {
fadeTextBox(item.textBoxId, false);
});
wire();
console.log("Card expand initialized");
}
var maxAttempts = 30;
var attempt = 0;
var readyInterval = setInterval(function() {
attempt++;
var allReady = true;
for (var i = 0; i < CLICKABLE_ITEMS.length; i++) {
try { object(CLICKABLE_ITEMS[i].id); } catch(e) { allReady = false; break; }
try { object(CLICKABLE_ITEMS[i].textBoxId); } catch(e) { allReady = false; break; }
}
try { object(TARGET_ID); } catch(e) { allReady = false; }
if (allReady) {
clearInterval(readyInterval);
init();
} else if (attempt >= maxAttempts) {
clearInterval(readyInterval);
console.error("Not all API objects ready after " + maxAttempts + " attempts");
init();
}
}, 200);
Configuring the Code for Your Project
After pasting the code, customize these sections.
Update the CLICKABLE_ITEMS Array
Replace the data-model-id values with your own:
var CLICKABLE_ITEMS = [
{ id: "YOUR_CARD_1_ID", option: "card1", textBoxId: "YOUR_TEXTBOX_1_ID" },
{ id: "YOUR_CARD_2_ID", option: "card2", textBoxId: "YOUR_TEXTBOX_2_ID" },
{ id: "YOUR_CARD_3_ID", option: "card3", textBoxId: "YOUR_TEXTBOX_3_ID" },
{ id: "YOUR_CARD_4_ID", option: "card4", textBoxId: "YOUR_TEXTBOX_4_ID" }
];
Update the Target ID
Replace with your placeholder's data-model-id:
var TARGET_ID = "YOUR_PLACEHOLDER_ID";
Adjust Animation Timing
Modify the expand and collapse durations to match your desired feel:
// In expand — slower for more dramatic effect
duration: 0.6,
// In collapse — slightly faster for snappy return
duration: 0.5,
Adding the Close Button
The code includes keyboard support (Escape to close), but you'll want a visual close button on each layer. Create a button in Storyline on each layer and add a JavaScript trigger that calls:
window.closeExpandedCard();
An important detail: the close button's JavaScript trigger should execute closeExpandedCard(), but the layer itself should NOT have a "hide layer" trigger on the same button. The collapse animation needs to play while the layer is still visible. Instead, let Storyline hide the layer through its normal variable-driven show/hide behavior after the animation completes.
If the layer hides before the collapse animation finishes, the clone will disappear instantly because it lives inside the layer's DOM container. The script handles this by moving the clone to the base slide-layer at the start of the collapse, but the cleanest results come from letting the animation finish first.
Technical Notes
Why Not position: fixed?
Storyline's modern player (2025+) applies CSS transforms at multiple levels for responsive scaling. When a parent element has a CSS transform, position: fixed no longer positions relative to the viewport. It positions relative to the nearest transformed ancestor. This produces incorrect coordinates, especially on mobile devices.
By appending the clone to a .slide-layer with position: absolute and using API-reported coordinates, we stay in Storyline's native coordinate system where everything is consistent.
Why Not getBoundingClientRect()?
For the same reason. getBoundingClientRect() returns viewport-relative coordinates that are distorted by the player's CSS transform pipeline. The API's object('ID').x and .y properties report positions in slide design coordinates, which are the same values you see in Storyline's authoring tool. These are stable across all player sizes, devices, and publish contexts.
The 150ms Delay
The setTimeout of 150ms before cloning exists because Storyline needs time to mount the layer's DOM after the variable is set. Without this delay, findActiveLayerContainer() would not find the newly shown layer. In testing, 150ms is reliable across desktop and mobile. If you encounter issues on slower devices, increase to 200ms.
Advanced JS API Requirement
This code requires Storyline 360 Build 3.98 or later (March 2025). The object(), setVar(), and getVar() functions are part of the Advanced JavaScript API. If you're on an older build, update through the Articulate 360 desktop app.
Troubleshooting Common Issues
Here are solutions to problems you might encounter.
Cards Don't Expand
Check that all data-model-id values are correct, including the target placeholder. Open the browser console and look for "Card expand initialized." If you see errors about API objects not being ready, verify that your Storyline version is Build 3.98 or later.
Clone Appears Behind the Fade
This means the clone is being appended to the wrong container. Run debugLayers() in the console (if you add that helper from the skill file) to check which layers exist and which are visible. The clone must be appended to the active layer, not the base layer.
No Collapse Animation
If clicking close makes the card snap back instantly, the layer is hiding before the animation can play. Make sure your close button only calls window.closeExpandedCard() and does not also trigger "hide layer" on the same click. The script reparents the clone to the base layer before animating, but the timing is cleaner when the layer stays visible during the animation.
Clone Doesn't Scale Properly
If the clone moves to the right position but stays at its original size, check that you're using scaleX/scaleY in the GSAP animation (not width/height). Storyline elements have hardcoded pixel dimensions on child elements that don't respond to parent sizing.
Flicker on Expand or Collapse
Flicker happens when there's a frame gap between hiding the original and showing the clone (or vice versa). The code handles this by synchronizing opacity changes with DOM operations on the same JavaScript execution frame. If you still see flicker, check that no other triggers are modifying the card's visibility.
Storyline Variables Not Updating
Make sure your variable names match exactly, including capitalization. The code uses the API's setVar() by default, falling back to GetPlayer().SetVar(). Test in "Preview in Browser" or published output, as some JavaScript features don't work in Storyline's built-in preview.
Accessibility Considerations
This interaction presents some accessibility challenges. The expand animation may be disorienting for users with vestibular disorders. Consider adding a reduced-motion option that skips the animation and shows content immediately.
Keyboard support is implemented with the Escape key for closing. For full keyboard navigation, add tabindex management and Enter key handling on the cards.
Screen reader users need additional context about the interactive nature of the cards. The API's click handler works with Storyline's accessibility layer, but you should also ensure your card elements have descriptive accessibility text in Storyline's authoring tool.
What's Next
You've created click-to-expand cards that animate smoothly between thumbnail and detail views using Storyline's Advanced JS API and the clone-into-layer pattern. In Part 4, the final installment of this series, we'll build a card shuffle stack where clicking a card moves it to the front of the deck.
The layer-aware cloning and API-based positioning techniques you learned here apply to many advanced interactions. Understanding how Storyline's DOM layers work and how to move elements between them opens up possibilities for complex, polished experiences that work reliably across desktop and mobile.
Please note, video is about a significantly different code base that worked in January, our code base has been improved, you can still follow the video if needed, but it may not currently align with our current code. Our current code is much shorter. I will rerecord video at a later date as time permits.