GSAP Instructions

Code Placement Overview

Ordinis includes 3 GSAP animations. Each is self-contained — adjust or remove any one without affecting the others. All timing values live in a single cfg object at the top of each function.

AnimationCode LocationScope
01 — Hero Image SliderHome Page → Before </body>Home only
02 — Case Study List HoverHome Page → Before </body>Home only
03 — Testimony Slider DragSite Settings → Before </body>All pages

Animation 01 — Hero Image Slider

Location: Home Page → Before </body> function initHeroGsapAnimations

What It Does

Auto-cycles through hero images every 4 seconds using a crossfade with a subtle scale-in effect on each incoming slide. As each image transitions, the matching stat number counts up from zero and the SVG path strokes in. If prefers-reduced-motion is active, the animation is skipped entirely.

Code Block

initHeroGsapAnimations — Home Page
if (typeof gsap !== 'undefined') gsap.delayedCall(0, initHeroGsapAnimations);

function initHeroGsapAnimations() {
  gsap.config({ nullTargetWarn: false });

  var heroImage = document.querySelector('.hero_image');
  var root = document.querySelector('.hero_image_stats');
  if (!heroImage || !root) return;

  var slides = gsap.utils.toArray(heroImage.querySelectorAll('.hero_image_item'));
  if (slides.length < 2) return;

  var titleEls = gsap.utils.toArray(root.querySelectorAll('.hero_image_overlay_title'));
  var textEls = gsap.utils.toArray(root.querySelectorAll('.hero_image_overlay_text'));
  var N = Math.min(slides.length, titleEls.length, textEls.length);
  if (N < 2) return;

  var cfg = { fade: 0.85, hold: 4, num: 1.15, scaleIn: 1.25 };
  var path = root.querySelector('.info_stats_indicator path');

  var proxies = [];
  while (proxies.length < N) proxies.push({ value: 0 });

  var gsapToggleActive = function (el, on) {
    var c = String(gsap.getProperty(el, 'className') || '')
      .replace(/\bis-active\b/g, ' ')
      .replace(/\s+/g, ' ')
      .trim();
    if (on) c = c ? c + ' is-active' : 'is-active';
    gsap.set(el, { attr: { class: c } });
  };

  var meta = [];
  gsap.utils.toArray(titleEls.slice(0, N)).forEach(function (el, i) {
    var raw = el.textContent.trim();
    var m = raw.match(/^([\d.,]+)([\s\S]*)$/);
    var target = m ? parseFloat(m[1].replace(/,/g, '')) : NaN;
    if (!m || !Number.isFinite(target)) { meta[i] = { ok: false }; return; }
    gsap.set(el, { innerHTML: '<span class="hero_overlay_num">0</span><span class="hero_overlay_sfx"></span>' });
    var num = el.querySelector('.hero_overlay_num');
    var sfx = el.querySelector('.hero_overlay_sfx');
    gsap.set(sfx, { textContent: m[2] });
    meta[i] = { ok: true, num: num, target: target, setNum: gsap.quickSetter(num, 'textContent') };
  });

  var numLayout = function () {
    var tl = gsap.timeline();
    gsap.utils.toArray(meta).forEach(function (o) {
      if (!o.ok) return;
      var ch = Math.max(1, String(Math.round(o.target)).length);
      tl.set(o.num, { display: 'inline-block', minWidth: ch + 'ch', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }, 0);
    });
  };

  var dashPrep = function () {
    if (!path) return 0;
    var L = path.getTotalLength();
    if (L > 0) gsap.set(path, { strokeDasharray: L, strokeDashoffset: L });
    return L;
  };

  var drawPath = function () {
    if (!path || dashPrep() <= 0) return;
    gsap.killTweensOf(path);
    gsap.set(path, { strokeDashoffset: path.getTotalLength() });
    gsap.to(path, { strokeDashoffset: 0, duration: cfg.fade, ease: 'power2.inOut' });
  };

  var setStat = function (i) {
    gsap.utils.toArray(titleEls).forEach(function (el, j) {
      var inRange = j < N;
      var on = inRange && j === i;
      gsapToggleActive(el, on);
      var o = inRange ? meta[j] : null;
      if (inRange && !on && o && o.ok) {
        gsap.killTweensOf(proxies[j]);
        gsap.set(proxies[j], { value: 0 });
        o.setNum('0');
      }
    });
    gsap.utils.toArray(textEls).forEach(function (el, j) {
      gsapToggleActive(el, j < N && j === i);
    });
  };

  var countUp = function (idx) {
    var o = meta[idx];
    if (!o || !o.ok) return;
    var p = proxies[idx];
    gsap.killTweensOf(p);
    gsap.set(p, { value: 0 });
    o.setNum('0');
    gsap.to(p, {
      value: o.target, duration: cfg.num, ease: 'power2.out',
      onUpdate: function () { o.setNum(String(Math.round(p.value))); },
      onComplete: function () { o.setNum(String(Math.round(o.target))); }
    });
  };

  gsap.context(function () {
    numLayout();
    dashPrep();
    gsap.set(heroImage, { position: 'relative', overflow: 'hidden' });
    var bootTl = gsap.timeline();
    gsap.utils.toArray(slides).forEach(function (item, i) {
      bootTl.set(item, {
        position: 'absolute', top: 0, left: 0, width: '100%', height: '100%',
        display: 'block', autoAlpha: i < N && i === 0 ? 1 : 0, scale: 1,
        transformOrigin: '50% 50%', pointerEvents: i < N ? 'auto' : 'none'
      }, 0);
    });

    var cur = 0;
    var holdDc;

    var advance = function () {
      var next = (cur + 1) % N;
      var outEl = slides[cur];
      var inEl = slides[next];
      gsap.set(inEl, { autoAlpha: 0, scale: cfg.scaleIn });
      gsap.timeline({
        onComplete: function () {
          gsap.set(outEl, { autoAlpha: 0 });
          gsap.set(inEl, { scale: 1 });
          cur = next;
          holdDc = gsap.delayedCall(cfg.hold, advance);
        }
      })
        .call(function () { setStat(next); countUp(next); drawPath(); })
        .to(outEl, { autoAlpha: 0, duration: cfg.fade, ease: 'power2.inOut' }, 0)
        .to(inEl, { autoAlpha: 1, scale: 1, duration: cfg.fade, ease: 'power2.inOut' }, 0);
    };

    setStat(0);
    countUp(0);
    gsap.delayedCall(0, function () { gsap.delayedCall(0, drawPath); });
    holdDc = gsap.delayedCall(cfg.hold, advance);
  }, heroImage);
}

