Hero section · GSAP workflow

Three animation layers, one coordinated system

Every premium hero runs three layers working together. Not independently — together. This is the architecture decision most developers miss.

Layer 1
Entrance choreography
Elements arrive in deliberate sequence on page load. This is what makes the hero feel designed rather than dumped.
Effect · Timeline sequence
Layer 2
Ambient motion
Something alive in the background while the user reads. Subtle. If they notice it, it's too much.
Effect · ADV-15 Organic Float
Layer 3
CTA interaction
Magnetic pull on hover. This is the premium signal. It tells the user this site was built with intention.
Effect · UI-08 Magnetic Button
The order matters. Entrance runs once on load. Ambient runs forever after. CTA is always-on. If you build them in the wrong order or trigger them independently, they fight each other. The code in Panel 3 sequences them correctly.
One HTML widget, placed below the section. All three layers live in a single <script> block. You do not need three separate widgets or three separate JS files. This is the Elementor-safe pattern.
Timeline of what fires
window load
|
0s
eyebrow
0.2s
0.2s
headline
0.5s
0.5s
subheading
0.8s
0.8s
CTA button
1s
1.0s
ambient float
loops forever →
magnetic CTA
always-on →
Step-by-step · Elementor editor

How to set up the Elementor structure

The CSS classes you add in Elementor are the selectors your GSAP code targets. Set these up first — the code in Panel 3 depends on them.

Section
Container (Flexbox)
Hero Section section
ds-hero
Advanced → CSS Classes → ds-hero
Layout → Min Height → 100vh · Align Items → Center
Col
Inner Container
Content Column
No class needed. Width: 60% on desktop.
Widget
Text Editor widget
Eyebrow / Label animates
ds-hero-eyebrow
Advanced → CSS Classes → ds-hero-eyebrow
Small caps text above the headline. e.g. "Web Design Studio"
Widget
Heading widget
H1 Headline animates
ds-hero-headline
Advanced → CSS Classes → ds-hero-headline
This targets the .elementor-heading-title inside it.
Widget
Text Editor widget
Subheading animates
ds-hero-sub
Advanced → CSS Classes → ds-hero-sub
Widget
Button widget
CTA Button animates + magnetic
ds-hero-cta
Advanced → CSS Classes → ds-hero-cta
The magnetic effect targets the .elementor-button inside it.
Ambient
HTML Widget (or Shape Divider decorative elements)
Floating background orbs floats
ds-hero-orb
Advanced → CSS Classes → ds-hero-orb on each decorative element.
Use multiple small divs or SVGs positioned absolutely in the section.
JS
HTML Widget — placed LAST inside the section
Animation Script all three layers
Drag an HTML widget to the very bottom of the hero section.
Paste the complete script from Panel 3 here.
This is the only JS you need for the entire hero.
Do not use Elementor's built-in entrance animations on any of these widgets. They use CSS keyframes that run before your GSAP timeline fires — you'll get double animations and jank. Leave Motion Effects blank on all hero widgets.
CSS class naming convention: prefix every animation class with ds- (designsuite). This prevents collision with Elementor's own classes and makes them easy to search across projects.
Complete script · paste into HTML widget

The full hero animation code

One script block. All three layers. Copy this into your HTML widget. Replace class names if yours differ from the diagram in Panel 2.

