← Overlays

Sticky Call-to-action Bar

Scroll past the hero and a bar springs up from the bottom of the screen with your offer and its button, so the call to action is never more than a thumb away. Scroll back up, or down to the footer, and it slides out of the way.

London · 14–16 MarchDesign Week 2027Get tickets

Early-bird: 30% offEnds Sunday

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-sticky-call-to-action-bar to your bar, with its text and button inside.
  3. Add data-after="#hero" (any selector) for the section it should wait for you to scroll past.

Options

Add any of these attributes to the same element.

data-afterThe element to scroll past before the bar shows. Without it, the bar shows after 400 px of scrolling.
data-untilHide the bar again while this element is on screen, such as footer.
data-durationTime to slide up. Default 600 ms.
data-containedSit at the bottom of its parent instead of the window, for previews. Give the parent position: relative and overflow: hidden.
data-autoplayScrolls the page in the preview down past the hero and back by itself while in view, until someone scrolls it.

A button inside with data-cta-close dismisses the bar for the rest of the visit. While it’s hidden the bar is inert, so its links can’t be tabbed to. Leave some space at the foot of your page so the bar never covers the last lines. It fires ctashow and ctahide events.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Sticky Call-to-action Bar: once you scroll past the hero, a bar slides up
    // from the bottom of the screen with your call to action. Scroll back up
    // (or down to the footer) and it slides away again.
    // Usage:
    //   <div data-sticky-call-to-action-bar data-after="#hero">
    //     <span>Early-bird tickets: 30% off</span>
    //     <a href="/tickets">Get tickets</a>
    //     <button data-cta-close aria-label="Close">×</button>
    //   </div>
    // Optional: data-after (selector of the element to scroll past, looked up
    // inside the bar's parent first; without it the bar shows after 400px of
    // scroll), data-until (selector: hide the bar again while this is on
    // screen, e.g. "footer"), data-duration (ms to slide up, default 600),
    // data-contained (sit at the bottom of the bar's parent instead of the
    // window; give the parent position: relative and overflow: hidden. The
    // scrolling element is found from data-after), data-autoplay (scrolls that
    // scrolling element down past the hero and back by itself while in view,
    // until someone scrolls or touches it).
    // A button inside with data-cta-close dismisses the bar for the visit.
    // Events: "ctashow" and "ctahide" on the bar.
    var DEFAULTS = { duration: 600, offset: 400, hold: 2200 };

    var css =
      '.scta{position:fixed;left:0;right:0;bottom:0;z-index:9000;box-sizing:border-box;visibility:hidden}' +
      '.scta.scta-c{position:absolute;z-index:4}' +
      ':where(.scta){display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:8px 16px;padding:12px 16px calc(12px + env(safe-area-inset-bottom));background:#fff;color:#1a1625;box-shadow:0 -10px 30px rgba(15,10,30,.14);font:600 15px/1.35 system-ui,sans-serif}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function on(v) { return v !== undefined && v !== 'false'; }

    // The nearest ancestor that scrolls, or null for the window.
    function scroller(node) {
      for (var n = node && node.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
        var o = getComputedStyle(n).overflowY;
        if ((o === 'auto' || o === 'scroll') && n.scrollHeight > n.clientHeight) return n;
      }
      return null;
    }

    function find(el, sel) {
      if (!sel) return null;
      return (el.parentNode && el.parentNode.querySelector(sel)) || document.querySelector(sel);
    }

    function setup(el) {
      var d = el.dataset;
      var b = {
        el: el, shown: false, past: false, atEnd: false, dismissed: false,
        duration: +d.duration || DEFAULTS.duration,
        after: find(el, d.after),
        until: find(el, d.until),
        deco: !!el.closest('[aria-hidden="true"]')
      };
      el._scta = b;
      el.classList.add('scta');
      if (on(d.contained)) el.classList.add('scta-c');
      if (!b.deco) el.inert = true;
      b.root = scroller(b.after || el);

      el.addEventListener('click', function (e) {
        if (!e.target.closest('[data-cta-close]')) return;
        b.dismissed = true;
        if (b.stopAuto) b.stopAuto();
        update(b);
      });

      if (b.after) {
        new IntersectionObserver(function (entries) {
          var e = entries[entries.length - 1];
          var top = e.rootBounds ? e.rootBounds.top : 0;
          b.past = !e.isIntersecting && e.boundingClientRect.top < top;
          update(b);
        }, { root: b.root }).observe(b.after);
      } else {
        var t = b.root || window;
        t.addEventListener('scroll', function () {
          var y = b.root ? b.root.scrollTop : window.scrollY;
          if ((y > DEFAULTS.offset) !== b.past) { b.past = !b.past; update(b); }
        }, { passive: true });
      }
      if (b.until) {
        new IntersectionObserver(function (entries) {
          b.atEnd = entries[entries.length - 1].isIntersecting;
          update(b);
        }, { root: b.root }).observe(b.until);
      }
      if (on(d.autoplay) && b.root && !reduced()) autoplay(b, +d.autoplay || DEFAULTS.hold);
      return b;
    }

    function update(b) {
      var want = b.past && !b.atEnd && !b.dismissed;
      if (want !== b.shown) (want ? show : hide)(b);
    }

    function show(b) {
      var el = b.el, kids = Array.from(el.children);
      b.shown = true;
      el.style.visibility = 'visible';
      if (!b.deco) el.inert = false;
      el.dispatchEvent(new CustomEvent('ctashow', { bubbles: true }));
      anime.utils.remove([el].concat(kids));
      if (reduced()) { anime.utils.set(el, { y: 0, opacity: 1 }); anime.utils.set(kids, { y: 0, opacity: 1 }); return; }
      anime.animate(el, { y: [el.offsetHeight + 24, 0], opacity: 1, ease: anime.spring({ bounce: 0.25, duration: b.duration }) });
      anime.animate(kids, { y: [14, 0], opacity: [0, 1], duration: b.duration * 0.8, delay: anime.stagger(60, { start: 120 }), ease: 'out(3)' });
    }

    function hide(b) {
      var el = b.el;
      b.shown = false;
      if (!b.deco) {
        if (el.contains(document.activeElement)) document.activeElement.blur();
        el.inert = true;
      }
      el.dispatchEvent(new CustomEvent('ctahide', { bubbles: true }));
      anime.utils.remove(el);
      var done = function () { if (!b.shown) el.style.visibility = 'hidden'; };
      if (reduced()) { anime.utils.set(el, { opacity: 0 }); return done(); }
      anime.animate(el, { y: el.offsetHeight + 24, duration: 380, ease: 'in(3)', onComplete: done });
    }

    // Demo mode: scroll the scrolling element down past the hero, wait, and back.
    function autoplay(b, hold) {
      var sc = b.root, timers = [], anim = null, visible = false, live = true;
      function later(fn, ms) { timers.push(setTimeout(fn, ms)); }
      function stop() {
        timers.forEach(clearTimeout); timers = [];
        if (anim) anim.pause();
        anim = null;
      }
      function glide(to, ms, then) {
        var o = { v: sc.scrollTop };
        anim = anime.animate(o, { v: to, duration: ms, ease: 'inOut(3)', onUpdate: function () { sc.scrollTop = o.v; }, onComplete: then });
      }
      function run() {
        stop();
        if (!visible || !live) return;
        var max = sc.scrollHeight - sc.clientHeight;
        var past = b.after
          ? b.after.getBoundingClientRect().bottom - sc.getBoundingClientRect().top + sc.scrollTop + sc.clientHeight * 0.25
          : DEFAULTS.offset + 80;
        later(function () {
          glide(Math.min(max, past), 1600, function () {
            later(function () {
              glide(0, 1300, function () { later(run, 300); });
            }, hold);
          });
        }, 900);
      }
      b.stopAuto = function () { if (!live) return; live = false; stop(); };
      ['wheel', 'touchstart', 'pointerdown', 'keydown'].forEach(function (t) {
        sc.addEventListener(t, function (e) { if (e.isTrusted) b.stopAuto(); }, { passive: true });
      });
      b.el.addEventListener('pointerdown', function (e) { if (e.isTrusted) b.stopAuto(); });
      new IntersectionObserver(function (entries) {
        visible = entries[entries.length - 1].isIntersecting;
        if (visible) run(); else stop();
      }, { threshold: 0.3 }).observe(sc);
    }

    function init() {
      document.querySelectorAll('[data-sticky-call-to-action-bar]').forEach(function (el) {
        if (el._scta) return;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section id="hero">…</section>

<div data-sticky-call-to-action-bar data-after="#hero" data-until="footer">
  <span>Early-bird tickets: 30% off</span>
  <a href="/tickets">Get tickets</a>
  <button data-cta-close aria-label="Close">×</button>
</div>