← Attention

Orbit Highlight

A small glowing dot, trailing a fading tail, runs round and round the element’s border, hugging its rounded corners. A quiet way to mark the option you recommend.

Most popularPro£12/monthUnlimited projectsPriority support

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-orbit-highlight to any element with a border or background. The dot starts circling when it scrolls into view.

Options

Add any of these attributes to the same element.

data-colorThe glow colour. Default #8b5cf6.
data-sizeHow big the dot is, in px. Default 8.
data-durationHow long one lap takes. Default 3600 ms.
data-dotsHow many dots, spaced evenly round the border. Default 1.
data-trailHow long the tail is, in dots. Default 8; 0 for no tail.
data-reverseGo round anticlockwise.

The dot follows the middle of the border and the element’s border-radius, and keeps up if the element changes size. It’s added inside the element, so don’t clip the element’s overflow.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Orbit Highlight: a small glowing dot, with a fading tail, circles the
    // element's border, following its rounded corners. It pauses while off screen.
    // Usage: <div data-orbit-highlight>…</div>
    // Optional: data-color (glow colour, default #8b5cf6), data-size (px of the
    // dot, default 8), data-duration (ms per lap, default 3600), data-dots (how
    // many dots, spaced evenly, default 1), data-trail (length of the tail in
    // dots, default 8, 0 for none), data-reverse (go anticlockwise).
    // The dot is added inside the element, so don't clip its overflow.
    var DEFAULTS = { color: '#8b5cf6', size: 8, duration: 3600, dots: 1, trail: 8 };

    var css =
      '.obh-dot{position:absolute;left:0;top:0;border-radius:50%;pointer-events:none;z-index:2;will-change:transform}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // Measure the line running down the middle of the border, corners included.
    function measure(el) {
      var cs = getComputedStyle(el);
      var bl = parseFloat(cs.borderLeftWidth) || 0;
      var bt = parseFloat(cs.borderTopWidth) || 0;
      var w = el.offsetWidth - bl, h = el.offsetHeight - bt;
      var r = Math.max(0, (parseFloat(cs.borderTopLeftRadius) || 0) - bl / 2);
      r = Math.min(r, w / 2, h / 2);
      var sw = w - 2 * r, sh = h - 2 * r, arc = Math.PI * r / 2;
      return { x: -bl / 2, y: -bt / 2, w: w, h: h, r: r, sw: sw, sh: sh, arc: arc, len: 2 * sw + 2 * sh + 4 * arc };
    }

    // The point a given distance round the border, clockwise from the top-left corner's end.
    function pointAt(m, dist) {
      var d = ((dist % m.len) + m.len) % m.len;
      var x = m.x, y = m.y, r = m.r;
      function corner(cx, cy, from, t) { var a = m.arc ? from + (t / m.arc) * (Math.PI / 2) : from; return [cx + r * Math.cos(a), cy + r * Math.sin(a)]; }
      if (d < m.sw) return [x + r + d, y];
      d -= m.sw;
      if (d < m.arc) return corner(x + m.w - r, y + r, -Math.PI / 2, d);
      d -= m.arc;
      if (d < m.sh) return [x + m.w, y + r + d];
      d -= m.sh;
      if (d < m.arc) return corner(x + m.w - r, y + m.h - r, 0, d);
      d -= m.arc;
      if (d < m.sw) return [x + m.w - r - d, y + m.h];
      d -= m.sw;
      if (d < m.arc) return corner(x + r, y + m.h - r, Math.PI / 2, d);
      d -= m.arc;
      if (d < m.sh) return [x, y + m.h - r - d];
      d -= m.sh;
      return corner(x + r, y + r, Math.PI, d);
    }

    function setup(el) {
      var d = el.dataset;
      var color = d.color || DEFAULTS.color;
      var size = +d.size || DEFAULTS.size;
      var trail = d.trail === undefined ? DEFAULTS.trail : Math.max(0, +d.trail);
      var count = Math.max(1, +d.dots || DEFAULTS.dots);
      if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
      var s = (el._obh = { dots: [], m: measure(el), p: { t: 0 } });
      for (var c = 0; c < count; c++) {
        for (var k = trail; k >= 0; k--) {
          var dot = document.createElement('span');
          dot.className = 'obh-dot';
          dot.setAttribute('aria-hidden', 'true');
          var f = 1 - k / (trail + 1); // 1 for the head, smaller down the tail
          var sz = k ? size * (0.35 + 0.55 * f) : size;
          dot.style.width = dot.style.height = sz + 'px';
          dot.style.marginLeft = dot.style.marginTop = -sz / 2 + 'px';
          if (k) {
            dot.style.background = color;
            dot.style.opacity = (0.6 * f).toFixed(2);
          } else {
            dot.style.background = '#fff';
            dot.style.boxShadow = '0 0 ' + size * 0.6 + 'px ' + size * 0.25 + 'px ' + color + ',0 0 ' + size * 2 + 'px ' + size * 0.6 + 'px ' + color;
          }
          el.appendChild(dot);
          s.dots.push({ el: dot, lap: c / count, back: k * size * 0.8 });
        }
      }
      var dir = d.reverse !== undefined && d.reverse !== 'false' ? -1 : 1;
      function draw() {
        s.dots.forEach(function (o) {
          var pt = pointAt(s.m, dir * ((s.p.t + o.lap) * s.m.len - o.back));
          o.el.style.transform = 'translate(' + pt[0].toFixed(2) + 'px,' + pt[1].toFixed(2) + 'px)';
        });
      }
      s.draw = draw;
      draw();
      if (reduced()) return;
      s.anim = anime.animate(s.p, { t: 1, duration: +d.duration || DEFAULTS.duration, ease: 'linear', loop: true, autoplay: false, onUpdate: draw });
      new ResizeObserver(function () { s.m = measure(el); draw(); }).observe(el);
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._obh;
          if (!s.anim) return;
          if (e.isIntersecting) s.anim.play();
          else s.anim.pause();
        });
      }, { threshold: 0 });
      document.querySelectorAll('[data-orbit-highlight]').forEach(function (el) {
        if (el._obh) return;
        setup(el);
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div class="plan" data-orbit-highlight>…</div>

<!-- or two gold dots, a little faster: -->
<div class="plan" data-orbit-highlight data-dots="2" data-color="#f59e0b" data-duration="2600">…</div>