1
Load GSAP from CDN
Goes at the top of your HTML widget. Loads GSAP core only — no paid plugins needed for this setup.
HTML widget — top of script block paste first
<!-- GSAP CDN — goes in HTML widget -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
If your theme or MU-plugin already loads GSAP globally (like ds-elementor-optimizer.php), skip this line. Loading GSAP twice causes silent conflicts.
2
Layer 1 — Entrance timeline
Sets initial hidden state with gsap.set() then sequences everything in using a timeline. Uses gsap.set() + gsap.to() — never gsap.from() which conflicts with Elementor's CSS.
<script>
window.addEventListener('load', () => {
  gsap.matchMedia().add({
    "(prefers-reduced-motion: no-preference)": () => {

      // ── LAYER 1: Entrance choreography ──────────────

      // Set initial hidden state FIRST (never gsap.from)
      gsap.set([
        '.ds-hero-eyebrow',
        '.ds-hero-headline .elementor-heading-title',
        '.ds-hero-sub',
        '.ds-hero-cta .elementor-button'
      ], { autoAlpha: 0, y: 30 });

      // Build the entrance timeline
      const tl = gsap.timeline({
        defaults: { ease: 'power3.out', duration: 0.7 },
        delay: 0.1  // slight delay — lets browser paint first
      });

      tl.to('.ds-hero-eyebrow', {
        autoAlpha: 1, y: 0
      })
      .to('.ds-hero-headline .elementor-heading-title', {
        autoAlpha: 1, y: 0, duration: 0.9
      }, '-=0.4')   // overlap — starts before eyebrow finishes
      .to('.ds-hero-sub', {
        autoAlpha: 1, y: 0
      }, '-=0.5')
      .to('.ds-hero-cta .elementor-button', {
        autoAlpha: 1, y: 0, duration: 0.6
      }, '-=0.4');

    },
    "(prefers-reduced-motion: reduce)": () => {
      // Show everything immediately — no animation
      gsap.set([
        '.ds-hero-eyebrow',
        '.ds-hero-headline .elementor-heading-title',
        '.ds-hero-sub',
        '.ds-hero-cta .elementor-button'
      ], { autoAlpha: 1, y: 0 });
    }
  });
});
3
Layer 2 — Ambient float
Starts after the entrance completes. Floats any element with class ds-hero-orb. Recursive onComplete means each orb picks a new random destination every cycle.
<script>
window.addEventListener('load', () => {
  gsap.matchMedia().add({
    "(prefers-reduced-motion: no-preference)": () => {

      // ── LAYER 2: Ambient floating orbs ──────────────
      // Target any element with class ds-hero-orb
      const orbs = gsap.utils.toArray('.ds-hero-orb');

      function floatOrb(el) {
        gsap.to(el, {
          x: gsap.utils.random(-24, 24),
          y: gsap.utils.random(-18, 18),
          rotation: gsap.utils.random(-6, 6),
          scale: gsap.utils.random(0.96, 1.04),
          duration: gsap.utils.random(3, 6),
          ease: 'sine.inOut',
          onComplete: () => floatOrb(el)
        });
      }

      // Stagger start so orbs don't move in sync
      orbs.forEach(orb => {
        gsap.delayedCall(gsap.utils.random(0, 2), () => floatOrb(orb));
      });

    },
    "(prefers-reduced-motion: reduce)": () => {} // stationary
  });
});
4
Layer 3 — Magnetic CTA
Uses gsap.quickTo() — creates a cached setter once, not on every mousemove. Touch detection prevents it firing on mobile. Elastic snap-back on leave.
<script>
window.addEventListener('load', () => {

  // ── LAYER 3: Magnetic CTA ────────────────────────
  // Desktop only — no-op on touch devices
  if ('ontouchstart' in window || navigator.maxTouchPoints > 0) return;

  const btn = document.querySelector('.ds-hero-cta .elementor-button');
  if (!btn) return; // guard if widget not present

  // quickTo — cached setter, NOT gsap.to() on every event
  const xTo = gsap.quickTo(btn, 'x', { duration: 0.4, ease: 'power2.out' });
  const yTo = gsap.quickTo(btn, 'y', { duration: 0.4, ease: 'power2.out' });
  const PROXIMITY = 80; // px from button centre

  btn.addEventListener('mousemove', e => {
    const r = btn.getBoundingClientRect();
    const dx = e.clientX - (r.left + r.width / 2);
    const dy = e.clientY - (r.top + r.height / 2);
    if (Math.sqrt(dx*dx + dy*dy) < PROXIMITY) {
      xTo(dx * 0.35);
      yTo(dy * 0.35);
    }
  });

  btn.addEventListener('mouseleave', () => {
    gsap.to(btn, {
      x: 0, y: 0,
      duration: 0.6,
      ease: 'elastic.out(1, 0.4)'
    });
  });

});
Why quickTo() not gsap.to() inside mousemove? gsap.to() creates a new tween object on every mouse event — up to 60 per second. That's 60 tweens/sec fighting each other. quickTo() creates one setter upfront and updates it. This is the correct pattern for any pointer-driven animation.
Complete combined script

All three layers in one block — this is the actual paste-ready code for your HTML widget.

HTML Widget · complete script production-ready
<!-- GSAP — skip if already loaded globally -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>

