Home / Blog

How to Create SVG Path Animation in Articulate Storyline with JavaScript

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

How to Create SVG Path Animation in Articulate Storyline with JavaScript

Adding SVG path animation in Articulate Storyline transforms static graphics into engaging, dynamic visuals that capture learner attention. This technique creates a "drawing" effect where lines appear to trace themselves on screen, perfect for revealing process flows, diagrams, or interactive icons. In this tutorial, you'll learn how to implement a powerful PathTracer module that synchronizes SVG animations with Storyline's timeline.

Whether you're building compliance training, product tours, or interactive scenarios, animated SVG paths add a professional polish that keeps learners engaged. Let's walk through how to set this up step by step.

Getting Started with SVG Path Animation

What You'll Need Before Starting

Before diving into SVG path animation in Articulate Storyline, gather these essentials:

  • Articulate Storyline 360

  • SVG files with single-path elements (icons or simple illustrations work best)

  • Basic familiarity with adding JavaScript triggers in Storyline

  • A text editor for copying code

The animation system uses GSAP (GreenSock Animation Platform), a professional-grade animation library that loads automatically. You don't need to install anything separately.

Understanding the PathTracer Module

The PathTracer module handles all the complex animation logic. At its core, it performs three key functions: it inlines SVG images into the page, calculates path lengths for the drawing effect, and synchronizes animations with Storyline's progress bar.

Here's how the configuration works for each animated element:

const MODEL_CONFIG = {
  OWNERSHIP: {
    id: '60bQyfwQpJH',
    groupId: '5beyYi7D6WU',
    buttonName: 'Ownership',
    animation: {
      strokeColor: '#8064A2',
      strokeWidth: 4,
      strokeStartTime: 0.25,
      strokeDuration: 4,
      fillColor: '#8064A2',
      fillStartTime: 2.75,
      fillDuration: 1
    }
  }
};

Each configuration defines an element's unique ID (from Storyline), its animation colors, timing, and optional interactivity settings.

Step-by-Step Implementation

Step 1: Prepare Your SVG Files

Your SVG files need a specific structure for the animation to work correctly. Each SVG should contain a single <path> element that defines the shape you want to animate.

Open your SVG in a text editor and verify it contains something like this:

<svg viewBox="0 0 100 100">
  <path d="M10,50 Q50,10 90,50 T170,50" />
</svg>

If your SVG has multiple paths, you'll need to combine them into one path or use separate SVG files for each animated element. Tools like Adobe Illustrator or Inkscape can help you prepare SVGs with single paths.

Step 2: Add SVGs to Your Storyline Project

Import each SVG file into your Storyline slide. Position them where you want the animation to appear. The module works with both embedded SVGs and SVG images that Storyline converts to <img> tags.

After adding your SVGs, you need to assign data-model-id attributes so the PathTracer can find them. Storyline automatically generates unique IDs for each element, which you can see in the published output's HTML.

To find these IDs, publish your project and inspect the HTML. Each Storyline object gets a data-model-id attribute like data-model-id="60bQyfwQpJH". Note these IDs for each SVG element you want to animate.

Alternatively, you can assign your own data-model-id attributes using a JavaScript trigger at timeline start:

Trigger: Execute JavaScript
When: Timeline starts
Action: Add data-model-id attributes

// Find elements by their Storyline-generated class or structure
// and assign custom data-model-id values
var ownershipIcon = document.querySelector('.slide-object-ownership');
if (ownershipIcon) {
  ownershipIcon.setAttribute('data-model-id', '60bQyfwQpJH');
}

The key is matching the id values in your MODEL_CONFIG to the data-model-id attributes on your SVG container elements.

Step 3: Add the PathTracer JavaScript Code

Create a new Execute JavaScript trigger that runs when your slide's timeline starts. Paste the complete PathTracer module code below into the script field.

The module automatically loads GSAP from a CDN, so you don't need to host any additional files. According to MDN Web Docs, the animation uses requestAnimationFrame for smooth, performance-optimized rendering.

