CR8V STACKS — The Blog
Brand Strategy
Positioning your business for long-term recognition and trust.
BRAND
↳ Need this done for you?
BRAND STRATEGY
SERVICES
We build identities that make your business memorable, credible, and easy to trust at first glance.
Positioning & messaging
Visual identity systems
View the Service →
Business Tips
Practical, tested advice for running a growing business.
SHOP
↳ Selling online?
ECOMMERCE
WEBSITES
From Shopify builds to full custom stores — we design and develop ecommerce sites that convert.
Shopify & custom builds
Checkout & payments setup
View the Service →
Content Creation
Writing and formats that keep an audience coming back.
WRITE
↳ Need content, not just tips?
CONTENT WRITING
SERVICES
Blog posts, web copy, and long-form content written to rank and to actually convert readers.
SEO-driven blog writing
Website & landing page copy
View the Service →
Digital Marketing
Strategies to grow reach, traffic, and qualified leads.
GROW
↳ Ready to grow faster?
DIGITAL MARKETING
SERVICES
SEO, email, and social strategy handled end-to-end so your traffic turns into real customers.
SEO & SEM campaigns
Email & social management
View the Service →
Web Design & Development
Building fast, functional sites that look as good as they perform.
BUILD
↳ Time for a new site?
WEBSITE DESIGN
& DEV
WordPress, Shopify, or fully custom — we design and build sites that are built to convert.
WordPress & Shopify builds
Fully custom development
View the Service →
CR8V Stacks
↳ CR8V Stacks Blog
BROWSE BY
CATEGORY
Brand Strategy Business Marketing
9
Business Productivity Systems, tools, and workflows
6
Business Tips Ecommerce · Small Business
25
Career Planning Grow your professional life
5
Content Creation Blogging · Content Writing
17
Digital Marketing Email · SEM · SEO · Social
41
Web Design & Development WordPress · Shopify · Ecommerce
60
Tutorials Step-by-step guides and how-tos
22
↳ Like what you're reading?
LET'S BUILD
SOMETHING TOGETHER
Explore Our Services
Cards to CTA Scroll Effect — Elementor Tutorial | Cr8v Stacks
Elementor Tutorial — CSS + JavaScript

Cards to CTA
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
  Live preview — drag the slider to scrub through the scroll effect
Why work with us
Built different.
Designed to convert.
01
Results First
Every decision is tied to outcomes, not aesthetics alone.
02
No Lock-in
You own everything. Cancel any time, no questions asked.
03
Fast Turnaround
Most projects live in weeks, not months of back-and-forth.
04
Radical Clarity
You'll always know what's happening and exactly why.
05
Premium Quality
Awwwards-level craft without the agency price tag.
06
Always Reachable
Direct access to the person doing the actual work.
07
Proven Impact
147 projects shipped. Real numbers, real testimonials.
Ready to see the difference?
Let's build something
worth talking about.
No obligation. No pitch deck. Just a real conversation
about what your project actually needs.
Response within 24hrs
No retainer required
Fixed pricing
drag to simulate scroll

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.

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:
  1. Download the .zip file above and extract the .json file on your computer.
  2. In WordPress, navigate to Templates → Saved Templates.
  3. Click Import Templates at the top, select your extracted .json file, and click Import Now.
  4. Once imported, simply load the template inside your Elementor canvas by opening the template library folder icon and choosing your saved template!
01
Concept

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 / selectorRole in the effect
.ctc-wrapperThe tall outer container — height: 100vh. Creates the scroll distance. Has no visual appearance itself.
.ctc-stickyThe inner sticky container — position: sticky; top: 0; height: 100vh. Stays on screen as the wrapper scrolls.
.ctc-cardEach benefit card. Absolutely positioned. JS updates left, top, transform: scale() and opacity based on scroll progress.
.ctc-card::beforeSubtle 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-headingSection heading. JS updates opacity and transform: scale() as progress increases.
.ctc-ctaThe CTA block. Hidden at start. JS sets opacity and transform: scale() based on progress above 72%.
02
Structure

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.
03
Reference

CSS class reference

ElementCSS class(es)Applied to
Outer Sectionctc-wrapperouter section element
Inner sticky sectionctc-stickyinner section element
Section headingctc-headingheading widget
Card HTML Widget (or Inner Section)ctc-card ctc-card-NUnique starting position CSS per card (e.g., ctc-card-1, ctc-card-2, etc.)
CTA block HTML widgetctc-ctahtml 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".
04
Build 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-heading in 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-heading directly 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 class="ctc-card-inner">
  <div class="ctc-num">01</div>
  <div class="ctc-title">Results First</div>
  <div class="ctc-desc">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: 14px top/bottom, 18px left/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 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 &rarr;</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>
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.
05
Code

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>
06
Customisation

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 change X and Y
  • In Widget 2, find the matching object in CARD_POSITIONS (index 0 = card 1) and update sx and sy to 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.

07
Mobile

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%, top 12%
  • Card 2: left 69%, top 8%
  • Card 3: left 0%, top 33%
  • Card 4: left 63%, top 32%
  • Card 5: left 2%, top 65%
  • Card 6: left 64%, top 67%
  • Card 7: left 32%, top 84%

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.
08
Troubleshooting

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.

09
Before 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.

12+
Free effects
2
HTML widgets only
0
Plugins required
Latest tutorial
Team Drift Scroll Effect
Elementor Free · Scroll + Hover

Get your Free eBook

Enter your details below and get instant access to the eBook — and we’ll email it to you too!

Sign Up

Get the latest update on small business marketing and general digital marketing contents.