Home / Blog

How to Create a JavaScript Accordion in Articulate Storyline

By Nathan Stoker · February 8, 2026 · 12 min read

How to Create a JavaScript Accordion in Articulate Storyline

Looking to add an engaging, interactive accordion to your Articulate Storyline course? This JavaScript accordion interaction creates a sleek card-based navigation where learners click through overlapping panels that slide smoothly across the screen. It's perfect for presenting sequential content, comparing options, or creating memorable menu systems.

In this tutorial, you'll learn how to implement this JavaScript accordion from scratch—no advanced coding experience required. By the end, you'll have a fully functional accordion system that tracks learner progress and marks completion when all panels have been viewed.

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

What You'll Build

This interaction creates a stack of overlapping cards that animate when clicked. Panels slide left or right to reveal content, with visual feedback showing which sections have been visited. The system automatically tracks completion, making it ideal for compliance training or structured learning paths.

The interaction includes:

  • Smooth GSAP-powered animations

  • Hover effects that brighten panels

  • Visual "visited" state with reduced saturation

  • Automatic completion tracking via Storyline variables

  • Responsive design that adapts to window resizing

Prerequisites

Before starting, make sure you have:

  • Articulate Storyline 360 (or Storyline 3)

  • Basic familiarity with adding JavaScript triggers

  • The panel graphics you want to use (PNG or SVG format works best)

  • GSAP library loaded in your project

Loading GSAP in Your Project

This interaction requires the GSAP animation library. You'll need to load it before the accordion system initializes.

Add this JavaScript trigger to your slide's timeline start (set to execute at 0.25 seconds):

var script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js';
document.head.appendChild(script);

This gives GSAP time to load before your accordion system needs it.

Step 1: Design Your Panels in Storyline

Start by creating your panel graphics directly on the slide. Each panel should be a separate object—you can use shapes, images, or grouped elements.

Important design tips:

  1. Make all panels the same size for consistent behavior

  2. Position panels so they overlap in a stack formation

  3. Place the first panel (Tab 1) at the bottom of the stacking order

  4. Each subsequent panel should appear above the previous one

To set the stacking order, right-click each panel and use "Send to Back" or "Bring to Front" until Tab 1 is at the bottom and Tab 4 (or your final panel) is on top.

Step 2: Find Your Data Model IDs

Every object in Storyline has a unique identifier called a data-model-id. You'll need these IDs to connect your JavaScript to your panel objects.

Here's how to find them:

  1. Preview your project in a web browser

  2. Right-click on your first panel and select "Inspect" (or press F12)

  3. Look for the data-model-id attribute in the HTML element

  4. Copy the alphanumeric string (it looks like "6EwRQaNDtUK")

  5. Repeat for each panel and your container object

Write down each ID—you'll enter them in the configuration section of the code.

Step 3: Create Your Storyline Variable

The interaction tracks when learners have viewed all panels. Create a True/False variable to capture this:

  1. Open the Variables panel in Storyline

  2. Click "Create a new variable"

  3. Name it accordionComplete

  4. Set the type to True/False

  5. Set the default value to False

You can use this variable to control navigation, trigger completion, or unlock additional content.

Step 4: Add the JavaScript Accordion Code

Now add the main interaction code. Create a new trigger with these settings:

  • Action: Execute JavaScript

  • When: Timeline starts

  • Time: 0.5 seconds (this ensures GSAP has loaded)

Copy and paste the complete code below into the JavaScript editor:

function initializeCardSystem() {
    // ============ CONFIGURATION ============
    var player = GetPlayer();
    var containerId = "6T5d1wMy0MS";
    var completionVariable = "accordionComplete";
    var cardGapPercent = 6; // Visible "tab" gap (as % of container width)
    var startPosition = "left"; // "left" or "right"

    var hoverBrightness = 1.05;
    var visitedSaturation = 0.9;

    var cards = [
        { id: "5h0Cwn23b7w" },
        { id: "63Agf8h13An" },
        { id: "64rCwfGnuVA" },
        { id: "6jzcEoMtReY" }
    ];
    // =======================================

    var cardStates = {};
    var cardVisited = {};
    var cardWidth = 0;  // Assume all cards same width
    var cardBaseLeft = 0;  // Where cards sit in DOM (x=0)
    var isInitialized = false;

    function getContainer() {
        return document.querySelector('[data-model-id="' + containerId + '"]');
    }

    function getContainerBounds() {
        var container = getContainer();
        if (!container) return { left: 0, width: 800 };
        var rect = container.getBoundingClientRect();
        return { left: rect.left, width: rect.width };
    }

    function getGapInPixels() {
        var bounds = getContainerBounds();
        return (cardGapPercent / 100) * bounds.width;
    }

    function getCardX(cardIndex, position) {
        var bounds = getContainerBounds();
        var gap = getGapInPixels();

        var targetViewportX;

        if (position === "left") {
            if (startPosition === "right") {
                // Expanded: left edges stagger from container left
                targetViewportX = bounds.left + (cardIndex * gap);
            } else {
                // Collapsed: cards mostly off-screen left
                var gapMultiplier = cards.length - cardIndex;
                targetViewportX = bounds.left - cardWidth + (gap * gapMultiplier);
            }
        } else {
            if (startPosition === "right") {
                // Collapsed: cards mostly off-screen right, tabs visible on left
                var gapMultiplier = cards.length - cardIndex;
                targetViewportX = bounds.left + bounds.width - (gap * gapMultiplier);
            } else {
                // Expanded: stagger from right
                targetViewportX = bounds.left + bounds.width - cardWidth - (cardIndex * gap);
            }
        }

        // Convert viewport position to transform value
        return targetViewportX - cardBaseLeft;
    }

    function getCardElement(cardId) {
        return document.querySelector('[data-model-id="' + cardId + '"]') ||
               document.querySelector('[data-acc-id="' + cardId + '"]');
    }

    function getLowestZIndexChild(parentElement) {
        var children = parentElement.querySelectorAll('*');
        var lowestChild = null;
        var lowestZ = Infinity;

        children.forEach(function(child) {
            var z = parseInt(window.getComputedStyle(child).zIndex, 10);
            if (isNaN(z)) z = 0;
            if (z < lowestZ) {
                lowestZ = z;
                lowestChild = child;
            }
        });

        return lowestChild;
    }

    function waitForElements(callback) {
        var attempts = 0;
        var check = function() {
            attempts++;
            var ready = getContainer() && cards.every(function(c) { return getCardElement(c.id); });
            if (ready) callback();
            else if (attempts < 30) setTimeout(check, 150);
        };
        check();
    }

    function applyHoverState(element) {
        var target = getLowestZIndexChild(element) || element;
        gsap.to(target, {
            filter: "brightness(" + hoverBrightness + ")",
            duration: 0.2,
            ease: "power2.out"
        });
    }

    function removeHoverState(element, cardId) {
        var target = getLowestZIndexChild(element) || element;
        var filterValue = cardVisited[cardId] ? "saturate(" + visitedSaturation + ")" : "brightness(1)";
        gsap.to(target, {
            filter: filterValue,
            duration: 0.2,
            ease: "power2.out"
        });
    }

    function applyVisitedState(element) {
        var target = getLowestZIndexChild(element) || element;
        gsap.to(target, {
            filter: "saturate(" + visitedSaturation + ")",
            duration: 0.3
        });
    }

    function checkAllVisited() {
        var allVisited = cards.every(function(card) {
            return cardVisited[card.id] === true;
        });

        if (allVisited) {
            player.SetVar(completionVariable, true);
            console.log("All cards visited - " + completionVariable + " set to true");
        }
    }

    function measureCards() {
        // Get measurements from first card (assume all same size)
        var firstElement = getCardElement(cards[0].id);
        if (firstElement) {
            // Reset transform to get true DOM position
            gsap.set(firstElement, { x: 0 });
            var rect = firstElement.getBoundingClientRect();
            cardWidth = rect.width;
            cardBaseLeft = rect.left;
        }
    }

    function initializeCards() {
        if (isInitialized) return;

        measureCards();

        var startFromLeft = startPosition === "left";

        for (var i = 0; i < cards.length; i++) {
            var card = cards[i];
            var element = getCardElement(card.id);
            if (!element) continue;

            cardVisited[card.id] = false;

            var targetX = getCardX(i, startPosition);
            var offscreenX = startFromLeft ? (targetX - 500) : (targetX + 500);

            if (typeof gsap !== 'undefined') {
                gsap.set(element, { x: offscreenX });
                gsap.to(element, {
                    x: targetX,
                    duration: 0.6,
                    delay: i * 0.15,
                    ease: "power3.out"
                });
            }

            cardStates[card.id] = startPosition;
        }

        isInitialized = true;
    }

    function moveCards(clickedCardId) {
        var clickedIndex = cards.findIndex(function(c) { return c.id === clickedCardId; });
        if (clickedIndex === -1) return;

        if (!cardVisited[clickedCardId]) {
            cardVisited[clickedCardId] = true;
            var clickedElement = getCardElement(clickedCardId);
            if (clickedElement) {
                applyVisitedState(clickedElement);
            }
            checkAllVisited();
        }

        var clickedCardState = cardStates[cards[clickedIndex].id];

        if (startPosition === "right") {
            if (clickedCardState === 'right') {
                for (var i = 0; i <= clickedIndex; i++) {
                    var card = cards[i];
                    var element = getCardElement(card.id);
                    if (!element) continue;
                    gsap.to(element, { x: getCardX(i, 'left'), duration: 0.5, ease: "power2.inOut" });
                    cardStates[card.id] = 'left';
                }
            } else {
                for (var i = clickedIndex; i < cards.length; i++) {
                    var card = cards[i];
                    var element = getCardElement(card.id);
                    if (!element) continue;
                    gsap.to(element, { x: getCardX(i, 'right'), duration: 0.5, ease: "power2.inOut" });
                    cardStates[card.id] = 'right';
                }
            }
        } else {
            if (clickedCardState === 'left') {
                for (var i = 0; i <= clickedIndex; i++) {
                    var card = cards[i];
                    var element = getCardElement(card.id);
                    if (!element) continue;
                    gsap.to(element, { x: getCardX(i, 'right'), duration: 0.5, ease: "power2.inOut" });
                    cardStates[card.id] = 'right';
                }
            } else {
                for (var i = clickedIndex; i < cards.length; i++) {
                    var card = cards[i];
                    var element = getCardElement(card.id);
                    if (!element) continue;
                    gsap.to(element, { x: getCardX(i, 'left'), duration: 0.5, ease: "power2.inOut" });
                    cardStates[card.id] = 'left';
                }
            }
        }
    }

    function addClickListeners() {
        cards.forEach(function(card) {
            var element = getCardElement(card.id);
            if (element) {
                element.style.cursor = 'pointer';

                var target = getLowestZIndexChild(element) || element;
                gsap.set(target, { filter: "brightness(1)" });

                element.addEventListener('click', function() {
                    moveCards(card.id);
                });

                element.addEventListener('mouseenter', function() {
                    applyHoverState(element);
                });

                element.addEventListener('mouseleave', function() {
                    removeHoverState(element, card.id);
                });
            }
        });
    }

    var resizeTimeout;
    window.addEventListener('resize', function() {
        if (!isInitialized) return;

        clearTimeout(resizeTimeout);
        resizeTimeout = setTimeout(function() {
            // Re-measure from first card
            var firstElement = getCardElement(cards[0].id);
            if (firstElement) {
                var currentX = parseFloat(gsap.getProperty(firstElement, "x")) || 0;
                gsap.set(firstElement, { x: 0 });
                var rect = firstElement.getBoundingClientRect();
                cardWidth = rect.width;
                cardBaseLeft = rect.left;
                gsap.set(firstElement, { x: currentX });
            }

            // Animate all cards to correct positions
            cards.forEach(function(card, index) {
                var element = getCardElement(card.id);
                if (!element) return;

                var targetX = getCardX(index, cardStates[card.id]);
                gsap.to(element, { x: targetX, duration: 0.3 });
            });
        }, 100);
    });

    waitForElements(function() {
        initializeCards();
        addClickListeners();
    });

    window.moveCards = moveCards;
}