Complete PathTracer Module Code

Copy this entire code block into your Storyline JavaScript trigger:

(function () {

  /* ──────────────────────────────────────────────────────────────
     CENTRALIZED MODEL CONFIGURATION
  ────────────────────────────────────────────────────────────── */
  const MODEL_CONFIG = {
    OWNERSHIP: {
      id: '60bQyfwQpJH',
      groupId: '5beyYi7D6WU',
      buttonName: 'Ownership',
      animation: {
        strokeColor: '#8064A2',
        strokeWidth: 4,
        strokeStartTime: 0.25,
        strokeDuration: 4,
        fillColor: '#8064A2',
        fillStartTime: 2.75,
        fillDuration: 1
      }
    },
    COLLABORATION: {
      id: '6TLpPc0LWaG',
      groupId: '5ptUj3ujuqk',
      buttonName: 'Collaboration',
      animation: {
        strokeColor: '#1888E7',
        strokeWidth: 5,
        strokeStartTime: 1.75,
        strokeDuration: 4,
        fillColor: '#1888E7',
        fillStartTime: 3.25,
        fillDuration: 1
      }
    },
    EMPATHY: {
      id: '5ffhD4XIYew',
      groupId: '6B5XQC8OVEE',
      buttonName: 'Empathy',
      animation: {
        strokeColor: '#C0504D',
        strokeWidth: 6,
        strokeStartTime: 2.5,
        strokeDuration: 4,
        fillColor: '#C0504D',
        fillStartTime: 5,
        fillDuration: 1
      }
    }
  };

  // Helper functions to work with the config
  const ModelUtils = {
    getAllModelIds() {
      return Object.values(MODEL_CONFIG).map(config => config.id);
    },
    
    getConfigById(modelId) {
      return Object.values(MODEL_CONFIG).find(config => config.id === modelId);
    },
    
    getGroupMapping() {
      const mapping = {};
      Object.values(MODEL_CONFIG).forEach(config => {
        mapping[config.id] = config.groupId;
      });
      return mapping;
    },
    
    getButtonNames() {
      const names = {};
      Object.values(MODEL_CONFIG).forEach(config => {
        names[config.id] = config.buttonName;
      });
      return names;
    }
  };

  /* ──────────────────────────────────────────────────────────────
     GLOBAL STORAGE (persists across Storyline re-execs)
  ────────────────────────────────────────────────────────────── */
  if (!window._PathTracerGlobal) {
    window._PathTracerGlobal = {
      animationCompleted: {},
      tracerStates: {}          // stores final visual state per modelId
    };
  }

  /* ──────────────────────────────────────────────────────────────
     PATH TRACER MODULE
  ────────────────────────────────────────────────────────────── */
  var PT = {
    _tracers: {},
    _gsapLoaded: false,
    _gsapLoading: false,
    _gsapCbs: [],
    _animationFrameIds: {},

    /* ---------- GSAP loader (idempotent) ---------- */
    _ensureGSAP(cb) {
      if (window.gsap) {
        this._gsapLoaded = true;
        cb();
      } else {
        this._gsapCbs.push(cb);
        if (!this._gsapLoading) {
          this._gsapLoading = true;
          const s = document.createElement('script');
          s.src = 'https://unpkg.com/gsap@3/dist/gsap.min.js';
          s.onload = () => {
            this._gsapLoaded = true;
            this._gsapLoading = false;
            this._gsapCbs.forEach(fn => fn());
            this._gsapCbs.length = 0;
          };
          s.onerror = () => console.error('Failed to load GSAP');
          document.head.appendChild(s);
        }
      }
    },

    /* ---------- register()  (inline SVG, build tracer) ---------- */
    register(modelIds) {
      const ids = Array.isArray(modelIds) ? modelIds : [modelIds];
      const promises = ids.map(modelId => {

        if (this._tracers[modelId]) return Promise.resolve(modelId);

        const container = document.querySelector(`[data-model-id="${modelId}"]`);
        if (!container) return Promise.reject(`Container ${modelId} not found`);

        /* — inline SVG if <img> — */
        const svg = container.querySelector('svg');
        const svgPromise = svg
          ? Promise.resolve(svg)
          : (async () => {
              const img = container.querySelector('img');
              if (!img) throw `No SVG or <img> for ${modelId}`;
              const text = await (await fetch(img.src)).text();
              const inl = new DOMParser().parseFromString(text, 'image/svg+xml').documentElement;
              inl.setAttribute('width', img.getAttribute('width') || '100%');
              inl.setAttribute('height', img.getAttribute('height') || '100%');
              img.parentNode.replaceChild(inl, img);
              return inl;
            })();

        return svgPromise.then(svgEl => {
          const orig = svgEl.querySelector('path');
          if (!orig) throw `No <path> in SVG for ${modelId}`;

          orig.style.visibility = 'hidden';

          svgEl.querySelector('path.__pt')?.remove();          // purge old tracer
          const trace = orig.cloneNode(true);
          const cs   = getComputedStyle(orig);
          const len  = trace.getTotalLength();

          trace.classList.add('__pt');
          trace.setAttribute('stroke',      cs.stroke && cs.stroke !== 'none' ? cs.stroke : '#000');
          trace.setAttribute('stroke-width', parseFloat(cs.strokeWidth) > 0 ? cs.strokeWidth : 1);
          trace.setAttribute('stroke-linecap',  'round');
          trace.setAttribute('stroke-linejoin', 'round');
          trace.setAttribute('fill','none');
          trace.style.strokeDasharray = `${len} ${len}`;
          trace.style.strokeDashoffset = len;
          trace.style.pointerEvents = 'none';
          trace.style.visibility = 'visible';
          trace.style.opacity = '1';

          svgEl.appendChild(trace);
          svgEl.style.pointerEvents = 'none';

          this._tracers[modelId] = {
            container,
            trace,
            length: len,
            timeline: null,
            initialProps: {},
            hoverEnabled: false,
            clickEnabled: false
          };

          this._makeStorylineClickable(container, modelId);

          /* cache original transforms */
          this._ensureGSAP(() => {
            const p = this._tracers[modelId].initialProps;
            p.x   = gsap.getProperty(container,"x");
            p.y   = gsap.getProperty(container,"y");
            p.scale = gsap.getProperty(container,"scale");
            p.rotate= gsap.getProperty(container,"rotation");
            p.skewX = gsap.getProperty(container,"skewX");
            p.skewY = gsap.getProperty(container,"skewY");
            p.opacity = gsap.getProperty(container,"opacity");
            p.blur  = parseFloat(getComputedStyle(container).filter.match(/blur\((\d+)px\)/)?.[1]) || 0;
            p.clipPath = getComputedStyle(container).clipPath || 'none';
            p.fillColor= getComputedStyle(orig).fill || 'none';
          });

          return modelId;
        });
      });

      return Promise.all(promises);
    },

    /* ---------- interactivity pass-through ---------- */
    _makeStorylineClickable(container, modelId) {
      const groupMapping = ModelUtils.getGroupMapping();
      const buttonNames = ModelUtils.getButtonNames();
      
      const groupId = groupMapping[modelId];
      const hoverTarget = groupId && document.querySelector(`[data-model-id="${groupId}"]`) || container;

      container.style.pointerEvents = 'auto';
      container.style.cursor = 'default';
      container.querySelectorAll('svg,path,.__pt').forEach(el=>el.style.pointerEvents='none');

      hoverTarget.addEventListener('mouseenter', ()=> {
        if (this._tracers[modelId]?.hoverEnabled && window.gsap) {
          gsap.to(hoverTarget,{scale:1.03,duration:0.2,ease:'power2.out',transformOrigin:'center'});
        }
      });
      hoverTarget.addEventListener('mouseleave', ()=> {
        if (this._tracers[modelId]?.hoverEnabled && window.gsap) {
          gsap.to(hoverTarget,{scale:1,duration:0.2,ease:'power2.out',transformOrigin:'center'});
        }
      });

      container.addEventListener('click', ()=> {
        if (!this._tracers[modelId].clickEnabled) return;
        const btn = buttonNames[modelId];
        if (!btn) return;
        ['GetPlayer',null].forEach(fn=>{
          const p = fn?window[fn]() : window.parent?.GetPlayer?.() || window.top?.GetPlayer?.();
          p?.SetVar?.('activityButton',btn);
        });
      });
    },

    _enableInteractivity(modelId){
      const t=this._tracers[modelId];
      if(t){t.hoverEnabled=t.clickEnabled=true;t.container.style.cursor='pointer';}
    },

    /* ---------- SYNC (with freeze & completed guards) ---------- */
    sync(modelId, slideDuration, cfg={}) {
      this.register(modelId).then(()=> {
        const t=this._tracers[modelId];
        this._ensureGSAP(()=> {

          /* if completed earlier, just render stored state and quit */
          if (window._PathTracerGlobal.animationCompleted[modelId]) {
            const s=window._PathTracerGlobal.tracerStates[modelId];
            if (s){
              gsap.set(t.container,{visibility:'visible',opacity:s.opacity??1});
              gsap.set(t.trace,{
                visibility:'visible',
                opacity:s.traceOpacity??1,
                stroke:s.strokeColor, strokeWidth:s.strokeWidth,
                fill:s.fillColor || 'none', strokeDashoffset:0
              });
              this._enableInteractivity(modelId);
            }
            return;
          }

          /* timeline build */
          const strokeStart=cfg.strokeStartTime||0,
                strokeDur  =cfg.strokeDuration || slideDuration,
                fillStart  =cfg.fillStartTime ?? (strokeStart+strokeDur),
                fillDur    =cfg.fillDuration || 1.5,
                tlTotal    =Math.max(strokeStart+strokeDur, fillStart+fillDur);

          t.timeline?.kill();
          t.timeline = gsap.timeline({
            paused:true,
            onComplete:()=>{
              window._PathTracerGlobal.animationCompleted[modelId]=true;
              window._PathTracerGlobal.tracerStates[modelId]={
                strokeColor: cfg.strokeColor || t.trace.getAttribute('stroke'),
                strokeWidth: cfg.strokeWidth || t.trace.getAttribute('stroke-width'),
                fillColor  : cfg.fillColor   || t.trace.style.fill,
                opacity    : gsap.getProperty(t.container,'opacity'),
                traceOpacity:gsap.getProperty(t.trace,'opacity')
              };
              this._enableInteractivity(modelId);
            }
          });

          if (cfg.strokeColor) t.trace.setAttribute('stroke',cfg.strokeColor);
          if (cfg.strokeWidth) t.trace.setAttribute('stroke-width',cfg.strokeWidth);
          t.trace.style.strokeDashoffset = t.length+'px';

          t.timeline.to(t.trace,{
            strokeDashoffset:0,duration:strokeDur,ease:'none'
          },strokeStart);

          if (cfg.fillColor){
            t.timeline.to(t.trace,{
              fill:cfg.fillColor,duration:fillDur,ease:'none'
            },fillStart);
          }

          /* container transforms */
          const cProps={};
          ['toX','toY','toScale','toRotate','toOpacity','toSkewX','toSkewY'].forEach(k=>{
            if(cfg[k]!==undefined)cProps[{toX:'x',toY:'y',toScale:'scale',toRotate:'rotation',
              toOpacity:'opacity',toSkewX:'skewX',toSkewY:'skewY'}[k]]=cfg[k];
          });
          if(cfg.toBlur!==undefined) cProps.filter=`blur(${cfg.toBlur}px)`;
          if(cfg.toClipPath!==undefined) cProps.clipPath=cfg.toClipPath;
          if(Object.keys(cProps).length) t.timeline.to(t.container,{...cProps,duration:slideDuration,ease:'none'},0);

          /* initial play then pause (pre-draw) */
          t.timeline.play().pause(0);

          /* ---------- SYNC LOOP with FREEZE ---------- */
          const progressBarFill=document.querySelector('div[data-ref="progressBarFill"]');
          if(!progressBarFill)return;

          this.stopSync(modelId);
          const self=this;
          const player = typeof GetPlayer==="function" ? GetPlayer() : null;

          function loop(){
            const cur=self._tracers[modelId];
            if(!cur||!cur.timeline)return;

            /* FREEZE: skip when variable true */
            if(player?.GetVar?.('AnimationFreeze')){
              self._animationFrameIds[modelId]=requestAnimationFrame(loop);
              return;
            }

            const m=/width:\s*([\d.]+)%/.exec(progressBarFill.getAttribute('style')||'');
            if(m){
              const pct=parseFloat(m[1])/100;
              const tlProg=(pct*slideDuration)/tlTotal;
              cur.timeline.progress(tlProg);

              // Fix: Ensure stroke is completely hidden at 0% progress to prevent dots
              if(pct < 0.001){
                cur.trace.style.strokeDashoffset = cur.length + 'px';
                cur.trace.style.fill = 'none';
              }
            }
            self._animationFrameIds[modelId]=requestAnimationFrame(loop);
          }
          this._animationFrameIds[modelId]=requestAnimationFrame(loop);
        });
      });
    },

    /* ---------- Convenience method to sync with predefined config ---------- */
    syncWithConfig(modelName, slideDuration, overrides = {}) {
      const config = MODEL_CONFIG[modelName];
      if (!config) {
        console.error(`Model config not found: ${modelName}`);
        return;
      }
      
      const animationConfig = { ...config.animation, ...overrides };
      this.sync(config.id, slideDuration, animationConfig);
    },

    /* ---------- stop / reset helpers ---------- */
    stopSync(modelId){
      if(this._animationFrameIds[modelId]){
        cancelAnimationFrame(this._animationFrameIds[modelId]);
        delete this._animationFrameIds[modelId];
      }
      this._tracers[modelId]?.timeline?.pause();
    },

    reset(modelId){
      const t=this._tracers[modelId];
      if(!t)return;
      this.stopSync(modelId);
      window._PathTracerGlobal.animationCompleted[modelId]=false;
      delete window._PathTracerGlobal.tracerStates[modelId];
      t.timeline?.kill(); t.timeline=null;
      t.trace.style.strokeDashoffset=t.length+'px';
      t.trace.style.fill='none';
      t.container.style.visibility='hidden';
      gsap.set(t.container,{...t.initialProps,filter:`blur(${t.initialProps.blur}px)`});
      gsap.set(t.trace,{stroke:t.initialProps.strokeColor,strokeWidth:t.initialProps.strokeWidth,fill:t.initialProps.fillColor});
      t.hoverEnabled=t.clickEnabled=false;
      t.container.style.cursor='default';
    },

    resetAll(){
      Object.keys(this._tracers).forEach(id=>this.reset(id));
    }
  };

  // Expose configuration for external access
  PT.ModelConfig = MODEL_CONFIG;
  PT.ModelUtils = ModelUtils;

  window.PathTracer = PT;

})();

