ui-037

Announcement Bar

Free

An announcement bar for sites: pushes the page down instead of covering it, rotates messages with a masked swap and collapses on dismiss.

intermediate
6 more details
micro-interactionstaggerstate-transitiontimed-streamprogress-barkeyboard-navigation

This element reads better at your own screen size than in the frame below:

Updated

About this component

A site-wide announcement bar for the top of the page: the promo bar, the top bar notification, the maintenance notice. It sits in normal document flow, so when it enters it tweens its height from zero and the page below rides down with it, and when it is dismissed the height tweens back to zero and the page reflows up. Nothing overlaps your header and nothing jumps.

Read the full effect overview

Messages are the list items inside the bar, so a promo with a CTA link is one line of HTML, not a JavaScript array. Every few seconds the current line exits upward through a mask with a slight lean and the next line's words rise in on a stagger, while a thin progress track shows the time to the next one. Rotation pauses on hover, on keyboard focus and while the tab is hidden, so nobody loses a message they were reading.

Four types (info, promo, warning, danger) set the accent through CSS custom properties and decide how the bar is announced: danger is role=alert, the rest are a polite status region. An optional storage key remembers a dismissal in localStorage; an optional sticky mode pins the bar after it has pushed in. Two themes, light and dark, defined entirely in CSS: dark is the same design re-valued, not a second skin.

Announcement Bar - UI component preview

What's included

10 items
  • Pushes the page down by tweening real height, not a transform, so the layout genuinely moves
  • Messages are semantic list items you edit in HTML, each with an optional CTA link
  • Masked line swap: the current line exits upward with a lean, the next line's words rise in on a stagger
  • Thin progress track shows the time to the next message, tweened rather than timed
  • Rotation pauses on hover, focus-within and when the tab is hidden
  • Four types (info, promo, warning, danger) set the accent and the live-region politeness
  • Dismiss spins the close icon, fades the content and collapses the height so the page reflows up
  • Optional data-storage-key remembers a dismissal; optional data-sticky pins the bar after push-in
  • Arrow keys step, Escape dismisses, and a horizontal swipe steps on touch
  • announcementBar.show / dismiss / next / prev / go / pause / resume, plus announcement:show / change / dismiss events

Perfect for

5 use cases
  • Shop promo bars: free shipping thresholds, sale windows, new drops
  • Scheduled maintenance and incident notices above the header
  • SaaS product announcements and release notes with a link
  • Cookie-free consent-adjacent notices that must not overlap the header
  • Event countdowns and registration reminders on a landing page

How it works

3 sections

The push is a height tween

The bar sits at the top of the document in normal flow. On show the script takes the bar's natural height, collapses it to zero and tweens it back open, so everything below moves with it instead of being covered. Dismiss runs the same tween closed while the content fades and the close icon spins, then sets the hidden attribute so an empty bar is never left in the flow.

A masked line swap

Every message is a list item stacked in one grid cell inside a viewport with overflow hidden. Each message's words are wrapped in spans once at init. On a swap the outgoing line rises away with a two-degree lean while the incoming line's words rise from below on a short stagger. Under prefers-reduced-motion the swap is an instant cut.

The countdown is the progress track

The time to the next message is a linear tween scaling the progress fill from zero to one, whose onComplete steps to the next message. Pausing the rotation is pausing that tween, so hovering, tabbing in or switching tabs holds the track where it is and leaving resumes it. A step requested during a swap is queued and played after it, so rapid arrow presses never desync the index.

Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="robots" content="noindex, nofollow">
  <title>Announcement Bar — GSAP Vault</title>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
  <link rel="stylesheet" href="assets/style.css">
  <style>
    /* Demo-only toolbar: the theme toggle and the bar's real controls, in a
       44px strip across the top. The body reserves it with padding-top (in
       assets/style.css). None of this is part of the component. */
    .showcase-toolbar {
      position: fixed; inset: 0 0 auto 0; z-index: 1000; height: 44px;
      display: flex; align-items: center; gap: 10px; padding: 0 12px;
      flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none;
      background: var(--ground-2); border-bottom: 1px solid var(--line);
      color: var(--ink-3); font-family: var(--font); font-size: 0.75rem;
      white-space: nowrap;
    }
    .showcase-toolbar::-webkit-scrollbar { display: none; }
    .showcase-toolbar__prompt { color: var(--ink-2); margin-right: auto; }
    .showcase-toolbar__group { display: flex; gap: 2px; padding: 2px; border-radius: 6px; border: 1px solid var(--line); background: var(--raised); }
    .showcase-toolbar button {
      appearance: none; border: 0; cursor: pointer; font: inherit;
      min-height: 26px; padding: 0 9px; border-radius: 4px;
      background: transparent; color: var(--ink-2);
    }
    .showcase-toolbar button:hover { background: var(--hover); color: var(--ink); }
    .showcase-toolbar button[aria-pressed="true"] { background: var(--ink); color: var(--ground); }
    .showcase-toolbar button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
    @media (max-width: 620px) { .showcase-toolbar__prompt { display: none; } }

    /* Demo-only: in your own page the sticky bar pins to the top of the
       viewport. Here it has to clear the toolbar strip. */
    .ann-bar.is-sticky { top: 44px; }
  </style>