Element Map

  • .hero_imageSlide container. Position and overflow set by GSAP on init.
  • .hero_image_itemIndividual slides. Stacked absolutely; crossfade via @@PROTECT2@@ + scale.
  • .hero_image_statsStats panel root. Used to scope all stat queries.
  • .hero_image_overlay_titleStat number labels. GSAP reads the value and animates a count-up from 0.
  • .hero_image_overlay_textStat description text. Toggled with @@PROTECT3@@ class on each transition.
  • .info_stats_indicator pathSVG path. Stroke draws from 0 to full length on each slide change.

Customizing Key Variables

  • cfg.hold — autoplay delay (seconds)

    How long each slide stays visible before advancing. Default: 4s.

    var cfg = { fade: 0.85, hold: 6, num: 1.15, scaleIn: 1.25 };
  • cfg.fade — crossfade duration (seconds)

    Transition speed. Also controls the SVG stroke draw. Default: 0.85s. Range: 0.5–1.2.

    var cfg = { fade: 0.6, hold: 4, num: 1.15, scaleIn: 1.25 };
  • cfg.num — counter duration (seconds)

    How long the number counts up from 0 to target. Default: 1.15s.

    var cfg = { fade: 0.85, hold: 4, num: 2, scaleIn: 1.25 };
  • cfg.scaleIn — incoming slide start scale

    Scale the next slide starts at before settling to 1×. Default: 1.25. Lower = subtler zoom.

    var cfg = { fade: 0.85, hold: 4, num: 1.15, scaleIn: 1.1 };

Removing the Animation

  • Go to Home page settings → Custom code → Before </body>
  • Delete or comment out the entire initHeroGsapAnimations function and its call line
  • Hero images revert to default Webflow stacking — ensure only one .hero_image_item is visible in the Designer
  • SVG path on .info_stats_indicator will not animate — set stroke-dashoffset: 0 in CSS to keep it always visible

Animation 02 — Case Study List Hover

Location: Home Page → Before </body> function initCaseStudyListGsap

What It Does

