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
- Paste the snippet just before
</head>. - Add
data-orbit-highlightto 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-color | The glow colour. Default #8b5cf6. |
data-size | How big the dot is, in px. Default 8. |
data-duration | How long one lap takes. Default 3600 ms. |
data-dots | How many dots, spaced evenly round the border. Default 1. |
data-trail | How long the tail is, in dots. Default 8; 0 for no tail. |
data-reverse | Go 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.
<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><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>