if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initializeCardSystem);
} else {
    initializeCardSystem();
}

Step 5: Configure the Code for Your Project

The configuration section at the top of the code controls how your accordion behaves. Update these values to match your project:

Container ID

var containerId = "6EwRQaNDtUK";

Replace with your container's data-model-id. The container defines the boundaries where panels will animate.

Panel IDs

javascript

var cards = [
    { id: "684vi27AJJH" },
    { id: "6WYVxtWtFdc" },
    { id: "6HcbXnGrH2T" },
    { id: "5YsnWSnjEPE" }
];

Replace each ID with your actual panel IDs. The order matters—list them from bottom to top of your visual stack. Add or remove entries to match your number of panels.

Start Position

javascript

var startPosition = "right";

This determines where panels begin. Set to "right" to have panels stacked on the right side (sliding left when clicked) or "left" for the opposite behavior.

Visual Settings

var cardGapPercent = 6;
var hoverBrightness = 1.05;
var visitedSaturation = 0.9;
  • cardGapPercent: How much of each panel's tab remains visible when stacked (as a percentage of container width)

  • hoverBrightness: How much to brighten panels on mouse hover (1.0 = no change, 1.1 = 10% brighter)

  • visitedSaturation: Saturation level for visited panels (0.9 = slightly desaturated to indicate completion)