On desktop (≥ 992 px), hovering a case study row crossfades in the matching image with a subtle scale-zoom from 1.25×. The active row's link text turns white; inactive rows dim to grey. No animation fires on tablet or mobile — layout falls back to default Webflow styles.

Code Block

initCaseStudyListGsap — Home Page
if (typeof gsap !== 'undefined') gsap.delayedCall(0, initCaseStudyListGsap);

function initCaseStudyListGsap() {
  gsap.config({ nullTargetWarn: false });

  var list = document.querySelector('.case-study_list');
  if (!list) return;

  var items = gsap.utils.toArray(list.querySelectorAll('.case-study_item'));
  if (items.length < 1) return;

  var imageWraps = [];
  var links = [];
  items.forEach(function (item) {
    links.push(item.querySelector('.case-study_link'));
    imageWraps.push(item.querySelector('.case-study_image'));
  });
  if (!links[0] || !imageWraps[0]) return;

  gsap.matchMedia().add('(min-width: 992px)', function () {
    var cfg = { fade: 0.85, scaleIn: 1.25 };
    var colorActive = '#ffffff';
    var colorInactive = '#9C9C9C';

    function coversFor(wrap) {
      return wrap ? gsap.utils.toArray(wrap.querySelectorAll('.object-fit-cover')) : [];
    }

    var settledIndex = 0;
    var swapTl = null;
    var ac = new AbortController();

    function hardResetLayering(exceptVisibleIndex) {
      imageWraps.forEach(function (w, i) {
        if (!w) return;
        gsap.killTweensOf(w);
        var cov = coversFor(w);
        gsap.killTweensOf(cov);
        var on = i === exceptVisibleIndex;
        gsap.set(w, { autoAlpha: on ? 1 : 0, zIndex: on ? 1 : 0 });
        gsap.set(cov, { scale: 1, transformOrigin: '50% 50%' });
      });
    }

    function applyLinkColors(activeIdx) {
      gsap.killTweensOf(links);
      links.forEach(function (link, j) {
        if (!link) return;
        gsap.to(link, { color: j === activeIdx ? colorActive : colorInactive, duration: cfg.fade * 0.45, ease: 'power2.out' });
      });
    }

    function goTo(nextIndex) {
      if (swapTl) swapTl.kill();
      hardResetLayering(settledIndex);
      if (nextIndex === settledIndex) { applyLinkColors(settledIndex); return; }

      var outWrap = imageWraps[settledIndex];
      var inWrap = imageWraps[nextIndex];
      if (!outWrap || !inWrap) return;

      var outCovers = coversFor(outWrap);
      var inCovers = coversFor(inWrap);

      gsap.set(inWrap, { autoAlpha: 0, zIndex: 2 });
      gsap.set(inCovers, { scale: cfg.scaleIn, transformOrigin: '50% 50%' });
      gsap.set(outWrap, { zIndex: 1, autoAlpha: 1 });

      swapTl = gsap.timeline({
        onComplete: function () {
          imageWraps.forEach(function (w, i) {
            if (!w) return;
            gsap.set(w, i === nextIndex ? { autoAlpha: 1, zIndex: 1 } : { autoAlpha: 0, zIndex: 0 });
          });
          imageWraps.forEach(function (w) {
            if (!w) return;
            gsap.set(coversFor(w), { scale: 1 });
          });
          settledIndex = nextIndex;
        }
      })
        .to(outWrap, { autoAlpha: 0, duration: cfg.fade, ease: 'power2.inOut' }, 0)
        .to(inWrap, { autoAlpha: 1, duration: cfg.fade, ease: 'power2.inOut' }, 0)
        .to(inCovers, { scale: 1, duration: cfg.fade, ease: 'power2.inOut' }, 0);

      applyLinkColors(nextIndex);
    }

    var ctx = gsap.context(function () {
      hardResetLayering(0);
      gsap.set(links[0], { color: colorActive });
      for (var k = 1; k < links.length; k++) {
        if (links[k]) gsap.set(links[k], { color: colorInactive });
      }
      items.forEach(function (_item, i) {
        var link = links[i];
        if (!link) return;
        link.addEventListener('mouseenter', function () { goTo(i); }, { signal: ac.signal });
      });
    }, list);

    return function () { if (swapTl) swapTl.kill(); ac.abort(); ctx.revert(); };
  });
}

