Benefit cards are absolutely positioned across a sticky section. A scroll listener maps the page's scroll progress through the section to a 0–1 value. JavaScript reads that value on every frame and updates each card's position, scale and opacity using CSS custom properties. When all cards dissolve, the CTA fades in. No animation libraries needed — only CSS transitions and requestAnimationFrame.
- Download the .zip file above and extract the .json file on your computer.
- In WordPress, navigate to Templates → Saved Templates.
- Click Import Templates at the top, select your extracted .json file, and click Import Now.
- Once imported, simply load the template inside your Elementor canvas by opening the template library folder icon and choosing your saved template!
How it works — the four scroll phases
The section is made tall enough to scroll through — typically 100vh. The inner content is position: sticky so it stays on screen while the outer wrapper scrolls past. A scroll listener calculates how far through the section the user is and maps that to a progress value between 0 and 1. That single number drives everything.
Each card's left and top position interpolates from its scatter position toward the centre (50%, 50%). Cards scale down with transform: scale(). Description text fades first, then the title.
The section heading opacity transitions to 0 as the cards approach centre. A subtle scale-down (from 1 to 0.96) makes the fade feel physical rather than flat.
Once converged, cards fade to opacity: 0 in a staggered sequence — each card offset by a small delay based on its index. They don't all vanish at once; the stagger gives it a breathing feel.
The CTA block fades in and scales from 0.92 to 1.0. It contains a full UI: eyebrow text, headline, supporting copy, a primary and secondary button, and three trust signal lines below.
What drives the animation
There are no animation libraries (no GSAP, no ScrollMagic). The Script Widget uses a native scroll event listener and requestAnimationFrame to calculate scroll progress on every paint frame. CSS transitions handle the actual visual smoothing — the JS only updates position and opacity values, and CSS does the interpolation.
position: sticky is the correct pattern for scroll-driven effects in Elementor because it plays nicely with Elementor's layout system. A position: fixed approach would break Elementor's column widths and padding logic. The outer wrapper is made tall (100vh) to create scrolling distance. The inner sticky element stays visible throughout.| Element / selector | Role in the effect |
|---|---|
| .ctc-wrapper | The tall outer container — height: 100vh. Creates the scroll distance. Has no visual appearance itself. |
| .ctc-sticky | The inner sticky container — position: sticky; top: 0; height: 100vh. Stays on screen as the wrapper scrolls. |
| .ctc-card | Each benefit card. Absolutely positioned. JS updates left, top, transform: scale() and opacity based on scroll progress. |
| .ctc-card::before | Subtle glass-shine gradient inside each card. position: absolute; inset: 0. Always visible, gives the dark card a premium surface feel without adding DOM elements. |
| .ctc-heading | Section heading. JS updates opacity and transform: scale() as progress increases. |
| .ctc-cta | The CTA block. Hidden at start. JS sets opacity and transform: scale() based on progress above 72%. |
Architecture tree
This is the Elementor widget hierarchy. Place the Styles Widget first and the Script Widget last within your parent column container.
Outer Section (Elementor Section · Advanced CSS Classes: ctc-wrapper) └── Parent Column │ ├── Styles Widget (HTML Widget · Copy & paste Widget 1) → FIRST element │ ├── Sticky Container (Elementor Section · Advanced CSS Classes: ctc-sticky) │ └── Inner Column │ │ │ ├── Heading Group (Container/Column · Advanced CSS Classes: ctc-heading) │ │ ├── Eyebrow (Text/Heading Widget · Advanced CSS Classes: ctc-eyebrow) │ │ ├── Title/Headline (Heading Widget) │ │ └── Description (Text Widget · Advanced CSS Classes: ctc-heading-desc) │ │ │ ├── Card 1 (Option A: HTML Widget (Easiest & Recommended, Copy Below) OR Option B: Native Column/Inner Section (design yourself) · Advanced CSS Classes: ctc-card ctc-card-1) │ │ └── Column (Option B) │ │ ├── Number (Text Widget · Advanced CSS Classes: ctc-num) │ │ ├── Title (Heading Widget · Advanced CSS Classes: ctc-title) │ │ └── Description (Text Widget · Advanced CSS Classes: ctc-desc) │ │ │ ├── Card 2 (Inner Section or HTML Widget · Advanced CSS Classes: ctc-card ctc-card-2) │ │ └── Same structure as Card 1 │ │ │ ├── Cards 3 - 7 (Inner Sections or HTML Widgets · Advanced CSS Classes: ctc-card ctc-card-3 to ctc-card-7) │ │ └── Same structure as Card 1 │ │ │ └── CTA Block (HTML Widget · Copy & paste Widget 2 · Advanced CSS Classes: ctc-cta) │ └── Script Widget (HTML Widget) → LAST element in Parent Column
ctc-wrapper) is what creates the scroll distance. The inner section (ctc-sticky) is what stays on screen. Both must exist — removing either one breaks the effect. The Styles and Script widgets live in the Parent Column of the outer section, not inside the sticky container.CSS class reference
| Element | CSS class(es) | Applied to |
|---|---|---|
| Outer Section | ctc-wrapper | outer section element |
| Inner sticky section | ctc-sticky | inner section element |
| Section heading | ctc-heading | heading widget |
| Card HTML Widget (or Inner Section) | ctc-card ctc-card-N | Unique starting position CSS per card (e.g., ctc-card-1, ctc-card-2, etc.) |
| CTA block HTML widget | ctc-cta | html widget |
ctc-card (shared — used by the script to animate cards) and ctc-card-N (unique — used by the CSS to set its starting scatter position). If you omit the ctc-card-N class, all cards will stack on top of each other at coordinates (0,0) in the editor, making them appear "on the same line".Step-by-step build guide
Add a new Section to your page. This is the Outer Section — it creates the scroll distance.
- Advanced → CSS Classes:
ctc-wrapper - Do not add any background, padding or other styling here — the wrapper is invisible by design
.ctc-wrapper { height: 100vh }. Do not set a height on this section in Elementor's Layout tab — it will conflict.Inside the outer wrapper's column, add an HTML widget as the very first element. This is the Styles Widget. Leave it empty for now — paste the CSS from Section 05 after completing the build.
Inside the outer wrapper's column, add a second Section (nested). This is the Sticky Container.
- Advanced → CSS Classes:
ctc-sticky - Background: set your dark colour here (e.g.
#080808) in the Style tab - Do not set height or padding — the CSS controls both
ctc-sticky. The outer wrapper is just an invisible scroll track.Inside the sticky section's column, add your section heading. You have two options depending on your design:
- Option A: Single Heading Widget — Add a single Heading widget, set its headline text, and apply the class
ctc-headingin Advanced → CSS Classes. - Option B: Title Container (Eyebrow + Heading + Description) [Recommended] — If you want a structured header block with an eyebrow text above and supporting paragraph text below, add an Elementor Container/Column, add your Text/Heading widgets inside it, and apply the class
ctc-headingdirectly to the parent Container/Column itself. This will make the entire group fade out and scale down together on scroll.
Choose one of the two options below to create your cards. Option A is faster and easier (copy-paste HTML), while Option B is built natively in Elementor.
ctc-card (shared — used by the script to animate cards) and ctc-card-N (unique — used by the CSS to set its starting scatter position). If you omit the ctc-card-N class, all cards will stack on top of each other at coordinates (0,0) in the editor, making them appear "on the same line".Add one more HTML widget after the cards, still inside the sticky section. This is the CTA block.
- Advanced → CSS Classes:
ctc-cta - Paste the CTA HTML markup below into this widget:
<div class="ctc-cta-pre">Ready to see the difference?</div> <div class="ctc-cta-headline">Let's build something<br>worth talking about.</div> <div class="ctc-cta-sub">No obligation. No pitch deck. Just a real conversation about what your project actually needs.</div> <div class="ctc-cta-actions"> <button class="ctc-btn-primary">Get a Free Quote →</button> <button class="ctc-btn-secondary">See our work first</button> </div> <div class="ctc-cta-trust"> <div class="ctc-trust-item"><div class="ctc-trust-dot"></div>Response within 24hrs</div> <div class="ctc-trust-item"><div class="ctc-trust-dot"></div>No retainer required</div> <div class="ctc-trust-item"><div class="ctc-trust-dot"></div>Fixed pricing</div> </div>
Back in the outer wrapper's column (not inside the sticky section), add an HTML widget as the very last element. This is the Script Widget. Paste the JS from Section 05.
Open the Styles Widget → paste Widget 1 code → click Update. Then open the Script Widget → paste Widget 2 code → click Update.
Publish or Preview the page. Scroll down through the section slowly. The cards should begin converging as the section scrolls past the 0% mark and the CTA should fully emerge by the time you reach the bottom of the scroll distance.
The two code widgets
Widget 1 contains all CSS — card positions, transitions, the sticky layout, and the CTA markup. Widget 2 contains the scroll listener and animation logic. If the layout looks wrong open Widget 1. If the animation doesn't trigger open Widget 2.
Widget 1 — Styles Widget (first element in outer wrapper column)
<!-- WIDGET 1: Styles Widget -->
<style>
/* ── OUTER WRAPPER ──────────────────────────────────
Creates the scroll distance. height: 100vh means
the section locks in place for the scroll effect.
─────────────────────────────────────────────────── */
.ctc-wrapper {
position: relative;
height: 100vh;
}
/* ── STICKY INNER CONTAINER ─────────────────────────
Stays fixed in the viewport during the effect.
─────────────────────────────────────────────────── */
.ctc-sticky {
position: relative;
height: 100vh;
min-height: 100vh;
width: 100%;
overflow: hidden;
}
/* ── HEADING GROUP ──────────────────────────────────
Centered header group containing eyebrow, title
and description.
─────────────────────────────────────────────────── */
.ctc-heading {
position: absolute !important;
left: 50% !important;
top: 50% !important;
transform: translate(-50%, -50%);
text-align: center !important;
z-index: 2 !important;
pointer-events: none !important;
width: 600px !important;
max-width: 90vw !important;
transition: opacity 0.3s ease, transform 0.3s ease;
color: #ffffff !important;
}
.ctc-eyebrow {
font-family: 'DM Mono', monospace;
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #7c3aed;
margin-bottom: 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.ctc-eyebrow::before,
.ctc-eyebrow::after {
content: '';
width: 24px;
height: 0.5px;
background: rgba(255, 255, 255, 0.15);
}
.ctc-heading-desc {
font-size: 14px;
color: rgba(255, 255, 255, 0.45);
line-height: 1.6;
margin-top: 14px;
max-width: 480px;
margin-left: auto;
margin-right: auto;
}
/* ── BENEFIT CARDS ──────────────────────────────────
All cards share these styles.
position: absolute - they float over the section.
transform: translate(0%, -50%) left-anchors them.
─────────────────────────────────────────────────── */
.ctc-card {
position: absolute !important;
transform: translate(0%, -50%) scale(1);
z-index: 5 !important;
pointer-events: none !important;
width: 280px !important;
max-width: 85vw !important;
transition: left 0.8s cubic-bezier(0.16, 1, 0.3, 1),
top 0.8s cubic-bezier(0.16, 1, 0.3, 1),
opacity 0.5s ease,
transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Premium glassmorphic container surface */
.ctc-card-inner {
background: rgba(255, 255, 255, 0.03) !important;
border: 0.5px solid rgba(255, 255, 255, 0.15) !important;
border-radius: 12px !important;
padding: 16px 20px !important;
position: relative;
overflow: hidden;
backdrop-filter: blur(8px);
transition: padding 0.6s ease, border-color 0.5s ease;
}
/* Subtle glass light-gleam */
.ctc-card-inner::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, transparent 60%);
pointer-events: none;
}
/* Card inner content typography */
.ctc-num {
font-family: monospace;
font-size: 10px;
letter-spacing: 0.18em;
color: rgba(255, 255, 255, 0.2);
margin-bottom: 8px;
transition: opacity 0.4s ease;
}
.ctc-title {
font-size: 14px;
font-weight: 600;
color: #ffffff;
letter-spacing: 0.01em;
margin-bottom: 6px;
line-height: 1.25;
transition: opacity 0.4s ease;
}
.ctc-desc {
font-size: 11.5px;
color: rgba(255, 255, 255, 0.4);
line-height: 1.6;
transition: opacity 0.4s ease;
}
/* ── BACKGROUND RADIAL GLOW ────────────────────────── */
.ctc-glow {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 600px;
height: 600px;
border-radius: 50%;
background: radial-gradient(circle, rgba(124, 58, 237, 0.09) 0%, transparent 70%);
pointer-events: none;
z-index: 1;
transition: opacity 0.4s ease, transform 0.4s ease;
}
/* ── CALL TO ACTION SECTION ───────────────────────── */
.ctc-cta {
position: absolute !important;
left: 50% !important;
top: 50% !important;
transform: translate(-50%, -50%) scale(0.94) !important;
z-index: 10 !important;
text-align: center !important;
opacity: 0 !important;
pointer-events: none !important;
width: 640px !important;
max-width: 92vw !important;
transition: opacity 0.5s ease, transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.ctc-cta-visible {
opacity: 1 !important;
pointer-events: auto !important;
transform: translate(-50%, -50%) scale(1) !important;
}
/* CTA inner split elements styles */
.ctc-cta-pre {
font-family: monospace;
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.3);
margin-bottom: 16px;
}
.ctc-cta-headline {
font-size: 38px;
font-weight: 500;
color: #ffffff;
line-height: 1.15;
letter-spacing: -0.02em;
margin-bottom: 14px;
}
.ctc-cta-sub {
font-size: 13.5px;
color: rgba(255, 255, 255, 0.4);
margin-bottom: 28px;
line-height: 1.65;
max-width: 520px;
margin-left: auto;
margin-right: auto;
}
/* Action Buttons & premium micro-glow styles */
.ctc-cta-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.ctc-btn-primary {
background: #ffffff;
color: #080808;
border: none;
border-radius: 99px;
padding: 13px 32px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, transform 0.15s, box-shadow 0.2s;
}
.ctc-btn-primary:hover {
background: #e8e8e8;
transform: translateY(-1px);
box-shadow: 0 0 20px rgba(124, 58, 237, 0.25);
}
.ctc-btn-secondary {
background: transparent;
color: rgba(255, 255, 255, 0.5);
border: 0.5px solid rgba(255, 255, 255, 0.2);
border-radius: 99px;
padding: 13px 24px;
font-size: 14px;
cursor: pointer;
transition: border-color 0.2s, color 0.2s;
}
.ctc-btn-secondary:hover {
border-color: rgba(255, 255, 255, 0.5);
color: #ffffff;
}
/* Bottom Trust Signal line */
.ctc-cta-trust {
margin-top: 24px;
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
}
.ctc-trust-item {
font-family: monospace;
font-size: 10px;
letter-spacing: 0.1em;
color: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
gap: 6px;
}
.ctc-trust-dot {
width: 3.5px;
height: 3.5px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
}
/* ── RESPONSIVE MEDIA QUERIES ─────────────────────── */
@media (max-width: 768px) {
.ctc-wrapper {
height: 100vh;
}
.ctc-heading {
font-size: clamp(20px, 6vw, 28px) !important;
width: 88vw !important;
}
.ctc-cta-headline {
font-size: 28px;
}
.ctc-cta-sub {
font-size: 12.5px;
}
.ctc-card {
position: absolute !important;
transform: translate(0%, -50%) scale(1);
z-index: 5 !important;
pointer-events: none !important;
width: 220px !important;
max-width: 85vw !important;
transition: left 0.8s cubic-bezier(0.16, 1, 0.3, 1),
top 0.8s cubic-bezier(0.16, 1, 0.3, 1),
opacity 0.5s ease,
transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
}
.ctc-card-1 { left: 11% !important; top: 12% !important; }
.ctc-card-2 { left: 69% !important; top: 8% !important; }
.ctc-card-3 { left: 0% !important; top: 33% !important; }
.ctc-card-4 { left: 63% !important; top: 32% !important; }
.ctc-card-5 { left: 2% !important; top: 65% !important; }
.ctc-card-6 { left: 64% !important; top: 67% !important; }
.ctc-card-7 { left: 32% !important; top: 84% !important; }
}
</style>
Widget 2 — Script Widget (last element in outer wrapper column)
<!-- WIDGET 2: Script Widget --> <script> (function () { if (window._ctcInitDone) return; window._ctcInitDone = true; /* ── CARD SCATTER POSITIONS ────────────────────────── sx = starting left %, sy = starting top %. These are % of the .ctc-sticky bounding rect. 50, 50 = dead centre. Adjust to change the layout. Update to match the .ctc-card-N CSS values. ─────────────────────────────────────────────────── */ var CARD_POSITIONS = [ { sx: 12, sy: 18 }, /* 01 — top left */ { sx: 74, sy: 12 }, /* 02 — top right */ { sx: 6, sy: 54 }, /* 03 — mid left */ { sx: 78, sy: 52 }, /* 04 — mid right */ { sx: 22, sy: 80 }, /* 05 — bottom left */ { sx: 68, sy: 80 }, /* 06 — bottom right */ { sx: 46, sy: 88 } /* 07 — bottom centre */ ]; function ease(t) { return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1; } function lerp(a, b, t) { return a + (b - a) * t; } function clamp(v, mn, mx) { return Math.min(Math.max(v, mn), mx); } function initCTC() { var wrapper = document.querySelector('.ctc-wrapper'); var sticky = document.querySelector('.ctc-sticky'); var heading = document.querySelector('.ctc-heading'); var glow = document.querySelector('.ctc-glow'); var cta = document.querySelector('.ctc-cta'); var cards = document.querySelectorAll('.ctc-card'); if (!wrapper || !sticky || !cards.length) return; var isMobile = window.innerWidth < 768; if (isMobile) { CARD_POSITIONS = [ { sx: 11, sy: 12 }, /* Card 1 */ { sx: 69, sy: 8 }, /* Card 2 */ { sx: 0, sy: 33 }, /* Card 3 */ { sx: 63, sy: 32 }, /* Card 4 */ { sx: 2, sy: 65 }, /* Card 5 */ { sx: 64, sy: 67 }, /* Card 6 */ { sx: 32, sy: 84 } /* Card 7 */ ]; } var cardData = []; cards.forEach(function(card, idx) { var pos = CARD_POSITIONS[idx] || { sx: 50, sy: 50 }; card.style.left = pos.sx + '%'; card.style.top = pos.sy + '%'; cardData.push({ el: card, inner: card.querySelector('.ctc-card-inner'), title: card.querySelector('.ctc-title'), desc: card.querySelector('.ctc-desc'), num: card.querySelector('.ctc-num'), sx: pos.sx, sy: pos.sy, delay: (idx + 1) * 0.04 }); }); var virtualProgress = 0.0; var lockActive = false; var lastScrollY = window.scrollY; function update(p) { /* Phase 1: 0 → 0.55 — cards converge */ var convP = clamp(p / 0.55, 0, 1); var convE = ease(convP); /* Phase 2: 0.3 → 0.6 — heading fades */ var headFade = 1 - clamp((p - 0.3) / 0.3, 0, 1); /* Phase 3: 0.55 → 0.75 — cards dissolve */ var dissolve = clamp((p - 0.55) / 0.2, 0, 1); /* Phase 4: 0.72 → 1.0 — CTA emerges */ var ctaP = clamp((p - 0.72) / 0.28, 0, 1); /* heading animation */ if (heading) { heading.style.opacity = headFade; heading.style.transform = 'translate(-50%,-50%) scale(' + (1 - convE * 0.04) + ')'; } /* glow animation */ if (glow) { glow.style.opacity = 0.5 + convE * 0.5; glow.style.transform = 'translate(-50%,-50%) scale(' + (0.8 + convE * 0.4) + ')'; } /* cards animation with pixel-based centering math */ cardData.forEach(function(c, i) { var stag = clamp((convP - c.delay * 0.3) * 1.2, 0, 1); var stagE = ease(stag); c.el.style.top = lerp(c.sy, 50, stagE) + '%'; c.el.style.transform = 'translate(0%,-50%) scale(' + lerp(1, 0.55, stagE) + ')'; if (stagE === 0) { c.el.style.left = c.sx + '%'; } else { var cardWidth = c.el.offsetWidth; var containerWidth = sticky.offsetWidth; // Calculate converged left position to center the card on screen var targetX = (containerWidth / 2) - (cardWidth / 2); var startX = (c.sx / 100) * containerWidth; var lx = lerp(startX, targetX, stagE); c.el.style.left = lx + 'px'; } var descAlpha = Math.max(0, 1 - stagE * 3); if (c.desc) c.desc.style.opacity = descAlpha; if (c.num) c.num.style.opacity = descAlpha; if (c.title) c.title.style.opacity = Math.max(0, 1 - stagE * 2); if (c.inner) { var pad = lerp(14, 4, stagE); c.inner.style.padding = pad + 'px ' + lerp(18, 8, stagE) + 'px'; var bAlpha = lerp(0.15, 0.4, Math.min(stagE * 2, 1)); c.inner.style.borderColor = 'rgba(255,255,255,' + bAlpha + ')'; } var cardAlpha = Math.max(0, 1 - ease(clamp((dissolve - i * 0.04) * 2, 0, 1))); c.el.style.opacity = cardAlpha; }); /* CTA animation */ if (cta) { if (ctaP > 0.01) { cta.classList.add('ctc-cta-visible'); cta.style.opacity = ease(ctaP); var ctaScale = lerp(0.92, 1, ease(ctaP)); cta.style.transform = 'translate(-50%, -50%) scale(' + ctaScale + ')'; } else { cta.classList.remove('ctc-cta-visible'); cta.style.opacity = 0; } } } // Lock scrolling on wrapper top alignment (100% stable boundary crossing logic) window.addEventListener('scroll', function() { var rect = wrapper.getBoundingClientRect(); var wrapperTop = window.scrollY + rect.top; if (lockActive) { window.scrollTo(0, wrapperTop); return; } var currentScroll = window.scrollY; // Crossing threshold down if (currentScroll >= wrapperTop && lastScrollY < wrapperTop && virtualProgress < 1.0) { lockActive = true; virtualProgress = 0.0; window.scrollTo(0, wrapperTop); update(virtualProgress); } // Crossing threshold up else if (currentScroll <= wrapperTop && lastScrollY > wrapperTop && virtualProgress > 0.0) { lockActive = true; virtualProgress = 1.0; window.scrollTo(0, wrapperTop); update(virtualProgress); } lastScrollY = currentScroll; }); // Hijack mouse wheel scrolls inside lock window.addEventListener('wheel', function(e) { if (!lockActive) return; e.preventDefault(); var delta = e.deltaY * 0.0018; // wheel scroll sensitivity var nextProgress = clamp(virtualProgress + delta, 0, 1); if (nextProgress !== virtualProgress) { virtualProgress = nextProgress; update(virtualProgress); } else { if (virtualProgress === 1.0 && e.deltaY > 0) { lockActive = false; // release scroll lock down } else if (virtualProgress === 0.0 && e.deltaY < 0) { lockActive = false; // release scroll lock up } } }, { passive: false }); // Hijack touch events for mobile scroll locking var touchStartY = 0; window.addEventListener('touchstart', function(e) { if (lockActive) { touchStartY = e.touches[0].clientY; } }, { passive: true }); window.addEventListener('touchmove', function(e) { if (!lockActive) return; e.preventDefault(); var touchY = e.touches[0].clientY; var deltaY = touchStartY - touchY; touchStartY = touchY; var delta = deltaY * 0.006; // mobile swipe sensitivity var nextProgress = clamp(virtualProgress + delta, 0, 1); if (nextProgress !== virtualProgress) { virtualProgress = nextProgress; update(virtualProgress); } else { if (virtualProgress === 1.0 && deltaY > 0) { lockActive = false; } else if (virtualProgress === 0.0 && deltaY < 0) { lockActive = false; } } }, { passive: false }); update(virtualProgress); } var isEditor = window.elementorFrontend && typeof window.elementorFrontend.isEditMode === 'function' && window.elementorFrontend.isEditMode(); if (isEditor) { window.jQuery(window).on('elementor/frontend/init', function() { window._ctcInitDone = false; initCTC(); }); setTimeout(function() { initCTC(); }, 1000); } else { initCTC(); } })(); </script>
Customising card positions and content
Card positions are defined in two places that must stay in sync: the CSS in Widget 1 and the CARD_POSITIONS array in Widget 2.
To move a card
- In Widget 1, find
.ctc-card-N { left: X%; top: Y%; }and changeXandY - In Widget 2, find the matching object in
CARD_POSITIONS(index 0 = card 1) and updatesxandsyto the same values
To change scroll speed
In Widget 1, change .ctc-wrapper { height: 100vh }. Higher = slower and more cinematic. Lower = faster. 250vh is the minimum before it feels rushed.
To change phase timing
In Widget 2, the phase ranges are defined as comments. For example clamp(p / 0.55, 0, 1) means convergence ends at 55% scroll. Change 0.55 to 0.45 to make cards converge faster, or 0.65 to stretch it out.
To add or remove cards
Add or remove HTML widgets with the ctc-card ctc-card-N classes. Add or remove a matching entry in CARD_POSITIONS in Widget 2.
Mobile behaviour & Customising Card Counts
The full scroll animation runs on mobile — same sticky wrapper, same convergence, same CTA reveal. The only difference is card starting positions. On desktop cards can sit wider because the viewport is wide enough. On a narrow phone, they must be positioned carefully so they do not collide or overlap.
Managing Card Positions to Prevent Collisions
On mobile, we set the card width to 220px and position them using specific coordinates that flow around the central services title to prevent overlapping. The default mobile coordinate structure for 7 cards is:
- Card 1: left
11%, top12% - Card 2: left
69%, top8% - Card 3: left
0%, top33% - Card 4: left
63%, top32% - Card 5: left
2%, top65% - Card 6: left
64%, top67% - Card 7: left
32%, top84%
These coordinate settings are applied in both the Widget 1 (CSS) media query (for editor preview) and the Widget 2 (JS) mobile position array (for active scroll animation).
Using Fewer Cards (4 or 5 Cards Layout)
If you want to use fewer cards instead of the default 7, the code handles this dynamically with no issues! You only need to follow these simple layouts using coordinates subsets:
- For a 4 Cards Layout: Reuse the positions of Card 1, 2, 5, and 6 (or Card 3, 4, 5, and 6) to keep the vertical balance neat and centered.
- For a 5 Cards Layout: Reuse Card 1, 2, 5, 6, and 7 (or Card 3, 4, 5, 6, and 7).
Simply delete the unnecessary HTML card widgets in Elementor. The JavaScript code automatically loops only over the cards present on your page and applies the coordinates in order.
Troubleshooting
The scroll listener is not running. Confirm the Script Widget is the last element in the outer wrapper's column. Then confirm the outer section has the class ctc-wrapper — the script uses this to calculate scroll progress. Open DevTools → Console and look for errors. If you see "Cannot read properties of null", a class is missing or misspelled.
The sticky container class is missing or the CSS is not loading. Confirm the inner section has the class ctc-sticky. Confirm the Styles Widget is the first element in the outer wrapper's column. If the CSS loaded correctly, .ctc-sticky should have position: sticky; top: 0; height: 100vh — inspect it in DevTools to verify these are applied.
The CARD_POSITIONS array in Widget 2 does not match the CSS positions in Widget 1. Every ctc-card-N { left: X%; top: Y% } entry in the CSS must have a matching { sx: X, sy: Y } entry at the same index in the JS array. They must be identical — even a 1% discrepancy will cause cards to jump at the start of the animation.
The CTA HTML widget must have the class ctc-cta in its Advanced → CSS Classes field. The script adds ctc-cta-visible to this element when progress exceeds 72% — if the element is not found, nothing happens. Also confirm the wrapper section is actually 100vh tall — if Elementor is overriding the height, the scroll progress may never reach 72%.
Clear your caching plugin and run Elementor → Tools → Regenerate CSS & Data. The guard flag window._ctcInitDone can persist across editor reloads in the same tab. Close the editor tab completely and test the published URL in a fresh browser tab.
The mobile CARD_POSITIONS array in Widget 2 or the mobile CSS positions in Widget 1 need tightening. On a narrow phone, keep all sx values between 16 and 80 and all sy values between 12 and 88. Update both the CSS .ctc-card-N rules inside the @media (max-width: 768px) block and the matching values in the if (isMobile) block in Widget 2 — they must be identical.
Pre-publish checklist
ctc-wrapperctc-stickyctc-headingctc-card and ctc-card-N classesctc-card class, and contains classes ctc-num, ctc-title, and ctc-desc (in HTML markup or Elementor widgets)ctc-ctaCARD_POSITIONS array in Widget 2 matches CSS positions in Widget 1More tutorials.
All in one place.
Every tool, tutorial and Elementor effect we build lives in the developer playground. Free to explore — no account needed.