</head>
<body data-variant="light">

  <header class="showcase-toolbar">
    <span class="showcase-toolbar__prompt">The bar rotates on its own; step it, pin it or dismiss it.</span>
    <div class="showcase-toolbar__group" role="group" aria-label="Type">
      <button type="button" data-set-type="info" aria-pressed="true">Info</button>
      <button type="button" data-set-type="promo" aria-pressed="false">Promo</button>
      <button type="button" data-set-type="warning" aria-pressed="false">Warning</button>
      <button type="button" data-set-type="danger" aria-pressed="false">Danger</button>
    </div>
    <div class="showcase-toolbar__group" role="group" aria-label="Position">
      <button type="button" data-set-sticky="false" aria-pressed="true">Static</button>
      <button type="button" data-set-sticky="true" aria-pressed="false">Sticky</button>
    </div>
    <div class="showcase-toolbar__group" role="group" aria-label="Demo controls">
      <button type="button" data-demo="replay">Replay</button>
      <button type="button" data-demo="reset">Reset</button>
    </div>
    <div class="showcase-toolbar__group" role="group" aria-label="Theme">
      <button class="variant-btn" type="button" data-variant="light" aria-pressed="true">Light</button>
      <button class="variant-btn" type="button" data-variant="dark" aria-pressed="false">Dark</button>
    </div>
  </header>

  <main>
    <!-- Component starts here -->
    <div class="ann-bar" data-announcement-bar data-type="info" data-interval="5000" data-storage-key="gsap-vault-announcement-demo" role="region" aria-label="Announcements">
      <div class="ann-bar__inner" data-inner>
        <button class="ann-bar__btn ann-bar__prev" type="button" data-prev aria-label="Previous announcement">
          <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
        </button>

        <div class="ann-bar__viewport" data-messages>
          <ul class="ann-bar__list">
            <li class="ann-bar__msg">
              <span class="ann-bar__pip" aria-hidden="true"></span>
              <span class="ann-bar__text" data-text>Free delivery on every order over £60 until Sunday</span>
              <a class="ann-bar__cta" href="#delivery">See details</a>
            </li>
            <li class="ann-bar__msg">
              <span class="ann-bar__pip" aria-hidden="true"></span>
              <span class="ann-bar__text" data-text>Version 2.4 is out: faster exports and a new audit log</span>
              <a class="ann-bar__cta" href="#release">Read the notes</a>
            </li>
            <li class="ann-bar__msg">
              <span class="ann-bar__pip" aria-hidden="true"></span>
              <span class="ann-bar__text" data-text>Scheduled maintenance Thursday 02:00–03:00 UTC, checkout will be paused</span>
            </li>
            <li class="ann-bar__msg">
              <span class="ann-bar__pip" aria-hidden="true"></span>
              <span class="ann-bar__text" data-text>Members get early access to the sale from Friday 9am</span>
              <a class="ann-bar__cta" href="#access">Join free</a>
            </li>
            <li class="ann-bar__msg">
              <span class="ann-bar__pip" aria-hidden="true"></span>
              <span class="ann-bar__text" data-text>Now shipping to 14 more countries, duties included</span>
            </li>
          </ul>
        </div>

        <button class="ann-bar__btn ann-bar__next" type="button" data-next aria-label="Next announcement">
          <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
        </button>
        <button class="ann-bar__btn ann-bar__close" type="button" data-close aria-label="Dismiss announcement">
          <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>
        </button>
      </div>
      <div class="ann-bar__progress" aria-hidden="true"><span data-progress></span></div>
    </div>
    <!-- Component ends here -->

    <!-- Demo furniture: a short page for the bar to sit on and push down.
         Not part of the component; delete it in your own page. -->
    <div class="stage-doc">
      <h1>Delivery and returns</h1>
      <p>Orders placed before 3pm on a working day are picked the same afternoon. Anything after that goes out the next morning. You get a tracking link as soon as the parcel is scanned, and a second message when it is out for delivery.</p>

      <h2>Delivery times</h2>
      <p>Standard delivery is two to four working days. Express is next working day if the order is placed before 3pm. Remote postcodes can take an extra day, and we say so at checkout rather than after you have paid.</p>
      <p>If a parcel has not moved for three days, reply to the dispatch email and we will open a claim with the carrier. You do not need to chase it yourself.</p>

      <h2>Returns</h2>
      <p>You have 30 days from delivery to send anything back, for any reason. Items should be unworn and in their original packaging, with the tags still attached. Print the label from your order page, or ask for one to be posted to you.</p>
      <p>Refunds are issued to the original payment method within five working days of the return arriving. Exchanges are shipped as soon as the return is scanned, so you are not waiting twice.</p>

      <h2>Duties and taxes</h2>
      <p>Prices include duties for every country we ship to, so there is nothing to pay on arrival. If a carrier asks you for a fee anyway, send us the paperwork and we will refund it.</p>
      <p>VAT is shown separately on your invoice. Business customers can add a VAT number to their account and it will appear on every invoice from then on.</p>

      <h2>Changing an order</h2>
      <p>An order can be changed or cancelled until it is picked, which is usually within a couple of hours. After that, the fastest route is to accept the delivery and return it, which keeps the refund on the same timeline as everything else.</p>
    </div>
  </main>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
  <script>
    // Demo-only: the product page embeds this demo, so a choice remembered
    // from an earlier visit must never leave the iframe empty. Clear the demo
    // key before the component reads it. A buyer's page keeps the memory.
    try { localStorage.removeItem('gsap-vault-announcement-demo'); } catch (e) {}
  </script>
  <script src="assets/script.js"></script>
  <script>
    // Demo-only theme switcher; also honours ?variant=name. The component
    // itself only needs data-variant on an ancestor of the bar (here, <body>).
    (function () {
      var buttons = Array.prototype.slice.call(document.querySelectorAll('.variant-btn'));
      var names = buttons.map(function (b) { return b.dataset.variant; });
      function setVariant(name) {
        document.body.setAttribute('data-variant', name);
        buttons.forEach(function (b) { b.setAttribute('aria-pressed', String(b.dataset.variant === name)); });
        // A theme can move padding, so anything measured re-measures.
        requestAnimationFrame(function () { window.dispatchEvent(new Event('resize')); });
      }
      var wanted = new URLSearchParams(location.search).get('variant');
      var initial = names.indexOf(wanted) > -1 ? wanted : document.body.getAttribute('data-variant') || names[0];
      setVariant(initial);
      buttons.forEach(function (b) { b.addEventListener('click', function () { setVariant(b.dataset.variant); }); });
    })();

    // Demo-only: drives the component's documented options and public API.
    (function () {
      var bar = document.querySelector('[data-announcement-bar]');
      if (!bar) return;

      // Type: data-type on the bar, via the API so the live region follows.
      var typeButtons = Array.prototype.slice.call(document.querySelectorAll('[data-set-type]'));
      typeButtons.forEach(function (b) {
        b.addEventListener('click', function () {
          if (!window.announcementBar) return;
          window.announcementBar.setType(b.dataset.setType);
          typeButtons.forEach(function (o) { o.setAttribute('aria-pressed', String(o === b)); });
        });
      });

      // Position: data-sticky on the bar. The script adds .is-sticky after a
      // push-in when the attribute was set at init, so the demo re-applies it
      // on every show as well as immediately.
      var stickyButtons = Array.prototype.slice.call(document.querySelectorAll('[data-set-sticky]'));
      var wantSticky = false;
      function applySticky() {
        bar.setAttribute('data-sticky', String(wantSticky));
        bar.classList.toggle('is-sticky', wantSticky && bar.classList.contains('is-open'));
      }
      stickyButtons.forEach(function (b) {
        b.addEventListener('click', function () {
          wantSticky = b.dataset.setSticky === 'true';
          stickyButtons.forEach(function (o) { o.setAttribute('aria-pressed', String(o === b)); });
          applySticky();
        });
      });
      bar.addEventListener('announcement:show', applySticky);

      var demos = {
        // Dismiss, then push back in once the collapse has finished.
        replay: function () {
          var api = window.announcementBar;
          if (!api.isOpen()) { api.forget(); api.show(); return; }
          bar.addEventListener('announcement:dismiss', function () { api.forget(); api.show(); }, { once: true });
          api.dismiss();
        },
        // Clear the remembered dismissal (data-storage-key) and show the bar.
        reset: function () {
          var api = window.announcementBar;
          api.forget();
          if (!api.isOpen()) api.show();
        }
      };
      document.querySelectorAll('[data-demo]').forEach(function (button) {
        button.addEventListener('click', function () {
          var run = demos[button.dataset.demo];
          if (run && window.announcementBar) run();
        });
      });
    })();
  </script>
</body>
</html>
/**
 * Announcement Banner Bar
 *
 * A top-of-page announcement bar that pushes the page down instead of
 * overlapping it, rotates through the messages you write as list items,
 * shows a thin progress track to the next one, and collapses away on
 * dismiss so the page reflows up. Pauses on hover, on focus and while the
 * tab is hidden; arrow keys and swipes step through the messages; an
 * optional storage key remembers a dismissal.
 *
 * @plugins none (GSAP core only)
 * @techniques micro-interaction, stagger, state-transition, timed-stream, progress-bar, keyboard-navigation
 */