Element Map

  • .case-study_listScope root for all queries. GSAP context is bound here.
  • .case-study_itemEach row. Mouseenter triggers the image swap.
  • .case-study_linkRow link element. Color animates between white (active) and grey (inactive).
  • .case-study_imageImage wrapper per row. Stacked absolutely; crossfades via @@PROTECT1@@.
  • .object-fit-coverImage inside wrapper. Scales from @@PROTECT2@@ to 1 as it fades in.

Customizing Key Variables

  • cfg.fade — crossfade duration (seconds)

    Image swap speed. Default: 0.85s. Link colour transition runs at ~45% of this value.

    var cfg = { fade: 0.5, scaleIn: 1.25 };
  • cfg.scaleIn — image entry scale

    Scale the incoming image starts at before settling to 1×. Default: 1.25. Set to 1.1 for a subtler zoom.

    var cfg = { fade: 0.85, scaleIn: 1.1 };
  • colorActive / colorInactive — link text colours

    Text colour for the hovered row and all others. Match to your brand palette if changed.

    var colorActive = '#ffffff'; var colorInactive = '#9C9C9C';

Removing the Animation

  • Go to Home page settings → Custom code → Before </body>
  • Delete or comment out the initCaseStudyListGsap function and its call line
  • All .case-study_image elements will be visible simultaneously — add display: none or hide all but the first in the Designer
  • Link colours revert to their CSS defaults; no manual fix needed
Desktop only. Wrapped in gsap.matchMedia('(min-width: 992px)') — will not fire on tablet or mobile. Removing it does not affect those breakpoints.

Animation 03 — Testimony Slider Drag

Location: Site Settings → Before </body> function initTestimonySlider Plugin: Observer

What It Does

Turns the testimony section into a draggable horizontal slider using GSAP Observer. Dragging or flicking snaps to the nearest slide with rubber-band resistance at both edges. On mouse devices, a custom pill cursor follows the pointer and fades in/out. This code runs site-wide — it exits silently on pages without the testimony section.

Code Block

initTestimonySlider — Site Settings (global)
if (typeof gsap !== 'undefined') {
  gsap.registerPlugin(Observer);
  gsap.delayedCall(0, initTestimonySlider);
}

function initTestimonySlider() {
  gsap.config({ nullTargetWarn: false });

  document.querySelectorAll('.testimony_content').forEach(function (root) {
    var wrapper = root.querySelector('.testimony_slider_wrapper');
    var slides = gsap.utils.toArray(root.querySelectorAll('.testimony_slider_item'));
    var pill = root.querySelector('.testimony_hover_indicator');

    if (!wrapper || slides.length < 2) return;

    var activeIndex = 0;
    var pressStartX = 0;
    var resizeDc;

    function slideGap() { return parseFloat(getComputedStyle(wrapper).columnGap) || 24; }
    function slideOffset(i) {
      var gap = slideGap();
      var offset = 0;
      for (var j = 0; j < i; j++) offset += slides[j].offsetWidth + gap;
      return offset;
    }
    function maxTravel() { return Math.min(0, root.offsetWidth - wrapper.scrollWidth); }

    function animateIn(i) {
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
      var content = slides[i] && slides[i].querySelector('.testimony_slider_video_content, .testimony_slider_text_content');
      if (!content) return;
      gsap.killTweensOf(content);
      gsap.fromTo(content, { opacity: 0.94 }, { opacity: 1, duration: 0.28, ease: 'power2.out', overwrite: 'auto' });
    }

    function goTo(i) {
      var mx = maxTravel();
      var tx = Math.round(gsap.utils.clamp(mx, 0, -slideOffset(i)));
      if (i !== activeIndex) { activeIndex = i; animateIn(i); }
      gsap.to(wrapper, { x: tx, duration: 0.55, ease: 'power3.out' });
    }

    function snap(velocityX, totalDelta) {
      var sw = slides[0] ? slides[0].offsetWidth : 200;
      var isFlick = Math.abs(velocityX) > 200;
      var isDraggedFar = Math.abs(totalDelta) > Math.max(60, sw * 0.15);
      if (isFlick || isDraggedFar) {
        goTo(gsap.utils.clamp(0, slides.length - 1, activeIndex + (totalDelta < 0 ? 1 : -1)));
        return;
      }
      var cx = gsap.getProperty(wrapper, 'x');
      var best = 0;
      var bestDist = Infinity;
      for (var i = 0; i < slides.length; i++) {
        var d = Math.abs(cx + slideOffset(i));
        if (d < bestDist) { bestDist = d; best = i; }
      }
      goTo(best);
    }

    gsap.set(root, { touchAction: 'pan-y', userSelect: 'none', cursor: 'grab' });

    Observer.create({
      target: root, type: 'pointer,touch', preventDefault: false,
      onPress: function () { gsap.killTweensOf(wrapper); pressStartX = gsap.getProperty(wrapper, 'x'); gsap.set(root, { cursor: 'grabbing' }); },
      onDrag: function (self) {
        var mx = maxTravel();
        var rawX = pressStartX + (self.x - self.startX);
        gsap.set(wrapper, { x: rawX > 0 ? rawX * 0.25 : rawX < mx ? mx + (rawX - mx) * 0.25 : rawX });
      },
      onRelease: function (self) { gsap.set(root, { cursor: 'grab' }); snap(self.velocityX, self.x - self.startX); }
    });

    window.addEventListener('resize', function () {
      if (resizeDc) resizeDc.kill();
      resizeDc = gsap.delayedCall(0.15, function () { goTo(activeIndex); });
    });

    if (pill) {
      gsap.set(pill, { left: 0, top: 0, xPercent: -50, yPercent: -50, autoAlpha: 0, pointerEvents: 'none' });
      var qx = gsap.quickTo(pill, 'x', { duration: 0.42, ease: 'power2.out' });
      var qy = gsap.quickTo(pill, 'y', { duration: 0.42, ease: 'power2.out' });
      Observer.create({
        target: root, type: 'pointer',
        onMove: function (self) {
          if (self.event.pointerType !== 'mouse') return;
          gsap.to(pill, { autoAlpha: 1, duration: 0.15 });
          var b = root.getBoundingClientRect();
          qx(self.x - b.left);
          qy(self.y - b.top);
        },
        onHoverEnd: function () { gsap.to(pill, { autoAlpha: 0, duration: 0.15 }); }
      });
    }
  });
}

