← Overlays

Spotlight Tour

An onboarding tour that dims the page around one thing at a time. The spotlight closes in from the edges, then glides and reshapes itself from step to step, with a card alongside that says what’s there.

Search… AR
+ New project

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-spotlight-tour to the area the tour covers (or <body>).
  3. Give each element to point out data-tour="What it does" and, if you like, data-tour-title.
  4. Add data-tour-start="#that-area" to the button that starts it.

Options

Add any of these attributes to the same element.

data-tour-stepOn a step: its place in the tour, if not page order.
data-paddingSpace round each element. Default 8 px.
data-radiusCorner radius of the spotlight. Default 12 px.
data-durationTime to glide between steps. Default 650 ms.
data-containedDim only the tour’s own area, not the whole page.

The card is a modal dialog: focus moves into it, Tab stays inside, the arrow keys step through and Esc ends the tour, with focus going back where it was. Steps off screen are scrolled into view. From code: animacious.tour(area).

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Spotlight Tour: an onboarding tour that dims the page around one
    // element at a time. The spotlight glides from step to step, reshaping
    // itself to each element, with a card beside it that explains what's
    // there. Esc ends the tour; Back, Next and the arrow keys move through it.
    // Usage:
    //   <button data-tour-start="#app">Take the tour</button>
    //   <div id="app" data-spotlight-tour>
    //     <input type="search" data-tour="Find anything from here." data-tour-title="Search">
    //     <nav data-tour="Your projects live here." data-tour-title="Projects">…</nav>
    //   </div>
    // Each element with data-tour inside it is a step, in page order (or set
    // data-tour-step="1", "2"… to choose). data-tour-start can point at the
    // tour; with no value it starts the first one on the page.
    // While it runs, focus stays in the card (a modal dialog), and it goes
    // back to where it was when the tour ends.
    // Optional: data-padding (px of space round each element, default 8),
    // data-radius (px, default 12), data-duration (ms to glide between steps,
    // default 650), data-contained (dim only the tour's own element, not the
    // whole page; give it position: relative), data-autoplay (ms per step,
    // with data-contained: plays the tour on a loop while on screen, for
    // previews).
    // Script use: animacious.tour(el) starts it, animacious.tour(el, false)
    // ends it.
    var DEFAULTS = { padding: 8, radius: 12, duration: 650 };
    var uid = 0;

    var css =
      '.spt{position:fixed;inset:0;z-index:2147482000;--spt-a:0}' +
      '.spt.spt-in{position:absolute;z-index:30;overflow:hidden;border-radius:inherit}' +
      '.spt-shade{position:absolute;inset:0}' +
      '.spt-hole{position:absolute;left:0;top:0;pointer-events:none;box-shadow:0 0 0 200vmax rgba(10,10,25,var(--spt-a))}' +
      '.spt-hole::after{content:"";position:absolute;inset:-2px;border-radius:inherit;border:2px solid rgba(255,255,255,.9)}' +
      '@media (prefers-reduced-motion:no-preference){.spt-hole::before{content:"";position:absolute;inset:0;border-radius:inherit;' +
      'box-shadow:0 0 0 2px #fff;animation:spt-ping 1.8s cubic-bezier(.2,.6,.3,1) infinite}}' +
      '@keyframes spt-ping{from{opacity:.8;transform:scale(1)}to{opacity:0;transform:scale(1.12)}}' +
      '.spt-card{position:absolute;left:0;top:0;width:min(20em,calc(100% - 16px));padding:1.1em 1.2em 1em;border-radius:1em;background:#fff;color:#0a0a0b;' +
      'box-shadow:0 20px 50px rgba(10,10,25,.3);text-align:left;font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;outline:none}' +
      '.spt-count{margin:0 0 .2em;font-size:.75em;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#6a0dad}' +
      '.spt-title{margin:0 0 .25em;font-size:1.1em;font-weight:800;letter-spacing:-.01em;line-height:1.25}' +
      '.spt-text{margin:0;color:#52525b}' +
      '.spt-foot{display:flex;align-items:center;gap:.5em;margin-top:1em}' +
      '.spt-dots{display:flex;gap:.3em;margin-right:auto}' +
      '.spt-dots i{width:.45em;height:.45em;border-radius:50%;background:#e4e0ec;transition:background .3s,width .3s}' +
      '.spt-dots i.on{width:1.2em;border-radius:99px;background:#6a0dad}' +
      '.spt-btn{font:inherit;font-size:.87em;font-weight:600;padding:.45em .95em;border-radius:99px;border:0;background:#f1edf7;color:#0a0a0b;cursor:pointer}' +
      '.spt-btn.spt-next{background:#6a0dad;color:#fff}' +
      '.spt-btn:focus-visible{outline:2px solid #6a0dad;outline-offset:2px}' +
      '.spt-btn[hidden]{display:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function lerp(a, b, t) { return a + (b - a) * t; }

    function steps(root) {
      var list = Array.from(root.querySelectorAll('[data-tour]'));
      return list.map(function (el, i) { return { el: el, i: i, n: +el.dataset.tourStep || 0 }; })
        .sort(function (a, b) { return (a.n - b.n) || (a.i - b.i); })
        .map(function (s) { return s.el; });
    }

    function build(root) {
      var d = root.dataset;
      var contained = d.contained !== undefined && d.contained !== 'false';
      var inThumb = !!root.closest('[aria-hidden="true"]');
      var id = 'spt-' + uid++;
      var o = document.createElement('div');
      o.className = 'spt' + (contained ? ' spt-in' : '');
      var B = inThumb ? 'span' : 'button type="button"', E = inThumb ? 'span' : 'button';
      o.innerHTML =
        '<div class="spt-shade"></div><div class="spt-hole"></div>' +
        '<div class="spt-card"' + (inThumb ? '' : ' role="dialog" aria-modal="true" aria-labelledby="' + id + '-t" aria-describedby="' + id + '-x" tabindex="-1"') + '>' +
        '<div' + (inThumb ? '' : ' aria-live="polite"') + '><p class="spt-count"></p><h2 class="spt-title" id="' + id + '-t"></h2><p class="spt-text" id="' + id + '-x"></p></div>' +
        '<div class="spt-foot"><span class="spt-dots" aria-hidden="true"></span>' +
        '<' + B + ' class="spt-btn spt-skip">Skip</' + E + '><' + B + ' class="spt-btn spt-back">Back</' + E + '><' + B + ' class="spt-btn spt-next">Next</' + E + '></div></div>';
      if (inThumb) o.setAttribute('aria-hidden', 'true');
      var t = {
        root: root, o: o, contained: contained, inThumb: inThumb, on: false, i: -1, list: [], prev: null, anim: null,
        hole: o.querySelector('.spt-hole'), card: o.querySelector('.spt-card'),
        r: null, c: null, pad: d.padding !== undefined ? +d.padding : DEFAULTS.padding, radius: d.radius !== undefined ? +d.radius : DEFAULTS.radius
      };
      o.querySelector('.spt-next').addEventListener('click', function () { go(t, t.i + 1); });
      o.querySelector('.spt-back').addEventListener('click', function () { go(t, t.i - 1); });
      o.querySelector('.spt-skip').addEventListener('click', function () { end(t); });
      // A click on the dimmed page nudges the card instead of closing the tour.
      o.querySelector('.spt-shade').addEventListener('click', function () {
        if (!reduced()) anime.animate(t.card, { scale: [1, 1.04, 1], duration: 320, ease: 'out(2)' });
      });
      o.addEventListener('keydown', function (e) {
        if (e.key === 'Escape') { e.preventDefault(); end(t); }
        else if (e.key === 'ArrowRight' && e.target.tagName !== 'INPUT') { e.preventDefault(); go(t, t.i + 1); }
        else if (e.key === 'ArrowLeft' && e.target.tagName !== 'INPUT') { e.preventDefault(); if (t.i > 0) go(t, t.i - 1); }
        else if (e.key === 'Tab') {
          // Keep focus inside the card.
          var f = Array.from(t.card.querySelectorAll('button:not([hidden])'));
          var at = f.indexOf(document.activeElement);
          e.preventDefault();
          f[(at + (e.shiftKey ? -1 : 1) + f.length) % f.length].focus();
        }
      });
      return t;
    }

    // Where things are, in the overlay's own coordinates.
    function bounds(t) {
      if (!t.contained) return { left: 0, top: 0, width: document.documentElement.clientWidth, height: window.innerHeight };
      return t.root.getBoundingClientRect();
    }
    function holeFor(t, el) {
      var b = bounds(t), r = el.getBoundingClientRect(), p = t.pad;
      return { x: r.left - b.left - p, y: r.top - b.top - p, w: r.width + p * 2, h: r.height + p * 2 };
    }
    function cardFor(t, h) {
      var b = bounds(t), cw = t.card.offsetWidth, ch = t.card.offsetHeight, g = 14;
      var below = h.y + h.h + g, above = h.y - g - ch;
      // Below if it fits, else above, else beside (a tall element).
      if (below + ch > b.height - 8 && above < 8) {
        var right = h.x + h.w + g, left = h.x - g - cw;
        if (right + cw <= b.width - 8 || left >= 8) {
          return {
            x: right + cw <= b.width - 8 ? right : left,
            y: Math.max(8, Math.min(b.height - ch - 8, h.y + h.h / 2 - ch / 2))
          };
        }
      }
      var y = below + ch <= b.height - 8 || above < 8 && b.height - h.y - h.h > h.y ? below : above;
      return {
        x: Math.max(8, Math.min(b.width - cw - 8, h.x + h.w / 2 - cw / 2)),
        y: Math.max(8, Math.min(b.height - ch - 8, y))
      };
    }
    function draw(t, r, c) {
      t.r = r;
      t.c = c;
      t.hole.style.width = r.w + 'px';
      t.hole.style.height = r.h + 'px';
      t.hole.style.transform = 'translate(' + r.x + 'px,' + r.y + 'px)';
      t.hole.style.borderRadius = r.rad + 'px';
      // left/top, since the card's transform is used for its own pop.
      t.card.style.left = c.x + 'px';
      t.card.style.top = c.y + 'px';
    }

    // Glides from wherever the spotlight is now to the step's element. The
    // target is measured live on every frame, so it stays locked on even while
    // the page scrolls it into view.
    function glide(t, el, duration, onDone) {
      var r0 = t.r, c0 = t.c, st = { p: 0 };
      // No card yet (the first step): it appears in place rather than flying in.
      if (t.anim) t.anim.pause();
      var frame = function () {
        var h = holeFor(t, el), c = cardFor(t, h), p = st.p;
        h.rad = t.radius;
        if (!r0) return draw(t, h, c);
        draw(t, { x: lerp(r0.x, h.x, p), y: lerp(r0.y, h.y, p), w: lerp(r0.w, h.w, p), h: lerp(r0.h, h.h, p), rad: lerp(r0.rad, h.rad, p) },
          c0 ? { x: lerp(c0.x, c.x, p), y: lerp(c0.y, c.y, p) } : c);
      };
      if (!duration || !r0) { st.p = 1; frame(); if (onDone) onDone(); return; }
      t.anim = anime.animate(st, { p: 1, duration: duration, ease: 'inOut(3)', onUpdate: frame, onComplete: onDone });
    }

    function fill(t) {
      var el = t.list[t.i], n = t.list.length;
      var q = function (s) { return t.o.querySelector(s); };
      q('.spt-count').textContent = 'Step ' + (t.i + 1) + ' of ' + n;
      q('.spt-title').textContent = el.dataset.tourTitle || '';
      q('.spt-title').hidden = !el.dataset.tourTitle;
      q('.spt-text').textContent = el.dataset.tour;
      q('.spt-back').hidden = t.i === 0;
      q('.spt-skip').hidden = t.i === n - 1;
      q('.spt-next').textContent = t.i === n - 1 ? 'Done' : 'Next';
      q('.spt-dots').innerHTML = t.list.map(function (_, i) { return '<i' + (i === t.i ? ' class="on"' : '') + '></i>'; }).join('');
    }

    function go(t, i) {
      if (!t.on) return;
      if (i >= t.list.length) return end(t);
      if (i < 0 || i === t.i) return;
      t.i = i;
      var el = t.list[i];
      var rm = reduced();
      fill(t);
      if (!rm) anime.animate(t.card.firstChild, { opacity: [0, 1], y: [6, 0], duration: 360, ease: 'out(3)' });
      // Bring the element into view first (a page tour only).
      if (!t.contained) {
        var r = el.getBoundingClientRect(), vh = window.innerHeight;
        if (r.top < 80 || r.bottom > vh - 200) el.scrollIntoView({ block: 'center', behavior: rm ? 'auto' : 'smooth' });
      }
      glide(t, el, rm ? 0 : +t.root.dataset.duration || DEFAULTS.duration);
    }

    function start(root) {
      var t = root._spt || (root._spt = build(root));
      if (t.on) return t;
      t.list = steps(root);
      if (!t.list.length) return t;
      t.on = true;
      t.i = -1;
      (t.contained ? root : document.body).appendChild(t.o);
      if (!t.inThumb) t.prev = document.activeElement;
      // The spotlight starts as big as the page and closes in on the first step.
      var b = bounds(t);
      t.r = { x: -40, y: -40, w: b.width + 80, h: b.height + 80, rad: 0 };
      t.c = null;
      go(t, 0);
      var rm = reduced();
      anime.utils.remove(t.o);
      anime.animate(t.o, { '--spt-a': [0, 0.62], duration: rm ? 0 : 450, ease: 'out(2)' });
      if (!rm) anime.animate(t.card, { opacity: [0, 1], scale: [0.94, 1], duration: 420, delay: 220, ease: 'outBack(1.4)' });
      else anime.utils.set(t.card, { opacity: 1, scale: 1 });
      if (!t.inThumb) t.o.querySelector('.spt-next').focus({ preventScroll: true });
      return t;
    }

    function end(t) {
      if (!t || !t.on) return;
      t.on = false;
      var rm = reduced(), b = bounds(t);
      var done = function () {
        if (t.on) return;
        if (t.o.parentNode) t.o.parentNode.removeChild(t.o);
        t.r = null;
        var p = t.prev;
        t.prev = null;
        if (p && p.focus && document.contains(p)) p.focus({ preventScroll: true });
      };
      if (rm) return done();
      if (t.anim) t.anim.pause();
      // Open back out to the whole page while the dimming lifts.
      var r0 = t.r, st = { p: 0 }, to = { x: -40, y: -40, w: b.width + 80, h: b.height + 80, rad: 0 };
      anime.animate(t.card, { opacity: 0, scale: 0.96, duration: 200, ease: 'in(2)' });
      anime.animate(t.o, { '--spt-a': 0, duration: 420, ease: 'out(2)' });
      t.anim = anime.animate(st, {
        p: 1, duration: 450, ease: 'inOut(2)', onComplete: done,
        onUpdate: function () {
          var p = st.p;
          t.hole.style.width = lerp(r0.w, to.w, p) + 'px';
          t.hole.style.height = lerp(r0.h, to.h, p) + 'px';
          t.hole.style.transform = 'translate(' + lerp(r0.x, to.x, p) + 'px,' + lerp(r0.y, to.y, p) + 'px)';
        }
      });
    }

    function autoplay(root) {
      var every = +root.dataset.autoplay || 2000, timer = null, rest = 0;
      new IntersectionObserver(function (entries) {
        clearInterval(timer);
        if (!entries[0].isIntersecting) { end(root._spt); return; }
        timer = setInterval(function () {
          if (root.closest('[hidden]')) return;
          var t = root._spt;
          if (!t || !t.on) { if (rest-- <= 0) start(root); return; }
          if (t.i >= t.list.length - 1) { end(t); rest = 1; }
          else go(t, t.i + 1);
        }, every);
      }, { threshold: 0.5 }).observe(root);
    }

    function init() {
      document.querySelectorAll('[data-spotlight-tour]').forEach(function (root) {
        if (root._sptInit) return;
        root._sptInit = true;
        var d = root.dataset;
        if (d.contained !== undefined && d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) autoplay(root);
      });
      document.addEventListener('click', function (e) {
        var b = e.target.closest && e.target.closest('[data-tour-start]');
        if (!b) return;
        var sel = b.getAttribute('data-tour-start');
        var root = sel ? document.querySelector(sel) : document.querySelector('[data-spotlight-tour]');
        if (!root) return;
        e.preventDefault();
        start(root);
      });
      // Keep the spotlight on its element when the page scrolls or resizes.
      var frame = 0;
      var follow = function () {
        if (frame) return;
        frame = requestAnimationFrame(function () {
          frame = 0;
          document.querySelectorAll('[data-spotlight-tour]').forEach(function (root) {
            var t = root._spt;
            if (t && t.on && t.i >= 0 && !(t.anim && !t.anim.completed)) glide(t, t.list[t.i], 0);
          });
        });
      };
      document.addEventListener('scroll', follow, { capture: true, passive: true });
      window.addEventListener('resize', follow);
    }

    window.animacious = window.animacious || {};
    window.animacious.tour = function (root, on) { if (on === false) end(root._spt); else start(root); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<button data-tour-start="#app">Take the tour</button>

<div id="app" data-spotlight-tour>
  <input type="search" data-tour-title="Search"
         data-tour="Find anything from here.">
  <nav data-tour-title="Projects"
       data-tour="Your projects live here.">…</nav>
</div>