Understanding How the JavaScript Accordion Works

The code uses several key techniques that make it reliable and flexible.

Element Selection with data-model-id

The getCardElement function locates your Storyline objects in the browser's DOM. Storyline assigns each object a unique data-model-id attribute, which remains consistent across previews and publishes. Learn more about DOM selection methods in the MDN querySelector documentation.

Responsive Positioning

Rather than using fixed pixel positions, the code calculates panel positions based on the container's current size. The getCardX function determines where each panel should be based on the container bounds, panel index, and current state. This means your interaction works correctly even when the browser window resizes.

Staggered Animation Entry

When the slide loads, panels animate in with a staggered timing effect:

gsap.to(element, {
    x: targetX,
    duration: 0.6,
    delay: i * 0.15,
    ease: "power3.out"
});

Each panel enters 0.15 seconds after the previous one, creating a polished cascade effect.

State Management

The code tracks two types of state for each panel. The cardStates object records whether each panel is currently positioned left or right. The cardVisited object tracks which panels the learner has clicked. When all panels show as visited, the completion variable triggers.

Troubleshooting Common Issues

Panels don't appear or animate: Verify that GSAP loaded successfully by checking the browser console for errors. Make sure your GSAP loading trigger fires before the main code trigger.

Panels animate to wrong positions: Double-check your container ID. The container defines the boundaries for positioning calculations.

Clicking doesn't work: Confirm your panel IDs are correct. Preview the project and use browser developer tools to verify the data-model-id values match your configuration.

Completion variable doesn't trigger: Ensure your Storyline variable name exactly matches the completionVariable value in the code (including capitalization).

Extending the Interaction

Once you have the basic interaction working, consider these enhancements:

  • Add content layers that show/hide based on which panel is expanded

  • Connect the completion variable to a results slide or navigation controls

  • Adjust timing values to match your course's pacing

  • Create custom entrance animations by modifying the offscreenX calculation

Conclusion

You've now built a professional JavaScript accordion in Articulate Storyline. This technique demonstrates how combining Storyline's native authoring with JavaScript and GSAP opens up interaction possibilities that aren't available through the standard toolset alone.

The principles you learned here—finding element IDs, managing state, and using GSAP for animation—apply to many other custom interactions. Start with this foundation and experiment with your own variations.

For more Storyline JavaScript techniques, explore the Articulate Community Forums where developers share solutions and troubleshooting tips.