/* ──────────────────────────────────────────────────────────────
   INVOCATION - Initialize and start animations
────────────────────────────────────────────────────────────── */
PathTracer.register(PathTracer.ModelUtils.getAllModelIds()).then(() => {
  const slideDur = 10; // Match your slide duration in seconds

  PathTracer.syncWithConfig('OWNERSHIP', slideDur);
  PathTracer.syncWithConfig('COLLABORATION', slideDur);
  PathTracer.syncWithConfig('EMPATHY', slideDur);
});

Remember to update the MODEL_CONFIG section with your own element IDs and animation settings before using this code.

Step 4: Configure Your Animation Settings

Customize the MODEL_CONFIG object for each SVG you want to animate. Here's what each property controls:

Property

Description

id

Unique identifier matching your data-model-id

groupId

ID of parent group for hover effects

buttonName

Value sent to Storyline variable on click

strokeColor

Color of the animated line

strokeWidth

Thickness of the animated line

strokeStartTime

When drawing begins (seconds)

strokeDuration

How long the drawing takes

fillColor

Color that fills the shape after drawing

fillStartTime

When fill animation begins

fillDuration

How long the fill takes

Adjust these values to create staggered animations where multiple icons draw in sequence.

Step 5: Initialize the Animations

At the bottom of your JavaScript, add the initialization code that registers your elements and starts the animations:

PathTracer.register(PathTracer.ModelUtils.getAllModelIds()).then(() => {
  const slideDur = 10; // Match your slide duration in seconds

  PathTracer.syncWithConfig('OWNERSHIP', slideDur);
  PathTracer.syncWithConfig('COLLABORATION', slideDur);
  PathTracer.syncWithConfig('EMPATHY', slideDur);
});

The slideDur value should match your Storyline slide's timeline duration. This ensures animations stay synchronized even when learners scrub backward or forward.

Advanced Features and Interactivity

Adding Interactivity to Your Animations

The PathTracer module includes built-in hover and click functionality. After an animation completes, the element becomes interactive. Hovering scales the icon slightly, and clicking sets a Storyline variable.

Create a Storyline variable called activityButton (text type). When learners click an animated icon, this variable receives the buttonName value from your configuration. Use this to trigger layers, navigation, or conditional logic.

For example, clicking the "Ownership" icon sets activityButton to "Ownership". You can then add a Storyline trigger that shows a specific layer when this variable changes.

Handling Timeline Scrubbing and Replay

One challenge with Storyline animations is maintaining state when learners scrub the timeline. The PathTracer solves this with its sync function, which continuously reads Storyline's progress bar and updates the animation accordingly.

The module also supports an "AnimationFreeze" variable. Create this as a True/False variable in Storyline. When set to True, animations pause at their current state, useful for assessment slides or pause points.