(function () {
  (function onReady(init) {
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', init, { once: true });
    } else {
      init();
    }
  })(function initAnnouncementBar() {
    if (typeof gsap === 'undefined') {
      console.warn('[announcement-banner-bar] GSAP is required but was not found.');
      return;
    }

    document.documentElement.classList.add('has-js');

    /* The bar's type decides how it is announced. A danger message interrupts
       (role=alert); everything else waits its turn (role=status, polite). */
    var TYPES = ['info', 'promo', 'warning', 'danger'];

    var ctx = gsap.context(function () {
      var mm = gsap.matchMedia();

      // Both pairs are complementary on purpose: gsap.matchMedia only runs the
      // callback while at least one condition matches, so a lone
      // "(pointer: coarse)" would never fire on a desktop.
      mm.add({
        isFine: '(pointer: fine)',
        isCoarse: '(pointer: coarse)',
        isMotion: '(prefers-reduced-motion: no-preference)',
        isReduced: '(prefers-reduced-motion: reduce)'
      }, function (context) {
        var isCoarse = context.conditions.isCoarse;
        var isReduced = context.conditions.isReduced;
        var handlers = [];
        var instances = [];

        var addHandler = function (element, type, handler, options) {
          element.addEventListener(type, handler, options);
          handlers.push([element, type, handler, options]);
        };

        /* Every helper is declared before its first use: the demo build turns
           function declarations into non-hoisted consts. */

        var dur = function (seconds) {
          return isReduced ? 0 : seconds;
        };

        /* Wrap each word of a message in its own span so the incoming line can
           stagger up through the mask. Runs once per message; the CTA link is
           left alone. */
        var splitWords = function (textEl) {
          if (textEl.dataset.split === 'true') return;
          var words = textEl.textContent.trim().split(/\s+/);
          textEl.textContent = '';
          words.forEach(function (word, i) {
            var span = document.createElement('span');
            span.className = 'ann-bar__word';
            span.textContent = word;
            textEl.appendChild(span);
            if (i < words.length - 1) textEl.appendChild(document.createTextNode(' '));
          });
          textEl.dataset.split = 'true';
        };

        var createBar = function (bar) {
          var viewport = bar.querySelector('[data-messages]');
          var items = viewport ? Array.prototype.slice.call(viewport.querySelectorAll('li')) : [];
          if (!viewport || !items.length) return null;

          var inner = bar.querySelector('[data-inner]') || bar.firstElementChild;
          var prevBtn = bar.querySelector('[data-prev]');
          var nextBtn = bar.querySelector('[data-next]');
          var closeBtn = bar.querySelector('[data-close]');
          var progress = bar.querySelector('[data-progress]');
          var closeIcon = closeBtn ? (closeBtn.querySelector('svg') || closeBtn) : null;

          var interval = Math.max(1000, Number(bar.dataset.interval) || 5000);
          var storageKey = bar.dataset.storageKey || '';
          var sticky = bar.dataset.sticky === 'true';

          var index = 0;
          var isOpen = false;
          var swapping = false;
          var queued = null;          // a step requested mid-swap, played after it
          var timer = null;           // the progress tween: pausing it pauses rotation
          var hovering = false;
          var focused = false;
          var userPaused = false;

          items.forEach(function (li) {
            var text = li.querySelector('[data-text]') || li;
            splitWords(text);
            li.setAttribute('aria-hidden', 'true');
            gsap.set(li, { yPercent: 0, y: 0, rotation: 0, opacity: 1 });
          });

          /* ---------- helpers ---------- */

          var emit = function (name, detail) {
            bar.dispatchEvent(new CustomEvent('announcement:' + name, {
              bubbles: true,
              detail: detail || {}
            }));
          };

          var remembered = function () {
            if (!storageKey) return false;
            try { return window.localStorage.getItem(storageKey) === 'dismissed'; } catch (e) { return false; }
          };

          var remember = function () {
            if (!storageKey) return;
            try { window.localStorage.setItem(storageKey, 'dismissed'); } catch (e) { /* private mode */ }
          };

          var forget = function () {
            if (!storageKey) return;
            try { window.localStorage.removeItem(storageKey); } catch (e) { /* private mode */ }
          };

          /* Politeness follows the type: a danger bar is role=alert, the rest
             role=status with a polite live region on the message viewport. */
          var applyType = function (type) {
            var safe = TYPES.indexOf(type) > -1 ? type : 'info';
            bar.setAttribute('data-type', safe);
            if (safe === 'danger') {
              viewport.setAttribute('role', 'alert');
              viewport.setAttribute('aria-live', 'assertive');
            } else {
              viewport.setAttribute('role', 'status');
              viewport.setAttribute('aria-live', 'polite');
            }
            viewport.setAttribute('aria-atomic', 'true');
          };

          var wordsOf = function (li) {
            return li.querySelectorAll('.ann-bar__word');
          };

          var focusablesIn = function (li) {
            return li.querySelectorAll('a[href], button');
          };

          var activate = function (li, on) {
            li.classList.toggle('is-active', on);
            li.setAttribute('aria-hidden', on ? 'false' : 'true');
            // Links in hidden messages must not be tab stops.
            Array.prototype.forEach.call(focusablesIn(li), function (el) {
              if (on) el.removeAttribute('tabindex');
              else el.setAttribute('tabindex', '-1');
            });
          };

          var shouldRun = function () {
            return isOpen && !hovering && !focused && !userPaused && !document.hidden && items.length > 1;
          };

          /* The countdown is a tween scrubbing the progress track, so pausing
             the rotation is pausing the tween and the track holds where it is. */
          var killTimer = function () {
            if (timer) { timer.kill(); timer = null; }
            if (progress) gsap.set(progress, { scaleX: 0 });
          };

          var startTimer = function () {
            killTimer();
            if (items.length < 2 || !isOpen) return;
            var target = progress || {};
            timer = gsap.fromTo(target, { scaleX: 0 }, {
              scaleX: 1,
              duration: interval / 1000,
              ease: 'none',
              paused: !shouldRun(),
              onComplete: function () { step(1); }
            });
          };

          var syncTimer = function () {
            if (!timer) return;
            if (shouldRun()) timer.play();
            else timer.pause();
          };

          /* The signature swap: the current line exits upward through the
             mask with a slight lean, and the next line's words rise in on a
             stagger. Under reduced motion it is an instant cut. */
          var swapTo = function (next, direction, onDone) {
            var from = items[index];
            var to = items[next];
            if (from === to) { if (onDone) onDone(); return; }
            swapping = true;
            var sign = direction < 0 ? -1 : 1;

            gsap.killTweensOf([from, to, wordsOf(from), wordsOf(to)]);
            activate(to, true);
            gsap.set(to, { yPercent: 0, y: 0, rotation: 0, opacity: 1 });
            gsap.set(wordsOf(to), { yPercent: isReduced ? 0 : 115 * sign, y: 0, opacity: isReduced ? 1 : 0 });

            var tl = gsap.timeline({
              onComplete: function () {
                activate(from, false);
                gsap.set(from, { yPercent: 0, y: 0, rotation: 0, opacity: 1 });
                gsap.set(wordsOf(from), { yPercent: 0, y: 0, opacity: 1 });
                index = next;
                swapping = false;
                emit('change', { index: index, type: bar.getAttribute('data-type') });
                if (onDone) onDone();
                if (queued !== null) { var q = queued; queued = null; step(q); }
              }
            });

            tl.to(from, {
              yPercent: -110 * sign,
              rotation: -2 * sign,
              opacity: 0,
              duration: dur(0.55),
              ease: 'expo.inOut'
            }, 0);
            // amount, not each: the spread stays 0.2s whether the line has
            // three words or twelve, so a long message never drags the swap.
            tl.to(wordsOf(to), {
              yPercent: 0,
              opacity: 1,
              duration: dur(0.55),
              ease: 'expo.inOut',
              stagger: { amount: dur(0.2) }
            }, dur(0.1));
          };

          var step = function (delta) {
            if (!isOpen || items.length < 2) return;
            if (swapping) { queued = delta; return; }
            var next = (index + delta + items.length) % items.length;
            swapTo(next, delta, startTimer);
          };

          var go = function (i) {
            if (!isOpen) return;
            var target = ((Number(i) || 0) % items.length + items.length) % items.length;
            if (target === index) { startTimer(); return; }
            if (swapping) { queued = target - index; return; }
            swapTo(target, target > index ? 1 : -1, startTimer);
          };

          /* ---------- open and close ---------- */

          var show = function () {
            if (isOpen) return;
            isOpen = true;
            bar.hidden = false;
            bar.classList.remove('is-dismissed');
            bar.classList.add('is-open');
            if (sticky) bar.classList.remove('is-sticky');

            items.forEach(function (li, i) { activate(li, i === index); });
            var current = items[index];
            var words = wordsOf(current);

            gsap.killTweensOf([bar, inner, words, closeIcon].filter(Boolean));
            if (closeIcon) gsap.set(closeIcon, { rotation: 0 });

            // Tween the height, never a transform: the page below must
            // genuinely move with the bar. Measure with the bar at auto so a
            // variant switch or a different font is measured, not guessed.
            gsap.set(bar, { height: 'auto', overflow: 'hidden' });
            var target = bar.offsetHeight;
            gsap.set(bar, { height: 0 });
            gsap.set(inner, { opacity: 1 });
            gsap.set(words, { yPercent: isReduced ? 0 : 115, y: 0, opacity: isReduced ? 1 : 0 });

            var tl = gsap.timeline({
              onComplete: function () {
                gsap.set(bar, { height: 'auto', clearProps: 'overflow' });
                if (sticky) bar.classList.add('is-sticky');
                startTimer();
                emit('show', { index: index });
              }
            });
            tl.to(bar, { height: target, duration: dur(0.7), ease: 'power3.out' }, 0);
            tl.to(words, {
              yPercent: 0,
              opacity: 1,
              duration: dur(0.6),
              ease: 'power3.out',
              stagger: { amount: dur(0.22) }
            }, dur(0.28));
          };

          var moveFocusOut = function () {
            if (!bar.contains(document.activeElement)) return;
            var all = document.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])');
            var next = null;
            for (var i = 0; i < all.length; i += 1) {
              if (!bar.contains(all[i]) && all[i].offsetParent !== null) { next = all[i]; break; }
            }
            if (next) next.focus({ preventScroll: true });
            else document.activeElement.blur();
          };

          var dismiss = function () {
            if (!isOpen) return;
            isOpen = false;
            killTimer();
            remember();
            moveFocusOut();
            bar.classList.remove('is-sticky');

            gsap.killTweensOf([bar, inner, closeIcon].filter(Boolean));
            gsap.set(bar, { height: bar.offsetHeight, overflow: 'hidden' });

            var tl = gsap.timeline({
              onComplete: function () {
                bar.classList.remove('is-open');
                bar.classList.add('is-dismissed');
                bar.hidden = true;
                gsap.set(bar, { height: 0 });
                emit('dismiss', { index: index });
              }
            });
            if (closeIcon) tl.to(closeIcon, { rotation: 90, duration: dur(0.4), ease: 'power3.out' }, 0);
            tl.to(inner, { opacity: 0, duration: dur(0.25), ease: 'power1.out' }, 0);
            tl.to(bar, { height: 0, duration: dur(0.5), ease: 'power3.inOut' }, dur(0.08));
          };

          /* ---------- wiring ---------- */

          applyType(bar.getAttribute('data-type') || 'info');
          if (progress) gsap.set(progress, { scaleX: 0 });

          if (prevBtn) addHandler(prevBtn, 'click', function () { step(-1); });
          if (nextBtn) addHandler(nextBtn, 'click', function () { step(1); });
          if (closeBtn) addHandler(closeBtn, 'click', function () { dismiss(); });

          addHandler(bar, 'mouseenter', function () { hovering = true; syncTimer(); });
          addHandler(bar, 'mouseleave', function () { hovering = false; syncTimer(); });
          addHandler(bar, 'focusin', function () { focused = true; syncTimer(); });
          addHandler(bar, 'focusout', function (e) {
            if (bar.contains(e.relatedTarget)) return;
            focused = false;
            syncTimer();
          });
          addHandler(document, 'visibilitychange', function () { syncTimer(); });

          addHandler(bar, 'keydown', function (e) {
            if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); }
            else if (e.key === 'ArrowRight') { e.preventDefault(); step(1); }
            else if (e.key === 'Escape') { e.preventDefault(); dismiss(); }
          });

          // Swipe on touch: net displacement, more sideways than down, so a
          // vertical scroll that starts on the bar is left to the page
          // (touch-action: pan-y in the CSS) and a wobbly tap is still a tap.
          if (isCoarse) {
            var startX = 0, startY = 0, tracking = false;
            addHandler(viewport, 'pointerdown', function (e) {
              if (e.pointerType === 'mouse') return;
              tracking = true; startX = e.clientX; startY = e.clientY;
            });
            addHandler(viewport, 'pointerup', function (e) {
              if (!tracking) return;
              tracking = false;
              var dx = e.clientX - startX;
              var dy = e.clientY - startY;
              if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) step(dx < 0 ? 1 : -1);
            });
            addHandler(viewport, 'pointercancel', function () { tracking = false; });
          }

          // Initial state. A remembered dismissal keeps the bar out of the
          // page entirely; otherwise it pushes in.
          gsap.set(bar, { height: 0 });
          if (remembered()) {
            bar.hidden = true;
            bar.classList.add('is-dismissed');
            items.forEach(function (li, i) { activate(li, i === index); });
          } else {
            show();
          }

          var api = {
            element: bar,
            show: show,
            dismiss: dismiss,
            next: function () { step(1); },
            prev: function () { step(-1); },
            go: go,
            pause: function () { userPaused = true; syncTimer(); },
            resume: function () { userPaused = false; syncTimer(); },
            setType: applyType,
            forget: forget,
            index: function () { return index; },
            isOpen: function () { return isOpen; }
          };

          instances.push({
            bar: bar,
            teardown: function () {
              killTimer();
              gsap.set(bar, { clearProps: 'height,overflow' });
              bar.hidden = false;
              bar.classList.remove('is-open', 'is-dismissed', 'is-sticky');
            }
          });
          return api;
        };

        var bars = document.querySelectorAll('[data-announcement-bar]');
        var first = null;
        bars.forEach(function (bar) {
          var api = createBar(bar);
          if (api && !first) first = api;
        });
        window.announcementBar = first;

        return function cleanup() {
          handlers.forEach(function (entry) {
            entry[0].removeEventListener(entry[1], entry[2], entry[3]);
          });
          handlers.length = 0;
          instances.forEach(function (instance) { instance.teardown(); });
          instances.length = 0;
          window.announcementBar = null;
        };
      });
    });

    window.gsapContext = ctx;

    /* revert(), never kill(): kill() drops the context without running the
       cleanup above, so the listeners and inline styles would stay behind. */
    window.addEventListener('beforeunload', function teardown() {
      if (ctx) ctx.revert();
    }, { once: true });
  });
})();
!function(){var e;e=function(){if("undefined"!=typeof gsap){document.documentElement.classList.add("has-js");var e=["info","promo","warning","danger"],t=gsap.context(function(){gsap.matchMedia().add({isFine:"(pointer: fine)",isCoarse:"(pointer: coarse)",isMotion:"(prefers-reduced-motion: no-preference)",isReduced:"(prefers-reduced-motion: reduce)"},function(t){var n=t.conditions.isCoarse,i=t.conditions.isReduced,o=[],a=[],r=function(e,t,n,i){e.addEventListener(t,n,i),o.push([e,t,n,i])},s=function(e){return i?0:e},c=function(t){var o=t.querySelector("[data-messages]"),c=o?Array.prototype.slice.call(o.querySelectorAll("li")):[];if(!o||!c.length)return null;var u=t.querySelector("[data-inner]")||t.firstElementChild,l=t.querySelector("[data-prev]"),d=t.querySelector("[data-next]"),f=t.querySelector("[data-close]"),p=t.querySelector("[data-progress]"),g=f?f.querySelector("svg")||f:null,h=Math.max(1e3,Number(t.dataset.interval)||5e3),m=t.dataset.storageKey||"",y="true"===t.dataset.sticky,v=0,b=!1,w=!1,x=null,A=null,E=!1,S=!1,k=!1;c.forEach(function(e){!function(e){if("true"!==e.dataset.split){var t=e.textContent.trim().split(/\s+/);e.textContent="",t.forEach(function(n,i){var o=document.createElement("span");o.className="ann-bar__word",o.textContent=n,e.appendChild(o),i<t.length-1&&e.appendChild(document.createTextNode(" "))}),e.dataset.split="true"}}(e.querySelector("[data-text]")||e),e.setAttribute("aria-hidden","true"),gsap.set(e,{yPercent:0,y:0,rotation:0,opacity:1})});var L=function(e,n){t.dispatchEvent(new CustomEvent("announcement:"+e,{bubbles:!0,detail:n||{}}))},C=function(n){var i=e.indexOf(n)>-1?n:"info";t.setAttribute("data-type",i),"danger"===i?(o.setAttribute("role","alert"),o.setAttribute("aria-live","assertive")):(o.setAttribute("role","status"),o.setAttribute("aria-live","polite")),o.setAttribute("aria-atomic","true")},q=function(e){return e.querySelectorAll(".ann-bar__word")},P=function(e,t){e.classList.toggle("is-active",t),e.setAttribute("aria-hidden",t?"false":"true"),Array.prototype.forEach.call(function(e){return e.querySelectorAll("a[href], button")}(e),function(e){t?e.removeAttribute("tabindex"):e.setAttribute("tabindex","-1")})},O=function(){return b&&!E&&!S&&!k&&!document.hidden&&c.length>1},T=function(){A&&(A.kill(),A=null),p&&gsap.set(p,{scaleX:0})},M=function(){if(T(),!(c.length<2)&&b){var e=p||{};A=gsap.fromTo(e,{scaleX:0},{scaleX:1,duration:h/1e3,ease:"none",paused:!O(),onComplete:function(){D(1)}})}},X=function(){A&&(O()?A.play():A.pause())},B=function(e,n,o){var a=c[v],r=c[e];if(a!==r){w=!0;var u=n<0?-1:1;gsap.killTweensOf([a,r,q(a),q(r)]),P(r,!0),gsap.set(r,{yPercent:0,y:0,rotation:0,opacity:1}),gsap.set(q(r),{yPercent:i?0:115*u,y:0,opacity:i?1:0});var l=gsap.timeline({onComplete:function(){if(P(a,!1),gsap.set(a,{yPercent:0,y:0,rotation:0,opacity:1}),gsap.set(q(a),{yPercent:0,y:0,opacity:1}),w=!1,L("change",{index:v=e,type:t.getAttribute("data-type")}),o&&o(),null!==x){var n=x;x=null,D(n)}}});l.to(a,{yPercent:-110*u,rotation:-2*u,opacity:0,duration:s(.55),ease:"expo.inOut"},0),l.to(q(r),{yPercent:0,opacity:1,duration:s(.55),ease:"expo.inOut",stagger:{amount:s(.2)}},s(.1))}else o&&o()},D=function(e){if(b&&!(c.length<2))if(w)x=e;else{var t=(v+e+c.length)%c.length;B(t,e,M)}},N=function(){if(!b){b=!0,t.hidden=!1,t.classList.remove("is-dismissed"),t.classList.add("is-open"),y&&t.classList.remove("is-sticky"),c.forEach(function(e,t){P(e,t===v)});var e=c[v],n=q(e);gsap.killTweensOf([t,u,n,g].filter(Boolean)),g&&gsap.set(g,{rotation:0}),gsap.set(t,{height:"auto",overflow:"hidden"});var o=t.offsetHeight;gsap.set(t,{height:0}),gsap.set(u,{opacity:1}),gsap.set(n,{yPercent:i?0:115,y:0,opacity:i?1:0});var a=gsap.timeline({onComplete:function(){gsap.set(t,{height:"auto",clearProps:"overflow"}),y&&t.classList.add("is-sticky"),M(),L("show",{index:v})}});a.to(t,{height:o,duration:s(.7),ease:"power3.out"},0),a.to(n,{yPercent:0,opacity:1,duration:s(.6),ease:"power3.out",stagger:{amount:s(.22)}},s(.28))}},_=function(){if(b){b=!1,T(),function(){if(m)try{window.localStorage.setItem(m,"dismissed")}catch(e){}}(),function(){if(t.contains(document.activeElement)){for(var e=document.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'),n=null,i=0;i<e.length;i+=1)if(!t.contains(e[i])&&null!==e[i].offsetParent){n=e[i];break}n?n.focus({preventScroll:!0}):document.activeElement.blur()}}(),t.classList.remove("is-sticky"),gsap.killTweensOf([t,u,g].filter(Boolean)),gsap.set(t,{height:t.offsetHeight,overflow:"hidden"});var e=gsap.timeline({onComplete:function(){t.classList.remove("is-open"),t.classList.add("is-dismissed"),t.hidden=!0,gsap.set(t,{height:0}),L("dismiss",{index:v})}});g&&e.to(g,{rotation:90,duration:s(.4),ease:"power3.out"},0),e.to(u,{opacity:0,duration:s(.25),ease:"power1.out"},0),e.to(t,{height:0,duration:s(.5),ease:"power3.inOut"},s(.08))}};if(C(t.getAttribute("data-type")||"info"),p&&gsap.set(p,{scaleX:0}),l&&r(l,"click",function(){D(-1)}),d&&r(d,"click",function(){D(1)}),f&&r(f,"click",function(){_()}),r(t,"mouseenter",function(){E=!0,X()}),r(t,"mouseleave",function(){E=!1,X()}),r(t,"focusin",function(){S=!0,X()}),r(t,"focusout",function(e){t.contains(e.relatedTarget)||(S=!1,X())}),r(document,"visibilitychange",function(){X()}),r(t,"keydown",function(e){"ArrowLeft"===e.key?(e.preventDefault(),D(-1)):"ArrowRight"===e.key?(e.preventDefault(),D(1)):"Escape"===e.key&&(e.preventDefault(),_())}),n){var I=0,R=0,H=!1;r(o,"pointerdown",function(e){"mouse"!==e.pointerType&&(H=!0,I=e.clientX,R=e.clientY)}),r(o,"pointerup",function(e){if(H){H=!1;var t=e.clientX-I,n=e.clientY-R;Math.abs(t)>40&&Math.abs(t)>Math.abs(n)&&D(t<0?1:-1)}}),r(o,"pointercancel",function(){H=!1})}gsap.set(t,{height:0}),function(){if(!m)return!1;try{return"dismissed"===window.localStorage.getItem(m)}catch(e){return!1}}()?(t.hidden=!0,t.classList.add("is-dismissed"),c.forEach(function(e,t){P(e,t===v)})):N();var Y={element:t,show:N,dismiss:_,next:function(){D(1)},prev:function(){D(-1)},go:function(e){if(b){var t=((Number(e)||0)%c.length+c.length)%c.length;t!==v?w?x=t-v:B(t,t>v?1:-1,M):M()}},pause:function(){k=!0,X()},resume:function(){k=!1,X()},setType:C,forget:function(){if(m)try{window.localStorage.removeItem(m)}catch(e){}},index:function(){return v},isOpen:function(){return b}};return a.push({bar:t,teardown:function(){T(),gsap.set(t,{clearProps:"height,overflow"}),t.hidden=!1,t.classList.remove("is-open","is-dismissed","is-sticky")}}),Y},u=document.querySelectorAll("[data-announcement-bar]"),l=null;return u.forEach(function(e){var t=c(e);t&&!l&&(l=t)}),window.announcementBar=l,function(){o.forEach(function(e){e[0].removeEventListener(e[1],e[2],e[3])}),o.length=0,a.forEach(function(e){e.teardown()}),a.length=0,window.announcementBar=null}})});window.gsapContext=t,window.addEventListener("beforeunload",function(){t&&t.revert()},{once:!0})}else console.warn("[announcement-banner-bar] GSAP is required but was not found.")},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",e,{once:!0}):e()}();
* { box-sizing: border-box; margin: 0; padding: 0; }

/* ---------------------------------------------------------------------
   Themes. Every colour, radius and shadow the bar (and the demo ground)
   uses is a custom property, so a theme is one block of values. Set
   data-variant on <body> in the demo, or on any ancestor of the bar in
   your own page. Nothing in assets/script.js reads the theme name.

   `light` is the default; `dark` is the same design at the other end of
   the same tokens, not a second design.

   The four type accents (--type-info / promo / warning / danger) are picked
   up by [data-type] on the bar and become --bar-accent, which colours the
   pip, the progress fill and the CTA.
   --------------------------------------------------------------------- */
body,
body[data-variant="light"] {
  color-scheme: light;
  /* Ground: the page the bar sits on. */
  --ground: #f2f2f1;
  --ground-2: #e9e9e7;
  --raised: #fbfbfa;
  --hover: #e2e2df;
  --ink: #141412;
  --ink-2: #4b4b47;
  --ink-3: #676762;
  --line: rgba(20, 20, 18, 0.12);
  --line-strong: rgba(20, 20, 18, 0.22);
  --accent: #635bff;
  --accent-ink: #ffffff;
  --accent-wash: #e7e5fc;
  --shadow: 0 1px 2px rgba(20, 20, 18, 0.06), 0 4px 12px rgba(20, 20, 18, 0.06);
  --font: 'Inter', system-ui, -apple-system, sans-serif;
  --radius: 10px;
  --radius-sm: 6px;
  --radius-xs: 4px;

  /* The bar itself. */
  --bar-bg: var(--raised);
  --bar-rule: 1px solid var(--line);
  --msg-size: 0.875rem;
  --msg-weight: 500;
  --progress-track: rgba(20, 20, 18, 0.10);
  --type-info: #4f46d6;
  --type-promo: #1c6b47;
  --type-warning: #7d5405;
  --type-danger: #b3261e;
}

/* Dark: the same tokens re-valued. Not a second design. */
body[data-variant="dark"] {
  color-scheme: dark;
  --ground: #141414;
  --ground-2: #0f0f0f;
  --raised: #1c1c1c;
  --hover: #232323;
  --ink: #f1efea;
  --ink-2: #c9c7c1;
  --ink-3: #9a988f;
  --line: rgba(241, 239, 234, 0.12);
  --line-strong: rgba(241, 239, 234, 0.22);
  --accent: #9a93ff;
  --accent-ink: #141414;
  --accent-wash: #232043;
  --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.4);
  --progress-track: rgba(241, 239, 234, 0.14);
  --type-info: #9a93ff;
  --type-promo: #61c495;
  --type-warning: #dcae55;
  --type-danger: #f0938c;
}

html { overflow-x: clip; }

body {
  min-height: 100svh;
  background: var(--ground);
  color: var(--ink);
  font-family: var(--font);
  font-size: 0.875rem;
  line-height: 1.45;
  overflow-x: clip;
  -webkit-font-smoothing: antialiased;
  padding-top: 44px;   /* the demo toolbar strip; delete it in your own page */
}
::selection { background: var(--accent); color: var(--accent-ink); }

/* ---------------------------------------------------------------------
   The component. It sits in normal flow at the top of the page, so when its
   height tweens the content below genuinely moves.
   --------------------------------------------------------------------- */
.ann-bar {
  position: relative;
  z-index: 50;
  width: 100%;
  background: var(--bar-bg);
  color: var(--ink);
  border-bottom: var(--bar-rule);
  --bar-accent: var(--type-info);
}
.ann-bar[data-type="promo"]   { --bar-accent: var(--type-promo); }
.ann-bar[data-type="warning"] { --bar-accent: var(--type-warning); }
.ann-bar[data-type="danger"]  { --bar-accent: var(--type-danger); }

/* Pre-hidden start state, gated under .has-js so a page without JavaScript
   shows the bar in full. The script tweens the height in; overflow is
   clipped only while it is closed or moving, so focus rings are never cut. */
.has-js .ann-bar:not(.is-open) { height: 0; overflow: hidden; }
.ann-bar[hidden] { display: none; }
.ann-bar.is-sticky { position: sticky; top: 0; }

.ann-bar__inner {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto auto;
  align-items: center;
  gap: 0.35rem;
  padding: 0.5rem 0.6rem 0.5rem 0.9rem;
  min-height: 44px;
}

/* Prev / next / close share one button style. */
.ann-bar__btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  flex: none;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--ink-3);
  cursor: pointer;
  font: inherit;
}
.ann-bar__btn svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
.ann-bar__btn:hover { background: var(--hover); color: var(--ink); }
.ann-bar__btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; background: var(--hover); color: var(--ink); }
.ann-bar__close { margin-left: 0.15rem; }

/* The mask. Messages stack in one grid cell; only the active one is shown.
   Vertical page scroll stays native on touch; a sideways swipe steps. */
.ann-bar__viewport {
  overflow: hidden;
  min-width: 0;
  touch-action: pan-y;
}
.ann-bar__list {
  list-style: none;
  display: grid;
}
.ann-bar__msg {
  grid-area: 1 / 1;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
  min-width: 0;
  padding: 0.15rem 0.25rem;
  visibility: hidden;
  transform-origin: 0 50%;   /* origin only: GSAP owns the transform */
  font-weight: var(--msg-weight);
  font-size: var(--msg-size);
  line-height: 1.5;
}
.ann-bar__msg.is-active { visibility: visible; }
/* Without JavaScript nothing rotates, so every message is listed in full
   rather than one shown and the rest lost. */
html:not(.has-js) .ann-bar__msg { grid-area: auto; visibility: visible; }

.ann-bar__pip {
  flex: none;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--bar-accent);
}

.ann-bar__text {
  min-width: 0;
  flex: 0 1 auto;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  color: var(--ink);
}
.ann-bar__word {
  display: inline-block;   /* so each word can move on its own */
}

.ann-bar__cta {
  flex: none;
  display: inline-flex;
  align-items: center;
  min-height: 26px;
  padding: 0 0.5rem;
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--bar-accent);
  font-weight: 600;
  font-size: 0.8125rem;
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
  white-space: nowrap;
}
.ann-bar__cta:hover { text-decoration-thickness: 2px; }
.ann-bar__cta:focus-visible { outline: 2px solid var(--bar-accent); outline-offset: 2px; }

/* Time to the next message. GSAP scales it from the left. */
.ann-bar__progress {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 2px;
  background: var(--progress-track);
}
.ann-bar__progress span {
  display: block;
  height: 100%;
  background: var(--bar-accent);
  transform-origin: 0 50%;
}

/* Coarse pointers: swipe replaces the arrows; the CTA and close grow to
   real tap targets, and the message stays one line. */
@media (pointer: coarse) {
  .ann-bar__inner {
    grid-template-columns: minmax(0, 1fr) auto;
    gap: 0.25rem;
    padding: 0.5rem 0.5rem 0.5rem 0.9rem;
    min-height: 56px;
  }
  .ann-bar__prev, .ann-bar__next { display: none; }
  .ann-bar__close { width: 44px; height: 44px; margin-left: 0; }
  .ann-bar__cta { min-height: 44px; padding: 0 0.5rem; }
  .ann-bar__msg { justify-content: flex-start; gap: 0.55rem; }
}
@media (max-width: 520px) {
  .ann-bar__msg { font-size: calc(var(--msg-size) * 0.92); }
}

/* ---------------------------------------------------------------------
   Demo furniture. A bar has to sit on a page to be judged, so the demo
   puts a short document under it: a heading and a few paragraphs, tall
   enough to scroll. It is not a site — no nav, no hero, no brand — and it
   is not part of the component. Delete the .stage-* rules and the markup
   under the bar in your own page. Its colours are the same ground tokens
   the bar uses, so the theme toggle flips both together.
   --------------------------------------------------------------------- */
.stage-doc {
  max-width: 640px;
  margin: 0 auto;
  padding: 40px 20px 96px;
  display: flex;
  flex-direction: column;
  gap: 14px;
  color: var(--ink-2);
}
.stage-doc h1 { font-size: 1.375rem; font-weight: 600; letter-spacing: -0.015em; color: var(--ink); }
.stage-doc h2 { font-size: 1rem; font-weight: 600; color: var(--ink); margin-top: 12px; }
.stage-doc p { max-width: 62ch; }

/* ---------------------------------------------------------------------
   Reduced motion: the bar mounts open in its final state, messages cut
   instead of sliding, and the progress track is hidden (the interval still
   runs, so the messages still rotate).
   --------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
  .has-js .ann-bar:not(.is-open) { height: auto; }
  .has-js .ann-bar.is-dismissed { height: 0; }
  .ann-bar__progress { display: none; }
  .ann-bar__msg, .ann-bar__word { transition: none; }
  .stage-doc * { transition: none !important; }
}

A top-of-page announcement bar that pushes the page down instead of overlapping it, rotates through the messages you write as list items with a masked line swap and a progress track, and collapses away on dismiss so the page reflows up.

Quick Start

1. Add to your HTML <head>:

<link rel="stylesheet" href="path/to/style.css">

2. Add before the closing </body> tag:

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="path/to/script.js"></script>

3. Add the component markup as the first thing inside <body> (the block marked <!-- Component starts here --> in index.html). Each message is one <li>; the CTA link is optional:

<div class="ann-bar" data-announcement-bar data-type="info" data-interval="5000" role="region" aria-label="Announcements">
  <div class="ann-bar__inner" data-inner>
    <button class="ann-bar__btn ann-bar__prev" type="button" data-prev aria-label="Previous announcement">…</button>
    <div class="ann-bar__viewport" data-messages>
      <ul class="ann-bar__list">
        <li class="ann-bar__msg">
          <span class="ann-bar__pip" aria-hidden="true"></span>
          <span class="ann-bar__text" data-text>Free shipping on every order over £60 until Sunday</span>
          <a class="ann-bar__cta" href="/shipping">See details</a>
        </li>
        <li class="ann-bar__msg">
          <span class="ann-bar__pip" aria-hidden="true"></span>
          <span class="ann-bar__text" data-text>Scheduled maintenance Thursday 02:00 UTC</span>
        </li>
      </ul>
    </div>
    <button class="ann-bar__btn ann-bar__next" type="button" data-next aria-label="Next announcement">…</button>
    <button class="ann-bar__btn ann-bar__close" type="button" data-close aria-label="Dismiss announcement">…</button>
  </div>
  <div class="ann-bar__progress" aria-hidden="true"><span data-progress></span></div>
</div>

The bar pushes in on load. Copy the three SVG icons from index.html, or use your own.

Options

Attribute Values Default What it does
data-type info, promo, warning, danger info Sets the accent (pip, progress fill, CTA) and how the bar is announced
data-interval milliseconds 5000 Time each message is shown before the next
data-storage-key any string none When set, a dismissal is written to localStorage under this key and the bar does not show again on later visits
data-sticky true off After the push-in the bar becomes position: sticky at the top

What's Included

  • index.html: the demo page, the component markup and the light/dark toggle

  • assets/style.css: the component, its four type accents and both themes

  • assets/script.js: readable, commented GSAP core source with the public API and cleanup

  • assets/script.min.js: minified production script

  • Height tween on show and dismiss, so the page below moves rather than being covered

  • Masked line swap with a per-word stagger on the incoming line; instant cut under reduced motion

  • Progress track that is the countdown itself, so pausing holds it where it is

  • Pauses on hover, on focus-within and while the tab is hidden

  • Steps queued during a swap, so fast arrow presses never desync the index

  • Swipe to step on touch; arrows hidden, close and CTA at 44px tap targets

  • Roles: region on the bar, status (polite) or alert on the message viewport, real <button>s with labels

Using It With Your Own Design

What the component needs from your markup: one element carrying data-announcement-bar with, inside it, an element carrying data-messages that contains a <ul> of <li> messages. Everything else is optional and found by hook: data-inner (the row that fades on dismiss; defaults to the bar's first child), data-prev, data-next, data-close, data-progress (the fill that scales) and data-text on the span whose words should stagger (defaults to the whole <li>, so put the CTA in its own element if you drop the span).

What is only the demo's furniture: the short document under the bar (.stage-doc), the 44px toolbar strip across the top of the demo and its script (and the padding-top: 44px on body that reserves it), and the Google Font links. None of it is part of the component; delete all of it.

CSS the component depends on:

  • The bar must be in normal flow at the top of the page (not position: fixed or absolute) or there is nothing to push. Put it before your header.
  • .has-js .ann-bar:not(.is-open) { height: 0; overflow: hidden } is the pre-hidden start state. Keep it: without it the bar paints at full height and then jumps to zero on the first frame. It is gated under .has-js so a page without JavaScript shows the bar in full.
  • Do not put a CSS transform on .ann-bar__msg, .ann-bar__word or [data-progress]; GSAP owns those, and a CSS transform underneath one is read as a pixel offset it never returns from. transform-origin is fine.
  • .ann-bar__viewport must keep overflow: hidden (it is the mask) and touch-action: pan-y (so a vertical scroll that starts on the bar is left to the page).
  • .ann-bar__word { display: inline-block } is what lets each word move on its own.

Themes

Two themes, light (the default) and dark: one design at two sets of token values, not two designs.

<body data-variant="dark">

The attribute can go on <body> or on any ancestor of the bar. Leaving it off gives you light. The demo's toggle and the ?variant=dark URL parameter only set this attribute; neither is part of the component, and nothing in assets/script.js reads the theme name.

Every colour, radius and shadow lives in the two body[data-variant] blocks at the top of assets/style.css, so re-valuing this list is the whole rebrand:

Token What it colours
--ground, --ground-2 The page under the bar (demo furniture)
--raised The bar's own background, via --bar-bg
--hover Button hover on the bar
--ink, --ink-2, --ink-3 Message text, body copy, the arrow and close buttons
--line, --line-strong The hairline under the bar, via --bar-rule
--accent, --accent-ink Focus rings and ::selection
--progress-track The unfilled part of the progress track
--type-info, --type-promo, --type-warning, --type-danger The accent per data-type; --bar-accent derives from them and colours the pip, the progress fill and the CTA
--radius, --radius-sm Corner radii on the controls
--font The face; point it at whatever your page already loads and drop the Google Fonts link
--msg-size, --msg-weight The message type

Both themes use one face, Inter, loaded in the demo from Google Fonts. To re-value for your own brand, edit the values in place rather than adding a third block: a second accent is a second opinion, and the four --type-* colours already carry the bar's status vocabulary.

Keyboard & Accessibility

Key What it does
Tab Reaches the previous, next and close buttons and the current message's CTA (links in hidden messages are out of the tab order)
ArrowLeft / ArrowRight Previous / next message while focus is inside the bar
Escape Dismisses the bar; focus moves to the first focusable element on the page outside it
Enter / Space Activates the focused button or CTA
  • Roles: the bar is role="region" with aria-label="Announcements"; the message viewport is role="status" with aria-live="polite" and aria-atomic="true", switching to role="alert" / assertive when the type is danger. Hidden messages carry aria-hidden="true".
  • Rotation pauses on focus-within, not just on hover, so a keyboard user gets the same reading time a mouse user does. It also pauses while the tab is hidden.
  • Every control has a :focus-visible ring in the bar's current accent.
  • On coarse pointers the arrow buttons hide; a horizontal swipe on the message steps instead, and the close button and CTA are at least 44px tall.
  • Under prefers-reduced-motion: reduce the bar mounts open in its final state, messages swap with an instant cut and the progress track is hidden. The interval still runs.

How It Works

The push is a height tween. The bar sits in normal flow. On show the script sets height: auto, reads offsetHeight, sets the height to zero and tweens it back to the measurement on power3.out; onComplete clears the inline height to auto so a later resize or variant switch reflows naturally. Dismiss runs the same tween to zero on power3.inOut while [data-inner] fades and the close icon rotates 90 degrees, then sets hidden, so an empty bar is never left in the flow. Because the height is real, the page below moves with it.

A masked line swap. Every <li> is stacked in one grid cell inside the viewport, which has overflow: hidden. On a swap the outgoing line tweens yPercent: -110 with a two-degree lean on expo.inOut while the incoming line's word spans rise from yPercent: 115 on a 35ms stagger. aria-hidden and the CTA's tabindex follow the active message. A step requested mid-swap is queued and played after it.

The countdown is the progress track. The time to the next message is a linear fromTo scaling [data-progress] from 0 to 1, whose onComplete steps forward. Pausing the rotation is pausing that tween, so hover, focus and a hidden tab hold the track where it is.

Customisation

  • Timing: data-interval on the bar. Eases and durations are the gsap.to / gsap.fromTo calls in assets/script.js (show, dismiss and swapTo).
  • Colours, fonts, radii and the four type accents are custom properties on the two body[data-variant] blocks in assets/style.css (the table under Themes).
  • Swipe threshold: the 40 px net displacement in the pointerup handler.
  • Message alignment: .ann-bar__msg { justify-content: center }; the coarse-pointer block sets it to flex-start.

Programmatic Control

window.announcementBar is the first bar on the page.

announcementBar.show();       // push in (a remembered dismissal is left in storage; call forget() to clear it)
announcementBar.dismiss();    // collapse; remembered if data-storage-key is set
announcementBar.next();
announcementBar.prev();
announcementBar.go(2);        // by index, wraps
announcementBar.pause();      // hold the rotation until resume()
announcementBar.resume();
announcementBar.setType('danger');   // swaps the accent and the live-region role
announcementBar.forget();     // clear the localStorage key
announcementBar.index();      // current message index
announcementBar.isOpen();

// On an SPA route change, revert the GSAP context:
window.gsapContext.revert();

Events, dispatched on the bar element and bubbling:

const bar = document.querySelector('[data-announcement-bar]');
bar.addEventListener('announcement:show', e => {});                    // detail.index
bar.addEventListener('announcement:change', e => console.log(e.detail.index, e.detail.type));
bar.addEventListener('announcement:dismiss', e => {});                 // detail.index

Requirements

  • GSAP 3.12+ (the demo uses 3.15.0; core only, no plugins)
  • No build step, no framework