<script>
window.addEventListener('load', () => {
  gsap.matchMedia().add({
    "(prefers-reduced-motion: no-preference)": () => {

      // ── LAYER 1: Entrance ───────────────────────────
      gsap.set([
        '.ds-hero-eyebrow',
        '.ds-hero-headline .elementor-heading-title',
        '.ds-hero-sub',
        '.ds-hero-cta .elementor-button'
      ], { autoAlpha: 0, y: 30 });

      const tl = gsap.timeline({
        defaults: { ease: 'power3.out', duration: 0.7 },
        delay: 0.1
      });

      tl.to('.ds-hero-eyebrow', { autoAlpha: 1, y: 0 })
        .to('.ds-hero-headline .elementor-heading-title',
            { autoAlpha: 1, y: 0, duration: 0.9 }, '-=0.4')
        .to('.ds-hero-sub', { autoAlpha: 1, y: 0 }, '-=0.5')
        .to('.ds-hero-cta .elementor-button',
            { autoAlpha: 1, y: 0, duration: 0.6 }, '-=0.4');

      // ── LAYER 2: Ambient float ──────────────────────
      function floatOrb(el) {
        gsap.to(el, {
          x: gsap.utils.random(-24, 24),
          y: gsap.utils.random(-18, 18),
          rotation: gsap.utils.random(-6, 6),
          scale: gsap.utils.random(0.96, 1.04),
          duration: gsap.utils.random(3, 6),
          ease: 'sine.inOut',
          onComplete: () => floatOrb(el)
        });
      }
      gsap.utils.toArray('.ds-hero-orb').forEach(orb => {
        gsap.delayedCall(gsap.utils.random(0, 2), () => floatOrb(orb));
      });

    },
    "(prefers-reduced-motion: reduce)": () => {
      gsap.set([
        '.ds-hero-eyebrow',
        '.ds-hero-headline .elementor-heading-title',
        '.ds-hero-sub',
        '.ds-hero-cta .elementor-button'
      ], { autoAlpha: 1, y: 0 });
    }
  });

  // ── LAYER 3: Magnetic CTA ────────────────────────
  if ('ontouchstart' in window || navigator.maxTouchPoints > 0) return;
  const btn = document.querySelector('.ds-hero-cta .elementor-button');
  if (!btn) return;

  const xTo = gsap.quickTo(btn, 'x', { duration: 0.4, ease: 'power2.out' });
  const yTo = gsap.quickTo(btn, 'y', { duration: 0.4, ease: 'power2.out' });

  btn.addEventListener('mousemove', e => {
    const r = btn.getBoundingClientRect();
    const dx = e.clientX - (r.left + r.width / 2);
    const dy = e.clientY - (r.top + r.height / 2);
    if (Math.sqrt(dx*dx + dy*dy) < 80) { xTo(dx * 0.35); yTo(dy * 0.35); }
  });
  btn.addEventListener('mouseleave', () => {
    gsap.to(btn, { x: 0, y: 0, duration: 0.6, ease: 'elastic.out(1, 0.4)' });
  });

});
</script>
Live preview

See all three layers running

Hover the CTA button (magnetic). The background orbs float continuously. Hit Replay to re-run the entrance sequence.

hero-section · all three layers active
Web Design Studio

We build sites that
move people.

Premium Elementor development for startups and studios who need more than a template.

1
Entrance: eyebrow → headline → subtext → button, each overlapping slightly so it flows as one motion rather than clunky steps
2
Ambient: the glow orb and dots drift with randomised destinations — subtle enough that users don't consciously notice
3
Magnetic: hover the CTA button — it pulls toward your cursor and snaps back elastically on leave
Slower entrance: increase duration values (0.7 → 1.0)
Less overlap: change '-=0.4' to '-=0.1' or '+=0.1'
Stronger magnet: increase 0.35 multiplier toward 0.5
More orb travel: increase random range from 24 to 40
Signature ease: swap 'power3.out' for your custom CustomEase name
Before you publish

Pre-flight checklist

Run through this every time. Each item has caught a real production bug.

Elementor Motion Effects are blank on all hero widgets
Check every widget in the hero: Advanced → Motion Effects → Entrance Animation should be "None". If anything is set, remove it — it will double-animate.
HTML widget is the last item inside the section
DOM order matters. The script runs document.querySelector — if the HTML widget loads before the heading widget is parsed, querySelector returns null and the animation silently fails.
Check for FLASH OF INVISIBLE CONTENT (FOIC)
On slow connections the page may flicker — elements are invisible for a moment before GSAP sets them. Fix: add this CSS to your custom CSS in Elementor:

.ds-hero-eyebrow, .ds-hero-headline, .ds-hero-sub, .ds-hero-cta { visibility: hidden; }

GSAP's autoAlpha sets visibility: visible when it animates, overriding this. So elements stay invisible until GSAP is ready.
Test prefers-reduced-motion
In Chrome DevTools → Rendering → Emulate CSS media → prefers-reduced-motion: reduce. All hero elements should appear immediately with no animation. This is an accessibility requirement, not optional.
Test on mobile — magnetic should be inert
Touch detection: 'ontouchstart' in window || navigator.maxTouchPoints > 0. The magnetic layer returns early and does nothing. The entrance and ambient still run. Verify on a real device, not just DevTools resize.
GSAP is not loaded twice
If your MU-plugin already loads GSAP globally, remove the CDN <script> line from the HTML widget. Open DevTools → Network → filter "gsap" — you should see exactly one request. Two means a version conflict.