Every premium hero runs three layers working together. Not independently — together. This is the architecture decision most developers miss.
<script> block. You do not need three separate widgets or three separate JS files. This is the Elementor-safe pattern.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.
ds-herods-hero-eyebrowds-hero-headline.elementor-heading-title inside it.ds-hero-subds-hero-cta.elementor-button inside it.ds-hero-orb on each decorative element.ds- (designsuite). This prevents collision with Elementor's own classes and makes them easy to search across projects.One script block. All three layers. Copy this into your HTML widget. Replace class names if yours differ from the diagram in Panel 2.
<!-- GSAP CDN — goes in HTML widget --> <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
ds-elementor-optimizer.php), skip this line. Loading GSAP twice causes silent conflicts.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 }); } }); });
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 }); });
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)' }); }); });
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.All three layers in one block — this is the actual paste-ready code for your HTML widget.
<!-- 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>
Hover the CTA button (magnetic). The background orbs float continuously. Hit Replay to re-run the entrance sequence.
Premium Elementor development for startups and studios who need more than a template.
duration values (0.7 → 1.0)'-=0.4' to '-=0.1' or '+=0.1'0.35 multiplier toward 0.524 to 40'power3.out' for your custom CustomEase nameRun through this every time. Each item has caught a real production bug.
document.querySelector — if the HTML widget loads before the heading widget is parsed, querySelector returns null and the animation silently fails..ds-hero-eyebrow, .ds-hero-headline, .ds-hero-sub, .ds-hero-cta { visibility: hidden; }autoAlpha sets visibility: visible when it animates, overriding this. So elements stay invisible until GSAP is ready.
'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.<script> line from the HTML widget. Open DevTools → Network → filter "gsap" — you should see exactly one request. Two means a version conflict.