Sleek top/bottom navigation buttons: click to jump, hover to scroll continuously
// ==UserScript==
// @name Top/Bottom Navigation Buttons
// @namespace https://openuserjs.org/
// @version 2.5.1
// @description Sleek top/bottom navigation buttons: click to jump, hover to scroll continuously
// @author r3dhack3r
// @license MIT
// @match *://*/*
// @grant none
// ==/UserScript==
(function () {
'use strict';
// ─── Configuration ────────────────────────────────────────────────────────────
const CFG = {
scrollThresholdTop: 200,
scrollThresholdBottom: 100,
hoverScrollSpeed: 15,
hoverScrollInterval: 16,
clickScrollDuration: 800,
};
// ─── Styles ───────────────────────────────────────────────────────────────────
const CSS = `
:root {
--nb-bg: rgba(255, 255, 255, 0.90);
--nb-bg-hover: rgba(255, 255, 255, 1);
--nb-bg-active: rgba(240, 242, 245, 1);
--nb-border: rgba(0, 0, 0, 0.08);
--nb-border-hover: rgba(0, 0, 0, 0.15);
--nb-shadow: 0 2px 10px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.05);
--nb-shadow-hover: 0 4px 20px rgba(0,0,0,0.10), 0 2px 5px rgba(0,0,0,0.06);
--nb-icon: #4b5563;
--nb-icon-hover: #111827;
--nb-size: 40px;
--nb-gap: 8px;
--nb-radius: 10px;
}
@media (prefers-color-scheme: dark) {
:root {
--nb-bg: rgba(30, 30, 36, 0.90);
--nb-bg-hover: rgba(42, 42, 50, 1);
--nb-bg-active: rgba(52, 52, 62, 1);
--nb-border: rgba(255,255,255,0.08);
--nb-border-hover: rgba(255,255,255,0.15);
--nb-shadow: 0 2px 10px rgba(0,0,0,0.30), 0 1px 3px rgba(0,0,0,0.20);
--nb-shadow-hover: 0 4px 20px rgba(0,0,0,0.45), 0 2px 5px rgba(0,0,0,0.25);
--nb-icon: #9ca3af;
--nb-icon-hover: #f3f4f6;
}
}
.nb-button {
all: unset;
position: fixed;
right: 24px;
width: var(--nb-size);
height: var(--nb-size);
background: var(--nb-bg);
border: 1px solid var(--nb-border);
border-radius: var(--nb-radius);
box-shadow: var(--nb-shadow);
backdrop-filter: blur(12px) saturate(1.4);
-webkit-backdrop-filter: blur(12px) saturate(1.4);
cursor: pointer;
z-index: 2147483647;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateX(12px) scale(0.88);
transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
}
.nb-button.nb-visible {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateX(0) scale(1);
}
.nb-button:hover {
background: var(--nb-bg-hover);
border-color: var(--nb-border-hover);
box-shadow: var(--nb-shadow-hover);
transform: translateX(0) scale(1.08);
}
.nb-button:active {
transform: translateX(0) scale(0.94);
background: var(--nb-bg-active);
transition-duration: 0.08s;
}
.nb-button svg {
width: 18px;
height: 18px;
fill: var(--nb-icon);
transition: fill 0.2s ease;
pointer-events: none !important;
}
.nb-button svg * {
pointer-events: none !important;
}
.nb-button:hover svg {
fill: var(--nb-icon-hover);
}
.nb-button.nb-scrolling svg {
animation: nb-pulse 0.6s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
}
#nb-top {
top: calc(50% - var(--nb-size) - var(--nb-gap) / 2);
}
#nb-bottom {
top: calc(50% + var(--nb-gap) / 2);
}
@keyframes nb-pulse {
from { transform: translateY(0); }
to { transform: translateY(-2px); }
}
`;
// ─── Utilities ────────────────────────────────────────────────────────────────
const getDocHeight = () => Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight
);
const easeInOutCubic = (t) =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
// ─── Create SVG Icon ──────────────────────────────────────────────────────────
function createSVGIcon(pathData) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.style.pointerEvents = 'none';
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', pathData);
path.style.pointerEvents = 'none';
svg.appendChild(path);
return svg;
}
// ─── Smooth Scroll (for clicks) ───────────────────────────────────────────────
let smoothScrollRAF = null;
function smoothScrollTo(targetY, duration = CFG.clickScrollDuration) {
// Stop any ongoing smooth scroll
if (smoothScrollRAF) {
cancelAnimationFrame(smoothScrollRAF);
smoothScrollRAF = null;
}
const startY = window.scrollY || window.pageYOffset;
const distance = targetY - startY;
// Already at target
if (Math.abs(distance) < 1) return;
const startTime = performance.now();
function animate(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = easeInOutCubic(progress);
const newY = startY + distance * eased;
window.scrollTo(0, newY);
if (progress < 1) {
smoothScrollRAF = requestAnimationFrame(animate);
} else {
smoothScrollRAF = null;
}
}
smoothScrollRAF = requestAnimationFrame(animate);
}
// ─── Continuous Scroll State ──────────────────────────────────────────────────
const scrollState = {
top: { interval: null, checkInterval: null },
bottom: { interval: null, checkInterval: null }
};
function startScroll(which, button) {
const state = scrollState[which];
const direction = which === 'top' ? 'up' : 'down';
// Already running
if (state.interval) return;
button.classList.add('nb-scrolling');
// Cancel smooth scroll if running
if (smoothScrollRAF) {
cancelAnimationFrame(smoothScrollRAF);
smoothScrollRAF = null;
}
// Main scroll interval
state.interval = setInterval(() => {
const scrollY = window.scrollY;
const docHeight = getDocHeight();
const windowHeight = window.innerHeight;
const maxScroll = docHeight - windowHeight;
// Boundary checks
if (direction === 'up' && scrollY <= 0) {
stopScroll(which, button);
return;
}
if (direction === 'down' && scrollY >= maxScroll - 1) {
stopScroll(which, button);
return;
}
// Perform scroll
const delta = direction === 'up' ? -CFG.hoverScrollSpeed : CFG.hoverScrollSpeed;
window.scrollBy(0, delta);
}, CFG.hoverScrollInterval);
// Position check interval - ensures scroll stops when mouse leaves
state.checkInterval = setInterval(() => {
const rect = button.getBoundingClientRect();
const mouseX = window.nb_lastMouseX || 0;
const mouseY = window.nb_lastMouseY || 0;
const isOutside = (
mouseX < rect.left ||
mouseX > rect.right ||
mouseY < rect.top ||
mouseY > rect.bottom
);
if (isOutside) {
stopScroll(which, button);
}
}, 50);
}
function stopScroll(which, button) {
const state = scrollState[which];
if (state.interval) {
clearInterval(state.interval);
state.interval = null;
}
if (state.checkInterval) {
clearInterval(state.checkInterval);
state.checkInterval = null;
}
button.classList.remove('nb-scrolling');
}
function stopAllScrolling(topButton, bottomButton) {
stopScroll('top', topButton);
stopScroll('bottom', bottomButton);
}
// ─── Track mouse position globally ────────────────────────────────────────────
window.nb_lastMouseX = 0;
window.nb_lastMouseY = 0;
document.addEventListener('mousemove', (e) => {
window.nb_lastMouseX = e.clientX;
window.nb_lastMouseY = e.clientY;
}, { passive: true, capture: true });
// ─── Button Visibility ────────────────────────────────────────────────────────
function updateButtonVisibility(topButton, bottomButton) {
const scrollY = window.scrollY;
const maxScroll = getDocHeight() - window.innerHeight;
if (scrollY > CFG.scrollThresholdTop) {
topButton.classList.add('nb-visible');
} else {
topButton.classList.remove('nb-visible');
}
if (scrollY < maxScroll - CFG.scrollThresholdBottom) {
bottomButton.classList.add('nb-visible');
} else {
bottomButton.classList.remove('nb-visible');
}
}
// ─── Event Handlers ───────────────────────────────────────────────────────────
function attachEvents(topButton, bottomButton) {
// Click handlers - FIXED: No stopPropagation, check if currently scrolling
topButton.addEventListener('click', (e) => {
e.preventDefault();
// Stop hover scroll if running
stopScroll('top', topButton);
// Jump to top
smoothScrollTo(0);
});
bottomButton.addEventListener('click', (e) => {
e.preventDefault();
// Stop hover scroll if running
stopScroll('bottom', bottomButton);
// Jump to bottom
const target = getDocHeight() - window.innerHeight;
smoothScrollTo(target);
});
// Hover handlers
const startTop = () => startScroll('top', topButton);
const stopTop = () => stopScroll('top', topButton);
const startBottom = () => startScroll('bottom', bottomButton);
const stopBottom = () => stopScroll('bottom', bottomButton);
// Use both mouseenter/mouseleave and mouseover/mouseout for redundancy
topButton.addEventListener('mouseenter', startTop, true);
topButton.addEventListener('mouseover', startTop, true);
topButton.addEventListener('mouseleave', stopTop, true);
topButton.addEventListener('mouseout', stopTop, true);
bottomButton.addEventListener('mouseenter', startBottom, true);
bottomButton.addEventListener('mouseover', startBottom, true);
bottomButton.addEventListener('mouseleave', stopBottom, true);
bottomButton.addEventListener('mouseout', stopBottom, true);
// Global safety nets
document.addEventListener('mouseleave', () => {
stopAllScrolling(topButton, bottomButton);
}, true);
window.addEventListener('blur', () => {
stopAllScrolling(topButton, bottomButton);
});
// Scroll listener
let rafPending = false;
window.addEventListener('scroll', () => {
if (!rafPending) {
rafPending = true;
requestAnimationFrame(() => {
updateButtonVisibility(topButton, bottomButton);
rafPending = false;
});
}
}, { passive: true });
// Resize listener
window.addEventListener('resize', () => {
updateButtonVisibility(topButton, bottomButton);
}, { passive: true });
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 'Home') {
e.preventDefault();
smoothScrollTo(0);
}
if (e.key === 'End') {
e.preventDefault();
smoothScrollTo(getDocHeight() - window.innerHeight);
}
}
});
}
// ─── Initialize ───────────────────────────────────────────────────────────────
function init() {
if (document.getElementById('nb-top')) return;
const styleEl = document.createElement('style');
styleEl.id = 'nb-styles';
styleEl.textContent = CSS;
(document.head || document.documentElement).appendChild(styleEl);
const topButton = document.createElement('button');
topButton.id = 'nb-top';
topButton.className = 'nb-button';
topButton.setAttribute('aria-label', 'Scroll to top');
topButton.setAttribute('title', 'Click: jump to top • Hover: scroll up');
topButton.appendChild(createSVGIcon('M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z'));
const bottomButton = document.createElement('button');
bottomButton.id = 'nb-bottom';
bottomButton.className = 'nb-button';
bottomButton.setAttribute('aria-label', 'Scroll to bottom');
bottomButton.setAttribute('title', 'Click: jump to bottom • Hover: scroll down');
bottomButton.appendChild(createSVGIcon('M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z'));
const root = document.body || document.documentElement;
root.appendChild(topButton);
root.appendChild(bottomButton);
attachEvents(topButton, bottomButton);
requestAnimationFrame(() => updateButtonVisibility(topButton, bottomButton));
}
if (document.body) {
init();
} else {
const observer = new MutationObserver(() => {
if (document.body) {
observer.disconnect();
init();
}
});
observer.observe(document.documentElement, { childList: true });
}
})();