Back to Top Ring
Scroll a little way down and a round back-to-top button pops up in the corner. The ring round its edge fills as you read, so it doubles as a progress meter. Tap it and the page glides back to the top as the ring drains, and the button tucks itself away.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<button data-back-to-top-ring aria-label="Back to top"></button>anywhere on the page. - The script fills it with the arrow and the ring and pins it to the bottom-right corner. Move it with your own CSS on
.btrif you like.
Options
Add any of these attributes to the same element.
data-target | A selector for a scrolling box to follow instead of the page. |
data-offset | How far to scroll before it appears, in px. Default 160. |
data-color | Colour of the ring. Default #6a0dad. |
data-track | Colour of the unfilled ring. Default #e9dff5. |
data-size | Size of the button, in px. Default 52. |
data-autoplay | Inside a scrolling box, it scrolls down and taps itself while in view, pausing when someone scrolls. Handy for demos. |
The ring is an SVG stroke, so it stays sharp at any size. The button is out of the tab order while hidden. With reduced motion it jumps straight to the top.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Back to Top Ring: a round back-to-top button pops up once you've scrolled
// a little way down. A ring round its edge fills as you scroll, so it
// doubles as a reading-progress meter. Tap it and the page glides back to
// the top while the ring drains, then the button tucks itself away.
// Usage: <button data-back-to-top-ring aria-label="Back to top"></button>
// The script fills it with the arrow and the ring, and pins it to the
// bottom-right corner of the window (class "btr"; override its position
// in your CSS if you like).
// Optional: data-target (a selector for a scrolling box to follow instead of
// the page), data-offset (px scrolled before it appears, default 160),
// data-color (ring colour, default #6a0dad), data-track (colour of the
// unfilled ring, default #e9dff5), data-size (px, default 52),
// data-autoplay (inside a scrolling box, it scrolls down and taps itself
// while in view, pausing when someone scrolls; handy for demos).
var DEFAULTS = { offset: 160, color: '#6a0dad', track: '#e9dff5', size: 52 };
var R = 21, C = 2 * Math.PI * R;
var css =
'.btr{position:fixed;right:24px;bottom:24px;z-index:40;display:grid;place-items:center;padding:0;border:0;border-radius:50%;background:#fff;color:#1f1630;cursor:pointer;-webkit-tap-highlight-color:transparent;box-shadow:0 10px 26px rgba(30,15,60,.18);visibility:hidden;opacity:0}' +
'.btr svg{position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none}' +
'.btr-ring{transform:rotate(-90deg);transform-origin:50% 50%}' +
'.btr-arrow{position:relative;width:38%;height:38%}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// The box to follow: the nearest match of the selector, so two demos on one
// page each follow their own box.
function find(el, sel) {
if (!sel) return null;
for (var p = el.parentElement; p; p = p.parentElement) {
var m = p.querySelector(sel);
if (m) return m;
}
return null;
}
function setup(el) {
var d = el.dataset;
var a11y = !el.closest('[aria-hidden="true"]');
var box = find(el, d.target);
var offset = d.offset !== undefined ? +d.offset : DEFAULTS.offset;
var size = +d.size || DEFAULTS.size;
el.classList.add('btr');
el.style.width = el.style.height = size + 'px';
if (a11y) {
if (el.tagName === 'BUTTON' && !el.getAttribute('type')) el.type = 'button';
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Back to top');
}
el.innerHTML =
'<svg viewBox="0 0 48 48" aria-hidden="true">' +
'<circle cx="24" cy="24" r="' + R + '" fill="none" stroke="' + (d.track || DEFAULTS.track) + '" stroke-width="3"/>' +
'<circle class="btr-ring" cx="24" cy="24" r="' + R + '" fill="none" stroke="' + (d.color || DEFAULTS.color) + '" stroke-width="3" stroke-linecap="round" stroke-dasharray="' + C.toFixed(2) + '" stroke-dashoffset="' + C.toFixed(2) + '"/>' +
'</svg>' +
'<svg class="btr-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M6 11l6-6 6 6"/></svg>';
var ring = el.querySelector('.btr-ring');
var arrow = el.querySelector('.btr-arrow');
var shown = false, raf = 0, queued = false, idleUntil = 0;
function pos() { return box ? box.scrollTop : window.scrollY; }
function max() { return box ? box.scrollHeight - box.clientHeight : document.documentElement.scrollHeight - window.innerHeight; }
function setPos(v) { if (box) box.scrollTop = v; else window.scrollTo(0, v); }
function show(on) {
if (on === shown) return;
shown = on;
anime.utils.remove(el);
if (a11y) el.tabIndex = on ? 0 : -1;
if (on) el.style.visibility = 'visible';
if (reduced()) {
anime.utils.set(el, { opacity: on ? 1 : 0, scale: 1, y: 0 });
if (!on) el.style.visibility = 'hidden';
return;
}
if (on) anime.animate(el, { opacity: [0, 1], scale: [0.4, 1], y: [16, 0], duration: 520, ease: 'outBack(1.8)' });
else anime.animate(el, { opacity: 0, scale: 0.6, y: 12, duration: 260, ease: 'in(2)', onComplete: function () { if (!shown) el.style.visibility = 'hidden'; } });
}
function update() {
queued = false;
var m = max(), y = pos();
var p = m > 0 ? Math.max(0, Math.min(1, y / m)) : 0;
ring.style.strokeDashoffset = (C * (1 - p)).toFixed(2);
show(y > offset);
}
function onScroll() {
if (queued) return;
queued = true;
requestAnimationFrame(update);
}
// Glide back to the top: quick at first, easing into the top.
function toTop() {
var from = pos();
cancelAnimationFrame(raf);
if (reduced() || from <= 0) { setPos(0); return; }
var ms = Math.min(1100, 450 + from * 0.35), t0 = performance.now();
var frame = function (now) {
var t = Math.min(1, (now - t0) / ms);
setPos(from * (1 - (1 - Math.pow(1 - t, 4))));
if (t < 1) raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
if (!reduced()) {
anime.animate(arrow, { y: [0, -5, 0], duration: 420, ease: 'out(3)' });
anime.animate(el, { scale: [0.88, 1], duration: 500, ease: 'outElastic(1, .5)' });
}
}
el.addEventListener('click', function () { idleUntil = Date.now() + 8000; toTop(); });
(box || window).addEventListener('scroll', onScroll, { passive: true });
if (window.ResizeObserver) new ResizeObserver(onScroll).observe(box || document.documentElement);
update();
if (box && d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
var timer = null, visible = false;
var stop = function () { idleUntil = Date.now() + 8000; cancelAnimationFrame(raf); };
['wheel', 'touchstart', 'keydown'].forEach(function (t) { box.addEventListener(t, stop, { passive: true }); });
var glideDown = function (ms) {
var from = pos(), to = max(), t0 = performance.now();
cancelAnimationFrame(raf);
var frame = function (now) {
var t = Math.min(1, (now - t0) / ms);
setPos(from + (to - from) * (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2));
if (t < 1) raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
};
var tick = function () {
if (!visible) return;
var wait = 1500;
if (Date.now() > idleUntil && !(window.matchMedia('(hover: hover)').matches && box.matches(':hover'))) {
// Read down the page, pause at the bottom, then tap the button.
if (pos() < max() - 2) { glideDown(3000); wait = 3900; }
else { toTop(); wait = 1900; }
}
timer = setTimeout(tick, wait);
};
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
clearTimeout(timer);
if (visible) timer = setTimeout(tick, 700);
else cancelAnimationFrame(raf);
}).observe(box);
}
}
function init() {
document.querySelectorAll('[data-back-to-top-ring]').forEach(function (el) {
if (el._btr) return;
el._btr = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><button data-back-to-top-ring aria-label="Back to top"></button>
<!-- bigger, in teal: -->
<button data-back-to-top-ring data-size="64" data-color="#0f766e" aria-label="Back to top"></button>