Element Map

  • .testimony_contentDrag root. Observer and pill cursor are bound here per slider instance.
  • .testimony_slider_wrapperThe moving track. GSAP translates this on the X axis during drag and snap.
  • .testimony_slider_itemIndividual slide cards. Width is read to calculate snap positions.
  • .testimony_slider_video_contentVideo slide inner content. Fades from 0.94 to 1 opacity on snap-in.
  • .testimony_slider_text_contentText slide inner content. Same fade-in as video content on snap-in.
  • .testimony_hover_indicatorPill cursor element. Follows mouse via GSAP quickTo; hidden on touch devices.

Customizing Key Variables

  • snap duration — slide animation speed

    How fast the track snaps to position after releasing. Default: 0.55s. Range: 0.3–0.8.

    gsap.to(wrapper, { x: tx, duration: 0.4, ease: 'power3.out' });
  • flick velocity threshold (px/s)

    Minimum Observer velocity to count as a flick and advance one slide. Default: 200 px/s. Lower = more sensitive.

    var isFlick = Math.abs(velocityX) > 150;
  • drag distance threshold

    Minimum travel to advance one slide. Default: larger of 60 px or 15% of slide width.

    var isDraggedFar = Math.abs(totalDelta) > Math.max(40, sw * 0.10);
  • pill cursor follow speed

    How fast the pill cursor chases the pointer. Default: 0.42s. Lower = snappier.

    var qx = gsap.quickTo(pill, 'x', { duration: 0.28, ease: 'power2.out' }); var qy = gsap.quickTo(pill, 'y', { duration: 0.28, ease: 'power2.out' });

Removing the Animation

  • Go to Site Settings → Custom code → Before </body>
  • Delete or comment out the gsap.registerPlugin(Observer) line, the entire initTestimonySlider function and its call line
  • .testimony_slider_wrapper retains no inline transform — scrolling reverts to native CSS overflow
  • The .testimony_hover_indicator pill remains in the DOM but hidden by its default CSS; no visual side effect
  • You can also disable Observer in Site Settings → GSAP plugins if no other code uses it
Global code. This function runs on every page. It uses document.querySelectorAll('.testimony_content') — if the selector is not found on a page, it exits silently with no side effects.