← Overlays

Feature Beacon

A softly pulsing dot marks something new. Tap it and a tip pops out of the dot explaining the feature; press “Got it” and the beacon pops away for good.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-feature-beacon="What it does" to the new button, link or panel, and data-title for the tip’s heading.
  3. Add data-key with a name of your own so it’s remembered once dismissed.

Options

Add any of these attributes to the same element.

data-titleThe tip’s heading.
data-positionWhere the dot sits on the element: top-right, top-left, bottom-right or bottom-left. Default top-right.
data-placementWhich side the tip opens on: bottom or top. Default bottom.
data-colorColour of the dot and button. Default #6a0dad.
data-keyA localStorage key: once dismissed, it never shows again. Without it, it shows on every visit.
data-beacon-returnBring the beacon back this many ms after “Got it”, for demos.
data-autoplayTime the tip stays open, in ms: it opens and closes by itself while in view, until someone touches it.

The dot and tip are added next to the element, not inside it, so a button can carry one; its parent should let them show (no overflow: hidden). The dot is a real button with aria-expanded, focus moves to “Got it” and back, and Esc or a press elsewhere closes the tip. It fires beacondismiss when dismissed. From code: animacious.beacon(el) opens the tip.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Feature Beacon: a softly pulsing dot marks something new. Click the dot
    // and a tip pops out of it explaining the feature; "Got it" closes the tip,
    // the beacon pops away and it stays away next time.
    // Usage: <button data-feature-beacon="Select text and let it suggest a clearer version."
    //                data-title="New: Rewrite">Rewrite</button>
    // Optional: data-title (tip heading), data-position (where the dot sits on
    // the element: "top-right", the default, "top-left", "bottom-right",
    // "bottom-left"), data-placement ("bottom", the default, or "top": which
    // side of the element the tip opens on), data-color (default #6a0dad),
    // data-key (localStorage key: once dismissed it never shows again; leave it
    // out to show it on every visit), data-beacon-return (ms: for demos, the
    // beacon comes back this long after "Got it"), data-autoplay (ms the tip
    // stays open: it opens and closes by itself while in view, until someone
    // touches it).
    // Event: "beacondismiss" on the element after "Got it".
    // Script use: animacious.beacon(el) opens the tip, animacious.beacon(el, false) closes it.
    var DEFAULTS = { position: 'top-right', placement: 'bottom', color: '#6a0dad', autoplay: 2800 };

    var css =
      '.bcn{position:absolute;z-index:6;width:22px;height:22px;margin:0;padding:0;border:0;background:none;cursor:pointer;-webkit-tap-highlight-color:transparent;--bcn-c:#6a0dad}' +
      '.bcn-dot,.bcn-ring{position:absolute;left:50%;top:50%;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;background:var(--bcn-c);pointer-events:none}' +
      '.bcn-dot{box-shadow:0 0 0 2px #fff,0 2px 6px rgba(0,0,0,.25)}' +
      '.bcn-ring{opacity:0}' +
      '.bcn:focus-visible{outline:2px solid var(--bcn-c);outline-offset:2px;border-radius:50%}' +
      '.bcn-tip{position:absolute;z-index:7;box-sizing:border-box;width:max-content;max-width:min(260px,calc(100vw - 32px));padding:14px 16px;border-radius:14px;background:#17141f;color:#fff;' +
      'box-shadow:0 16px 40px rgba(14,10,28,.28);font:500 13px/1.45 system-ui,sans-serif;text-align:left;visibility:hidden;--bcn-ax:24px;--bcn-c:#6a0dad}' +
      '.bcn-tip::before{content:"";position:absolute;left:var(--bcn-ax);top:-6px;width:12px;height:12px;margin-left:-6px;background:inherit;border-radius:2px;transform:rotate(45deg)}' +
      '.bcn-tip.bcn-up::before{top:auto;bottom:-6px}' +
      '.bcn-tip b{display:block;margin-bottom:3px;font-size:14px}' +
      '.bcn-tip p{margin:0 0 10px;opacity:.82}' +
      '.bcn-ok{display:inline-block;padding:6px 14px;border:0;border-radius:99px;background:var(--bcn-c);color:#fff;font:inherit;font-weight:700;cursor:pointer}' +
      '.bcn-ok:focus-visible{outline:2px solid #fff;outline-offset:2px}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var uid = 0;
    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function on(v) { return v !== undefined && v !== 'false'; }
    function seen(key) { try { return key && localStorage.getItem(key); } catch (e) { return null; } }
    function save(key) { try { if (key) localStorage.setItem(key, '1'); } catch (e) { /* private mode */ } }

    function setup(el) {
      var d = el.dataset;
      if (seen(d.key)) return null;
      var deco = !!el.closest('[aria-hidden="true"]');
      var color = d.color || DEFAULTS.color;
      var b = { el: el, open: false, gone: false, deco: deco, pos: d.position || DEFAULTS.position, up: d.placement === 'top' };
      el._bcn = b;
      function mk(tag, cls) {
        var n = document.createElement(deco && tag === 'button' ? 'span' : tag);
        if (n.tagName === 'BUTTON') n.type = 'button';
        n.className = cls;
        n.style.setProperty('--bcn-c', color);
        return n;
      }
      // The beacon and tip sit next to the element (so a button can carry one)
      // and are placed over it.
      var dot = mk('button', 'bcn');
      dot.innerHTML = '<span class="bcn-ring"></span><span class="bcn-ring"></span><span class="bcn-dot"></span>';
      var tip = mk('div', 'bcn-tip');
      if (b.up) tip.classList.add('bcn-up');
      if (d.title) { var t = document.createElement('b'); t.textContent = d.title; tip.appendChild(t); }
      var p = document.createElement('p');
      p.textContent = d.featureBeacon || 'Something new is here.';
      tip.appendChild(p);
      var ok = mk('button', 'bcn-ok');
      ok.textContent = 'Got it';
      tip.appendChild(ok);
      if (!deco) {
        tip.id = 'bcn-tip-' + ++uid;
        tip.setAttribute('role', 'dialog');
        tip.setAttribute('aria-label', d.title || 'New feature');
        dot.setAttribute('aria-label', 'New: ' + (d.title || 'what is this?'));
        dot.setAttribute('aria-expanded', 'false');
        dot.setAttribute('aria-controls', tip.id);
      } else {
        dot.setAttribute('aria-hidden', 'true');
        tip.setAttribute('aria-hidden', 'true');
      }
      el.insertAdjacentElement('afterend', tip);
      el.insertAdjacentElement('afterend', dot);
      b.dot = dot; b.tip = tip; b.ok = ok;
      b.rings = Array.from(dot.querySelectorAll('.bcn-ring'));
      b.core = dot.querySelector('.bcn-dot');
      place(b);
      if ('ResizeObserver' in window) new ResizeObserver(function () { place(b); }).observe(el.parentNode);

      dot.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); b.auto = false; toggle(b, !b.open, true); });
      ok.addEventListener('click', function (e) { e.preventDefault(); dismiss(b, true); });
      tip.addEventListener('keydown', function (e) { if (e.key === 'Escape') { toggle(b, false); if (!b.deco) dot.focus(); } });
      if (!reduced()) {
        b.pulse = anime.animate(b.rings, { scale: [1, 3.2], opacity: [0.55, 0], duration: 1800, delay: anime.stagger(900), loop: true, ease: 'out(2)' });
        new IntersectionObserver(function (entries) {
          if (!b.pulse || b.gone) return;
          if (entries[0].isIntersecting) b.pulse.play(); else b.pulse.pause();
        }).observe(el);
      }
      return b;
    }

    function place(b) {
      if (b.gone) return;
      var el = b.el, dot = b.dot;
      var x = el.offsetLeft + (b.pos.indexOf('left') > -1 ? 0 : el.offsetWidth) - 11;
      var y = el.offsetTop + (b.pos.indexOf('bottom') > -1 ? el.offsetHeight : 0) - 11;
      dot.style.left = x + 'px';
      dot.style.top = y + 'px';
      if (b.open) placeTip(b);
    }

    function placeTip(b) {
      var el = b.el, tip = b.tip;
      var cx = parseFloat(b.dot.style.left) + 11;
      var host = tip.offsetParent || document.body;
      var w = tip.offsetWidth;
      var left = cx - 26;
      // Keep it inside its container and the screen.
      var hostR = host.getBoundingClientRect();
      var maxRight = Math.min(host.clientWidth, document.documentElement.clientWidth - hostR.left) - 8;
      left = Math.max(Math.max(8, 8 - hostR.left), Math.min(left, maxRight - w));
      tip.style.left = left + 'px';
      tip.style.top = (b.up ? el.offsetTop - tip.offsetHeight - 12 : el.offsetTop + el.offsetHeight + 12) + 'px';
      tip.style.setProperty('--bcn-ax', Math.max(14, Math.min(w - 14, cx - left)) + 'px');
      tip.style.transformOrigin = (cx - left) + 'px ' + (b.up ? '100%' : '0');
    }

    function toggle(b, open, byUser) {
      if (b.gone || open === b.open) return;
      b.open = open;
      var tip = b.tip;
      if (!b.deco) b.dot.setAttribute('aria-expanded', open);
      anime.utils.remove([tip, b.core]);
      if (open) {
        tip.style.visibility = 'visible';
        placeTip(b);
        if (byUser && !b.deco) setTimeout(function () { if (b.open) b.ok.focus({ preventScroll: true }); }, 60);
        if (reduced()) return anime.utils.set(tip, { opacity: 1, scale: 1 });
        anime.animate(b.core, { scale: [1, 0.7, 1], duration: 360, ease: 'out(2)' });
        anime.animate(tip, { opacity: [0, 1], scale: [0.4, 1], y: [b.up ? 8 : -8, 0], duration: 520, ease: 'outBack(1.7)' });
        anime.animate(tip.children, { opacity: [0, 1], y: [6, 0], duration: 380, delay: anime.stagger(60, { start: 140 }), ease: 'out(3)' });
        return;
      }
      if (tip.contains(document.activeElement) && !b.deco) b.dot.focus({ preventScroll: true });
      var done = function () { if (!b.open) tip.style.visibility = 'hidden'; };
      if (reduced()) return done();
      anime.animate(tip, { opacity: 0, scale: 0.6, duration: 220, ease: 'in(2)', onComplete: done });
    }

    function dismiss(b, byUser) {
      var d = b.el.dataset;
      var hadFocus = b.tip.contains(document.activeElement);
      toggle(b, false);
      if (!b.byAuto) {
        b.gone = true;
        save(d.key);
        if (hadFocus && !b.deco && b.el.focus) b.el.focus({ preventScroll: true });
        b.el.dispatchEvent(new CustomEvent('beacondismiss', { bubbles: true }));
        if (b.pulse) b.pulse.pause();
        var hide = function () { b.dot.style.visibility = 'hidden'; };
        if (reduced()) hide();
        else anime.animate(b.dot, { scale: [1, 1.4, 0], duration: 420, delay: 120, ease: 'in(2)', onComplete: hide });
        if (d.beaconReturn !== undefined) setTimeout(function () {
          b.gone = false;
          b.dot.style.visibility = 'visible';
          place(b);
          if (b.pulse) b.pulse.play();
          if (!reduced()) anime.animate(b.dot, { scale: [0, 1], duration: 600, ease: 'outBack(3)' });
        }, +d.beaconReturn || 2000);
      }
    }

    // Demo mode: the tip opens and closes by itself.
    function autoplay(b, hold) {
      var timers = [], visible = false;
      b.auto = true;
      function later(fn, ms) { timers.push(setTimeout(function () { if (b.auto && visible) fn(); }, ms)); }
      function run() {
        timers.forEach(clearTimeout); timers = [];
        later(function () { toggle(b, true); }, 1400);
        later(function () {
          if (!reduced()) anime.animate(b.ok, { scale: [1, 0.9, 1], duration: 240 });
          b.byAuto = true; dismiss(b); b.byAuto = false;
        }, 1400 + hold);
        later(run, 2400 + hold);
      }
      b.el.parentNode.addEventListener('pointerdown', function (e) { if (e.isTrusted) { b.auto = false; timers.forEach(clearTimeout); } });
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible && b.auto && !b.open) run();
        else if (!visible) timers.forEach(clearTimeout);
      }, { threshold: 0.4 }).observe(b.el);
    }

    // A press outside an open tip closes it (without dismissing the beacon).
    document.addEventListener('pointerdown', function (e) {
      if (!e.isTrusted) return;
      document.querySelectorAll('.bcn-tip').forEach(function (tip) {
        var el = tip.previousElementSibling && tip.previousElementSibling.previousElementSibling;
        var b = el && el._bcn;
        if (b && b.open && !tip.contains(e.target) && !b.dot.contains(e.target)) toggle(b, false);
      });
    });

    function init() {
      document.querySelectorAll('[data-feature-beacon]').forEach(function (el) {
        if (el._bcn !== undefined) return;
        el._bcn = null;
        var b = setup(el);
        if (b && on(el.dataset.autoplay) && !reduced()) autoplay(b, +el.dataset.autoplay || DEFAULTS.autoplay);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.beacon = function (el, open) {
      var b = el._bcn || setup(el);
      if (b) toggle(b, open !== false, true);
    };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<button data-feature-beacon="Select some text and it suggests a clearer version."
        data-title="New: Rewrite"
        data-key="beacon-rewrite">✨ Rewrite</button>