Concept43
end. * * Runs every carousel whose prefix is listed in CAROUSELS below * (currently "industries" and "services"). For a prefix, e.g. "services", * it hooks into these V4 global classes (edit their styles in Elementor, not here): * .services-carousel wrapper (one per carousel, can be reused on any page) * .services-track scrolling row that holds the cards * .services-arrow-prev previous button * .services-arrow-next next button * .services-dots container holding two sample dots (one active, one not) * .services-dot dot style * .services-dot-active active dot style * * To add a new carousel: build it with the same structure and class names * using a new prefix, then add that prefix to CAROUSELS. * * Logo walls (MARQUEES below) scroll on their own from right to left, loop * forever and can be dragged/swiped. For a prefix, e.g. "logos": * .logos-marquee the visible window (overflow hidden, holds the edge fades) * .logos-track the row of logos that moves; every child is one logo * Add/remove/reorder logos in Elementor; the script adapts on its own. * Visitors with "reduce motion" turned on get a still row they can drag. * * The script copies the sample dot so there is one dot per position. * Keep both sample dots in Elementor: they are the template, and they make * Elementor print the dot class CSS on the page. * * Infinite loop: the cards are copied once before and once after the real set. * When scrolling stops inside a copy, the track jumps (invisibly) to the * matching real card, so it never runs out in either direction. * * Autoplay (AUTOPLAY below): moves one card forward every few seconds. * The countdown restarts whenever the carousel stops moving, so visitors who * use the arrows, dots or swipe always get the full pause before it moves on. * It pauses while the mouse is over the carousel, while a finger is on it, * while it has keyboard focus, while it is off screen or the tab is hidden. * Visitors with "reduce motion" turned on don't get autoplay. */ (function () { // prefix: loop on/off (false = carousel with a start and an end) var CAROUSELS = { industries: true, services: true }; // prefix: seconds between automatic moves (leave a prefix out = no autoplay) var AUTOPLAY = { industries: 2.5, services: 2.5 }; // Logo walls (auto-scrolling, draggable, no navigation). // prefix: seconds each logo takes to move one slot (higher = slower) var MARQUEES = { logos: 4 }; var DISABLED_OPACITY = '0.35'; var DRAG_THRESHOLD = 6; // px the mouse must move before a click becomes a drag var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; var hasScrollEnd = 'onscrollend' in window; function mod(a, n) { return ((a % n) + n) % n; } function init(root, p, LOOP, AUTO) { if (root.dataset.carouselReady) return; var track = root.querySelector('.' + p + '-track'); if (!track) return; root.dataset.carouselReady = '1'; var DOT = p + '-dot'; var ACTIVE = p + '-dot-active'; var prev = root.querySelector('.' + p + '-arrow-prev'); var next = root.querySelector('.' + p + '-arrow-next'); var dotsWrap = root.querySelector('.' + p + '-dots'); var originals = Array.prototype.slice.call(track.children); var n = originals.length; var clones = []; var looping = false; var dots = []; var ticking = false; var settleTimer = null; var target = null; // where a smooth scroll is heading var template = null; var pointerDown = false; // mouse drag state var dragging = false; var suppressClick = false; var startX = 0; var startScroll = 0; var autoTimer = null; // autoplay countdown var autoMovedAt = 0; // when the last automatic move started var hovering = false; var touching = false; var focused = false; var onScreen = true; if (!n) return; if (dotsWrap) { var sample = dotsWrap.querySelector('.' + DOT + ':not(.' + ACTIVE + ')') || dotsWrap.querySelector('.' + DOT); if (sample) { template = sample.cloneNode(false); template.classList.remove(ACTIVE); ['data-id', 'data-interaction-id', 'id'].forEach(function (a) { template.removeAttribute(a); }); } } /* ---------- measurements ---------- */ function step() { return n > 1 ? originals[1].offsetLeft - originals[0].offsetLeft : track.clientWidth; } function base() { // scroll position at which the first real card is aligned to the start return originals[0].offsetLeft - track.firstElementChild.offsetLeft; } function maxScroll() { return Math.max(0, track.scrollWidth - track.clientWidth); } function overflows() { var last = originals[n - 1]; return last.offsetLeft + last.offsetWidth - originals[0].offsetLeft > track.clientWidth + 2; } function pageCount() { if (looping) return n; var m = maxScroll(); return m <= 2 ? 1 : Math.ceil(m / step() - 0.05) + 1; } function position() { // card-sized position relative to the first real card (can be negative / beyond n) var x = target !== null ? target : track.scrollLeft; return Math.round((x - base()) / step()); } function current() { if (looping) return mod(position(), n); if (track.scrollLeft >= maxScroll() - 2) return pageCount() - 1; return Math.round(track.scrollLeft / step()); } /* ---------- movement ---------- */ function jump(left) { track.style.scrollBehavior = 'auto'; track.scrollLeft = left; track.style.scrollBehavior = ''; } function smooth(left) { left = Math.max(0, Math.min(left, maxScroll())); clearTimeout(settleTimer); if (reduceMotion || Math.abs(left - track.scrollLeft) < 1) { jump(left); settle(); return; } target = left; track.scrollTo({ left: left, behavior: 'smooth' }); settleTimer = setTimeout(function () { settle(true); }, 1000); // safety net in case scrollend never fires } function goBy(delta) { if (looping) { smooth(base() + (position() + delta) * step()); } else { goTo(current() + delta); } } function goTo(i) { if (looping) { // pick the copy of card i closest to where we are, so we always take the short way var s = step(), w = n * s, x = track.scrollLeft; var left = base() + i * s; [left - w, left + w].forEach(function (c) { if (Math.abs(c - x) < Math.abs(left - x)) left = c; }); smooth(left); return; } var last = pageCount() - 1; i = Math.max(0, Math.min(i, last)); smooth(i === last ? maxScroll() : i * step()); } // After scrolling stops: if we're sitting on a copy, jump to the real card. function settle(force) { if (pointerDown) return; // still dragging with the mouse // a stray scrollend can arrive before an arrow/drag glide has arrived if (force !== true && target !== null && Math.abs(track.scrollLeft - target) > 1) return; target = null; track.style.scrollSnapType = ''; if (looping) { var s = step(), w = n * s, rel = track.scrollLeft - base(); if (rel < -s / 2) jump(track.scrollLeft + w); else if (rel > w - s / 2) jump(track.scrollLeft - w); } update(); schedule(); } /* ---------- autoplay ---------- */ function canAutoplay() { return AUTO > 0 && !reduceMotion && !hovering && !touching && !focused && !pointerDown && onScreen && !document.hidden && pageCount() > 1; } // (Re)start the countdown; called every time the carousel comes to rest. // After an automatic move the time spent gliding counts towards the wait, // so moves start exactly AUTO apart. After a visitor action: a full wait. function schedule() { clearTimeout(autoTimer); autoTimer = null; if (!canAutoplay()) return; var since = Date.now() - autoMovedAt; if (since >= AUTO) since = 0; autoTimer = setTimeout(function () { autoTimer = null; if (!canAutoplay()) return; if (target !== null) { autoMovedAt = 0; schedule(); return; } // still gliding, wait a full round autoMovedAt = Date.now(); if (!looping && current() >= pageCount() - 1) goTo(0); // no loop: rewind at the end else goBy(1); }, Math.max(AUTO - since, 300)); } /* ---------- loop copies ---------- */ function makeClone(el) { var c = el.cloneNode(true); c.setAttribute('aria-hidden', 'true'); c.setAttribute('data-carousel-clone', ''); c.removeAttribute('id'); [c].concat(Array.prototype.slice.call(c.querySelectorAll('a, button, [tabindex], [id]'))).forEach(function (e) { if (e.matches('a, button, [tabindex]')) e.setAttribute('tabindex', '-1'); if (e !== c) e.removeAttribute('id'); }); return c; } function addClones() { var before = document.createDocumentFragment(); var after = document.createDocumentFragment(); originals.forEach(function (el) { var b = makeClone(el), a = makeClone(el); before.appendChild(b); after.appendChild(a); clones.push(b, a); }); track.insertBefore(before, originals[0]); track.appendChild(after); looping = true; } function removeClones() { clones.forEach(function (c) { c.remove(); }); clones = []; looping = false; } /* ---------- UI state ---------- */ function setArrow(btn, disabled) { if (!btn) return; btn.style.opacity = disabled ? DISABLED_OPACITY : ''; btn.style.cursor = disabled ? 'default' : ''; btn.setAttribute('aria-disabled', disabled ? 'true' : 'false'); } function update() { ticking = false; var count = pageCount(); var idx = current(); var single = count <= 1; if (prev) prev.style.visibility = single ? 'hidden' : ''; if (next) next.style.visibility = single ? 'hidden' : ''; if (dotsWrap) dotsWrap.style.visibility = single ? 'hidden' : ''; if (!dragging) track.style.cursor = single ? '' : 'grab'; setArrow(prev, !looping && idx <= 0); setArrow(next, !looping && idx >= count - 1); dots.forEach(function (d, i) { d.classList.toggle(ACTIVE, i === idx); if (i === idx) d.setAttribute('aria-current', 'true'); else d.removeAttribute('aria-current'); }); } function buildDots() { if (!dotsWrap) return; var count = pageCount(); if (dots.length === count) return; dotsWrap.innerHTML = ''; dots = []; for (var i = 0; i < count; i++) { var d; if (template) { d = template.cloneNode(false); } else { d = document.createElement('button'); d.className = DOT; } if (d.tagName === 'BUTTON') d.type = 'button'; d.setAttribute('aria-label', 'Go to slide ' + (i + 1)); d.addEventListener('click', goTo.bind(null, i)); dotsWrap.appendChild(d); dots.push(d); } } // Runs on load and whenever the track changes size (breakpoints, rotation…) function refresh() { var idx = looping ? current() : 0; var shouldLoop = LOOP && n > 1 && overflows(); if (shouldLoop && !looping) addClones(); if (!shouldLoop && looping) removeClones(); if (looping) jump(base() + idx * step()); target = null; buildDots(); update(); schedule(); } /* ---------- events ---------- */ if (prev) { prev.setAttribute('aria-label', 'Previous'); prev.addEventListener('click', function () { goBy(-1); }); } if (next) { next.setAttribute('aria-label', 'Next'); next.addEventListener('click', function () { goBy(1); }); } track.addEventListener('scroll', function () { if (!ticking) { ticking = true; requestAnimationFrame(update); } if (!hasScrollEnd) { clearTimeout(settleTimer); settleTimer = setTimeout(function () { settle(); }, 150); } }, { passive: true }); if (hasScrollEnd) track.addEventListener('scrollend', function () { settle(); }); /* ---------- autoplay pauses ---------- */ if (AUTO > 0) { // mouse only: on touch screens a tap would count as "hovering" and never end root.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') { hovering = true; schedule(); } }); root.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') { hovering = false; schedule(); } }); // touch events keep firing during a native swipe (pointer events get cancelled) // any visitor action (click, tap, key, trackpad scroll) earns a full wait afterwards ['pointerdown', 'keydown', 'wheel'].forEach(function (type) { root.addEventListener(type, function () { autoMovedAt = 0; }, { capture: true, passive: true }); }); root.addEventListener('touchstart', function () { autoMovedAt = 0; touching = true; schedule(); }, { passive: true }); var touchEnd = function (e) { if (e.touches.length) return; touching = false; schedule(); }; root.addEventListener('touchend', touchEnd, { passive: true }); root.addEventListener('touchcancel', touchEnd, { passive: true }); // keyboard users: pause while tabbing through the carousel (a mouse click // on an arrow can leave focus behind, so only count keyboard focus) var checkFocus = function () { var a = document.activeElement; var f = false; try { f = !!a && root.contains(a) && a.matches(':focus-visible'); } catch (err) { f = !!a && root.contains(a); } if (f !== focused) { focused = f; schedule(); } }; root.addEventListener('focusin', checkFocus); root.addEventListener('focusout', function () { setTimeout(checkFocus, 0); }); if (window.IntersectionObserver) { new IntersectionObserver(function (entries) { onScreen = entries[0].isIntersecting; schedule(); }).observe(root); } document.addEventListener('visibilitychange', schedule); } /* ---------- mouse drag (touch already swipes natively) ---------- */ track.addEventListener('dragstart', function (e) { e.preventDefault(); }); // no ghost images/links track.addEventListener('pointerdown', function (e) { if (e.pointerType !== 'mouse' || e.button !== 0 || pageCount() <= 1) return; pointerDown = true; dragging = false; startX = e.clientX; startScroll = track.scrollLeft; }); track.addEventListener('pointermove', function (e) { if (!pointerDown) return; var dx = e.clientX - startX; if (!dragging) { if (Math.abs(dx) < DRAG_THRESHOLD) return; dragging = true; clearTimeout(settleTimer); target = null; track.setPointerCapture(e.pointerId); track.style.scrollSnapType = 'none'; track.style.scrollBehavior = 'auto'; track.style.userSelect = 'none'; track.style.cursor = 'grabbing'; if (window.getSelection) window.getSelection().removeAllRanges(); } track.scrollLeft = startScroll - dx; }); function endDrag(e) { if (!pointerDown) return; pointerDown = false; if (!dragging) return; dragging = false; suppressClick = true; // a drag shouldn't open the card's link setTimeout(function () { suppressClick = false; }, 0); if (track.hasPointerCapture(e.pointerId)) track.releasePointerCapture(e.pointerId); track.style.scrollBehavior = ''; track.style.userSelect = ''; track.style.cursor = 'grab'; // Land on a card: any drag past ~10% of a card moves in the drag direction var s = step(); var rel = (track.scrollLeft - (looping ? base() : 0)) / s; var forward = e.clientX < startX; var pos = forward ? Math.ceil(rel - 0.1) : Math.floor(rel + 0.1); if (looping) smooth(base() + pos * s); else goTo(pos); } track.addEventListener('pointerup', endDrag); track.addEventListener('pointercancel', endDrag); track.addEventListener('lostpointercapture', endDrag); track.addEventListener('click', function (e) { if (!suppressClick) return; e.preventDefault(); e.stopPropagation(); suppressClick = false; }, true); var lastWidth = -1; function onResize() { if (track.clientWidth === lastWidth) return; lastWidth = track.clientWidth; refresh(); } if (window.ResizeObserver) new ResizeObserver(onResize).observe(track); else window.addEventListener('resize', onResize); onResize(); } /* ================= Logo wall (marquee) ================= */ function initMarquee(root, p, secondsPerItem) { if (root.dataset.marqueeReady) return; var track = root.querySelector('.' + p + '-track'); if (!track) return; var originals = Array.prototype.slice.call(track.children); var n = originals.length; if (!n) return; root.dataset.marqueeReady = '1'; var clones = []; var x = 0; // current offset in px (always between -setW and 0) var v = 0; // current velocity in px/s (negative = moving left) var setW = 0; // width of one full set of logos var speed = 0; // cruising speed in px/s var last = 0; var running = false; var inView = true; var pointerDown = false, dragging = false, suppressClick = false; var startX = 0, startOffset = 0, lastMoveX = 0, lastMoveT = 0, dragV = 0; track.style.touchAction = 'pan-y'; // vertical page scroll stays native on touch track.querySelectorAll('img').forEach(function (img) { img.setAttribute('draggable', 'false'); img.loading = 'eager'; // logos off to the side must be ready before they slide in }); if (track.tagName === 'IMG') track.setAttribute('draggable', 'false'); function wrap(val) { if (!setW) return 0; val = val % setW; return val > 0 ? val - setW : val; } function render() { track.style.transform = 'translate3d(' + x + 'px,0,0)'; } function measure() { var ratio = setW ? x / setW : 0; clones.forEach(function (c) { c.remove(); }); clones = []; function addSet() { originals.forEach(function (el) { var c = el.cloneNode(true); c.setAttribute('aria-hidden', 'true'); c.setAttribute('data-carousel-clone', ''); c.removeAttribute('id'); c.querySelectorAll('[id]').forEach(function (e) { e.removeAttribute('id'); }); c.querySelectorAll('a, button').forEach(function (e) { e.setAttribute('tabindex', '-1'); }); if (c.matches('a, button')) c.setAttribute('tabindex', '-1'); track.appendChild(c); clones.push(c); }); } // one full set = distance from the first logo to its first copy // (works with logos of any width and any gap) addSet(); setW = clones[0].offsetLeft - originals[0].offsetLeft; speed = setW / n / secondsPerItem; if (setW <= 0) { setW = 0; return; } // add more copies if needed so the row never runs out while it loops var sets = Math.max(1, Math.ceil(root.clientWidth / setW)); for (var s = 1; s < sets; s++) addSet(); x = wrap(ratio * setW); render(); } function frame(t) { if (!running) return; var dt = last ? Math.min((t - last) / 1000, 0.05) : 0; last = t; if (!dragging) { var cruise = reduceMotion ? 0 : -speed; v += (cruise - v) * (1 - Math.exp(-dt * 4)); // ease back to cruising after a flick x = wrap(x + v * dt); render(); } requestAnimationFrame(frame); } function start() { if (running || !inView) return; running = true; last = 0; requestAnimationFrame(frame); } function stop() { running = false; } /* drag / swipe (mouse and touch) */ track.addEventListener('dragstart', function (e) { e.preventDefault(); }); track.addEventListener('pointerdown', function (e) { if (e.pointerType === 'mouse' && e.button !== 0) return; pointerDown = true; dragging = false; startX = lastMoveX = e.clientX; startOffset = x; lastMoveT = performance.now(); dragV = 0; }); track.addEventListener('pointermove', function (e) { if (!pointerDown) return; var dx = e.clientX - startX; if (!dragging) { if (Math.abs(dx) < DRAG_THRESHOLD) return; dragging = true; track.setPointerCapture(e.pointerId); track.style.cursor = 'grabbing'; if (window.getSelection) window.getSelection().removeAllRanges(); } var now = performance.now(); var dtm = (now - lastMoveT) / 1000; if (dtm > 0) dragV = 0.8 * ((e.clientX - lastMoveX) / dtm) + 0.2 * dragV; lastMoveX = e.clientX; lastMoveT = now; x = wrap(startOffset + dx); render(); }); function endDrag(e) { if (!pointerDown) return; pointerDown = false; if (!dragging) return; dragging = false; suppressClick = true; setTimeout(function () { suppressClick = false; }, 0); if (track.hasPointerCapture(e.pointerId)) track.releasePointerCapture(e.pointerId); track.style.cursor = ''; // keep the flick's momentum, unless the pointer was held still before release v = performance.now() - lastMoveT > 100 ? 0 : Math.max(-2500, Math.min(2500, dragV)); } track.addEventListener('pointerup', endDrag); track.addEventListener('pointercancel', endDrag); track.addEventListener('lostpointercapture', endDrag); track.addEventListener('click', function (e) { if (!suppressClick) return; e.preventDefault(); e.stopPropagation(); suppressClick = false; }, true); /* only animate while the wall is on screen */ if (window.IntersectionObserver) { new IntersectionObserver(function (entries) { inView = entries[0].isIntersecting; if (inView) start(); else stop(); }).observe(root); } var lastWidth = -1; function onResize() { if (root.clientWidth === lastWidth) return; lastWidth = root.clientWidth; measure(); } if (window.ResizeObserver) new ResizeObserver(onResize).observe(root); else window.addEventListener('resize', onResize); onResize(); // logo widths depend on the images, so measure again once each one has loaded originals.forEach(function (el) { var imgs = el.tagName === 'IMG' ? [el] : Array.prototype.slice.call(el.querySelectorAll('img')); imgs.forEach(function (img) { if (!img.complete) img.addEventListener('load', measure, { once: true }); }); }); v = reduceMotion ? 0 : -speed; start(); } function initAll() { Object.keys(CAROUSELS).forEach(function (p) { document.querySelectorAll('.' + p + '-carousel').forEach(function (root) { init(root, p, CAROUSELS[p], (AUTOPLAY[p] || 0) * 1000); }); }); Object.keys(MARQUEES).forEach(function (p) { document.querySelectorAll('.' + p + '-marquee').forEach(function (root) { initMarquee(root, p, MARQUEES[p]); }); }); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initAll); else initAll(); })();