Scroll Effect
Benefit cards are scattered across a dark section. As you scroll, they converge inward, shrink, dissolve — and a full CTA emerges from the centre where they collapsed. Built with scroll position tracking, CSS transitions and two HTML widgets.
Elementor Free
2 HTML widgets
Scroll position JS
CSS transforms
Advanced
Desktop + Mobile
requestAnimationFrame.Template Download: Need the pre-built Elementor container template? Download the .zip archive containing the layout JSON.
Download .zip
How to import the JSON file in WordPress: WordPress security rules prevent uploading raw .json templates directly to the Media Library. To work around this:
- 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!
Contents
- 01 How it works — the four scroll phases
- 02 Architecture tree
- 03 CSS class reference
- 04 Step-by-step build guide
- 05 The two code widgets
- 06 Customising card positions and content
- 07 Mobile behaviour
- 08 Troubleshooting
- 09 Pre-publish checklist
01Concept
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.
0 – 55%
Cards converge
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.
30 – 60%
Heading fades
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.
55 – 75%
Cards dissolve
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.
72 – 100%
CTA emerges
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.
Why sticky + tall wrapper instead of a fixed section? 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%. |
02Structure
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
Two nested sections. The outer section (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.
Cards are HTML widgets, not Elementor card widgets. Each card’s content (number, title, description) is written directly in the HTML widget code. This is because the cards need absolute positioning that Elementor’s native widgets don’t support. The Script Widget contains the card position data — you edit card content in the HTML widgets and card positions in the script.
03Reference
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 |
Two classes on each card. Every card widget needs both 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”.
04Build guide
Step-by-step build guide
1
Create the outer wrapper section
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
The height is set in CSS, not in Elementor. The Styles Widget sets .ctc-wrapper { height: 100vh }. Do not set a height on this section in Elementor’s Layout tab — it will conflict.
2
Add the Styles Widget as the first element
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.
3
Add the inner sticky section
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
This is the section your visitors actually see. Everything visible — heading, cards, CTA — lives inside ctc-sticky. The outer wrapper is just an invisible scroll track.
4
Add the section heading group
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.
5
Build the Cards (Two Options)
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.
Option A: HTML Widget (Easiest & Recommended)
First, add an Inner Section or Container inside the sticky container. Under Advanced → CSS Classes, assign it the class ctc-cards-wrap. This serves as the shared canvas for all cards.
Next, add an HTML widget inside this ctc-cards-wrap container. Under Advanced → CSS Classes, apply the CSS classes ctc-card ctc-card-1.
Paste this template inside the HTML widget, and update the text content:
<div> <div>01</div> <div>Results First</div> <div>Every decision is tied to outcomes, not aesthetics alone.</div> </div>
Duplicate this HTML widget until you have 7 cards in total. CRITICAL: Update the CSS classes for each duplicate so they match their card number:
- Card 1 widget → CSS Classes:
ctc-card ctc-card-1 - Card 2 widget → CSS Classes:
ctc-card ctc-card-2 - Card 3 widget → CSS Classes:
ctc-card ctc-card-3 - Continue this pattern up to Card 7:
ctc-card ctc-card-7
Option B: Native Elementor Widgets (Visual Styling)
First, ensure you have created the cards wrap container by adding an Inner Section or Container inside the sticky container and assigning it the class ctc-cards-wrap in Advanced → CSS Classes.
Next, add an Inner Section/Container inside this cards wrap container, and apply the CSS classes ctc-card ctc-card-1 (Advanced → CSS Classes).
Style the Card Inner Section natively in the Style Tab:
- Background: Classic → Color:
rgba(255, 255, 255, 0.04) - Border: Solid → Width:
1px→ Color:rgba(255, 255, 255, 0.15) - Border Radius:
10px - Padding:
14pxtop/bottom,18pxleft/right
Drag these widgets inside the Card Inner Section’s column:
- Text widget (Number) → enter
01→ Advanced CSS Classes:ctc-num - Heading widget (Title) → enter title → Advanced CSS Classes:
ctc-title - Text Editor widget (Description) → enter text → Advanced CSS Classes:
ctc-desc
Duplicate the Inner Section until you have 7 cards in total. CRITICAL: Update the CSS classes for each card Inner Section to match its index (e.g., ctc-card ctc-card-2, ctc-card ctc-card-3 up to ctc-card ctc-card-7).
Two classes on each card. Every card widget needs both 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”.
6
Add the CTA HTML widget
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>Ready to see the difference?</div> <div>Let's build something<br>worth talking about.</div> <div>No obligation. No pitch deck. Just a real conversation about what your project actually needs.</div> <div> <button>Get a Free Quote →</button> <button>See our work first</button> </div> <div> <div><div></div>Response within 24hrs</div> <div><div></div>No retainer required</div> <div><div></div>Fixed pricing</div> </div>
7
Add the Script Widget as the last element
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.
The Script Widget goes in the outer column, not the sticky section. It needs to be in the DOM after both the sticky container and all cards. Placing it inside the sticky section causes timing issues in some Elementor versions.
8
Paste the CSS then the JS
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.
Always test on the published frontend. Elementor’s editor does not fire real scroll events reliably. The effect will not appear correctly inside the editor canvas — publish or use Preview Changes and test in a real browser tab.
05Code
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>
06Customisation
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.
07Mobile
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.
No Elementor mobile breakpoint changes needed. Everything is handled in Widget 1’s media query and Widget 2’s mobile position array. You do not need to touch anything in Elementor’s responsive panel for this effect.
Test on a real device, not Elementor’s preview. The editor canvas does not fire real scroll events. Use a real phone or Chrome DevTools device simulation (F12 → device toolbar) to verify the mobile animation.
08Troubleshooting
Troubleshooting
Cards are visible but nothing moves when scrolling
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 section scrolls away instead of staying on screen
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.
Cards move in the wrong direction or converge off-centre
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.
CTA never appears even after scrolling all the way through
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%.
Effect works in editor but not on the published page
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.
On mobile some cards are cut off or bleeding off screen edges
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.
09Before publishing
Pre-publish checklist
Checklist 0 / 13
Outer section has the class ctc-wrapper
Inner nested section has the class ctc-sticky
Styles Widget is the first element in the outer wrapper column
Script Widget is the last element in the outer wrapper column
Heading widget has the class ctc-heading
Every card widget has both ctc-card and ctc-card-N classes
Each card has ctc-card class, and contains classes ctc-num, ctc-title, and ctc-desc (in HTML markup or Elementor widgets)
CTA HTML widget has the class ctc-cta
CARD_POSITIONS array in Widget 2 matches CSS positions in Widget 1
Tested on published frontend — cards converge and dissolve on scroll
CTA fully visible after scrolling through the section
Scrolling back up re-scatters the cards correctly
Tested on mobile — full scroll animation plays with cards staying within the screen edges
Cr8v Stacks
More effects.
More tutorials.
All in one place.
Every tool, tutorial and Elementor effect we build lives in the developer playground. Free to explore — no account needed.
Explore playground
Visit cr8vstacks.com
12+
Free effects
2
HTML widgets only
0
Plugins required
Latest tutorial
Team Drift Scroll Effect
Elementor Free · Scroll + Hover