// The sync loop automatically checks this variable
if(player?.GetVar?.('AnimationFreeze')){
  // Animation pauses until variable becomes False
  return;
}

Troubleshooting and Optimization

Troubleshooting Common Issues

If your animations aren't appearing, check these common issues:

Animation doesn't start: Verify your data-model-id attributes match the IDs in MODEL_CONFIG. Case sensitivity matters.

Path appears as a dot: This usually means your SVG has an extremely short path. Check that your SVG's d attribute contains actual drawing commands, not just a single point.

Animation doesn't sync with scrubbing: Ensure your slide contains Storyline's default progress bar. The module reads from div[data-ref="progressBarFill"].

GSAP fails to load: If you're publishing to an LMS with strict security policies, you may need to host GSAP locally rather than loading from the CDN.

Performance Optimization Tips

For smooth animations, keep these best practices in mind.

Use simple SVG paths with fewer nodes. Complex illustrations with hundreds of anchor points will animate more slowly. Aim for paths under 50 nodes when possible.

Limit simultaneous animations to 3-5 elements. The module handles multiple animations efficiently, but too many concurrent animations can affect performance on older devices.

Test on target devices early. Mobile browsers handle SVG animations differently than desktop browsers. Publish and test on actual learner devices before finalizing.

Key Takeaways

SVG path animation in Articulate Storyline creates engaging, professional visuals without requiring video files or complex timeline animations. The PathTracer module handles synchronization automatically, and once configured, you can reuse the same pattern across multiple projects.

Start with simple icons before attempting complex illustrations. As you become comfortable with the configuration options, experiment with staggered timing, color fills, and interactive elements. Your learners will appreciate the polished, dynamic experience.

Ready to try it yourself? Download the complete code from this tutorial and start animating your first SVG today.