scroll-button (Draggable, Auto-Fade, Fluent 2 Design)
// ==UserScript==
// @name scroll-button
// @namespace https://github.com/livinginpurple
// @version 20260210.15
// @description scroll-button (Draggable, Auto-Fade, Fluent 2 Design)
// @license WTFPL
// @author livinginpurple
// @include *
// @exclude *://www.plurk.com/*
// @exclude *://www.google.com/maps/*
// @exclude *://www.google.com.tw/maps/*
// @exclude *://mail.google.com/*
// @run-at document-end
// ==/UserScript==
(() => {
'use strict';
const btnId = 'gamma-scroll-btn';
if (document.getElementById(btnId)) return;
// --- Helpers ---
const ns = 'http://www.w3.org/2000/svg';
const createSVG = (pathData, fillRule) => {
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('viewBox', '0 0 16 16');
const path = document.createElementNS(ns, 'path');
path.setAttribute('d', pathData);
if (fillRule) path.setAttribute('fill-rule', fillRule);
svg.appendChild(path);
return svg;
};
const getScrollDetails = (scroller) => {
const isWin = scroller === window || scroller === document || scroller === document.documentElement || scroller === document.body;
return {
top: isWin ? window.scrollY : scroller.scrollTop,
height: isWin ? document.documentElement.scrollHeight : scroller.scrollHeight,
target: isWin ? window : scroller
};
};
// --- Fluent 2 Design Tokens ---
// Border Radius: Large (8px) for 40px+ components
// Shadow: Layered key + ambient shadows
// Motion: 250ms ease decelerate curve
// Colors: Neutral palette with acrylic blur
// --- Elements ---
const style = document.createElement('style');
style.textContent = `
#${btnId} {
position: fixed; right: 20px; bottom: 20px;
width: 40px; height: 40px;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.0578);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.14), 0 0 2px rgba(0, 0, 0, 0.12);
z-index: 10000; cursor: grab;
display: flex; justify-content: center; align-items: center;
transition: opacity 0.25s cubic-bezier(0.1, 0.9, 0.2, 1),
transform 0.15s cubic-bezier(0.1, 0.9, 0.2, 1),
background-color 0.1s cubic-bezier(0.1, 0.9, 0.2, 1),
box-shadow 0.25s cubic-bezier(0.1, 0.9, 0.2, 1);
touch-action: none; padding: 0;
background-color: rgba(255, 255, 255, 0.7);
-webkit-backdrop-filter: saturate(150%) blur(20px);
backdrop-filter: saturate(150%) blur(20px);
color: #242424;
opacity: 0.3;
}
#${btnId} svg { width: 18px; height: 18px; fill: currentColor; }
#${btnId}:hover {
background-color: rgba(249, 249, 249, 0.85);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.14), 0 0 2px rgba(0, 0, 0, 0.12);
transform: translateY(-1px);
}
#${btnId}:active, #${btnId}.is-dragging {
background-color: rgba(249, 249, 249, 0.65);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.14), 0 0 1px rgba(0, 0, 0, 0.12);
transform: scale(0.96);
}
@media (prefers-color-scheme: dark) {
#${btnId} {
background-color: rgba(44, 44, 44, 0.7);
border: 1px solid rgba(255, 255, 255, 0.0837);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.28), 0 0 2px rgba(0, 0, 0, 0.24);
color: #fff;
}
#${btnId}:hover {
background-color: rgba(50, 50, 50, 0.85);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.28), 0 0 2px rgba(0, 0, 0, 0.24);
}
#${btnId}:active, #${btnId}.is-dragging {
background-color: rgba(40, 40, 40, 0.65);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.28), 0 0 1px rgba(0, 0, 0, 0.24);
}
}
#gamma-trash-zone {
position: fixed; top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 56px; height: 56px;
border-radius: 12px;
background-color: rgba(0, 0, 0, 0.4);
-webkit-backdrop-filter: saturate(150%) blur(20px);
backdrop-filter: saturate(150%) blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
display: flex; justify-content: center; align-items: center;
z-index: 9999; opacity: 0; pointer-events: none;
transition: opacity 0.25s cubic-bezier(0.1, 0.9, 0.2, 1),
transform 0.2s cubic-bezier(0.1, 0.9, 0.2, 1),
background-color 0.15s cubic-bezier(0.1, 0.9, 0.2, 1);
}
#gamma-trash-zone.visible { opacity: 1; }
#gamma-trash-zone.active {
transform: translate(-50%, -50%) scale(1.15);
background-color: rgba(196, 43, 28, 0.85);
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 16px rgba(196, 43, 28, 0.3);
}
#gamma-trash-zone svg { width: 22px; height: 22px; fill: currentColor; }
`;
document.head.appendChild(style);
const icons = {
up: createSVG('M8 12a.5.5 0 0 0 .5-.5V5.707l2.146 2.147a.5.5 0 0 0 .708-.708l-3-3a.5.5 0 0 0-.708 0l-3 3a.5.5 0 1 0 .708.708L7.5 5.707V11.5a.5.5 0 0 0 .5.5z', 'evenodd'),
down: createSVG('M8 4a.5.5 0 0 1 .5.5v5.793l2.146-2.147a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-3-3a.5.5 0 1 1 .708-.708L7.5 10.293V4.5A.5.5 0 0 1 8 4z', 'evenodd'),
trash: createSVG('M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z')
};
const btn = document.createElement('button');
btn.id = btnId;
btn.type = 'button';
btn.setAttribute('aria-label', 'Scroll navigation');
document.body.appendChild(btn);
const trashZone = document.createElement('div');
trashZone.id = 'gamma-trash-zone';
trashZone.appendChild(icons.trash.cloneNode(true));
document.body.appendChild(trashZone);
// --- State ---
let activeScroller = window;
let stopTimer, fadeTimer;
let isPressed = false, isDragging = false;
let startX, startY, offsetX, offsetY;
const wakeUp = (permanent = false) => {
btn.style.opacity = '1';
clearTimeout(stopTimer);
clearTimeout(fadeTimer);
if (!permanent) fadeTimer = setTimeout(() => btn.style.opacity = '0.3', 3000);
};
const updateIcon = () => {
const { top } = getScrollDetails(activeScroller);
const state = top < 50 ? 'top' : 'scrolled';
if (btn.dataset.state === state) return;
btn.dataset.state = state;
btn.replaceChildren(icons[state === 'top' ? 'down' : 'up'].cloneNode(true));
};
// --- Handlers ---
const handleScroll = (e) => {
activeScroller = (e.target && e.target !== document) ? e.target : window;
btn.style.opacity = '0.3';
updateIcon();
clearTimeout(stopTimer);
clearTimeout(fadeTimer);
stopTimer = setTimeout(wakeUp, 150);
};
const onMove = (e) => {
if (!isPressed) return;
const pointer = e.type.includes('touch') ? e.touches[0] : e;
const dist = Math.hypot(pointer.clientX - startX, pointer.clientY - startY);
if (!isDragging && dist > 10) {
isDragging = true;
const rect = btn.getBoundingClientRect();
btn.style.left = rect.left + 'px';
btn.style.top = rect.top + 'px';
btn.style.right = btn.style.bottom = 'auto';
trashZone.classList.add('visible');
}
if (isDragging) {
const x = Math.max(0, Math.min(pointer.clientX - offsetX, window.innerWidth - 40));
const y = Math.max(0, Math.min(pointer.clientY - offsetY, window.innerHeight - 40));
btn.style.left = x + 'px';
btn.style.top = y + 'px';
const inTrash = Math.hypot(window.innerWidth / 2 - (x + 20), window.innerHeight / 2 - (y + 20)) < 80;
trashZone.classList.toggle('active', inTrash);
btn.style.opacity = inTrash ? '0.5' : '1';
wakeUp(true);
}
};
const onEnd = () => {
if (trashZone.classList.contains('active')) {
window.removeEventListener('scroll', handleScroll, { capture: true });
btn.remove();
trashZone.remove();
} else {
isPressed = isDragging = false;
btn.classList.remove('is-dragging');
trashZone.classList.remove('visible', 'active');
wakeUp();
}
};
// --- Init ---
btn.addEventListener('mousedown', (e) => {
isPressed = true; startX = e.clientX; startY = e.clientY;
const rect = btn.getBoundingClientRect();
offsetX = e.clientX - rect.left; offsetY = e.clientY - rect.top;
btn.classList.add('is-dragging');
wakeUp();
});
btn.addEventListener('touchstart', (e) => {
isPressed = true; startX = e.touches[0].clientX; startY = e.touches[0].clientY;
const rect = btn.getBoundingClientRect();
offsetX = e.touches[0].clientX - rect.left; offsetY = e.touches[0].clientY - rect.top;
btn.classList.add('is-dragging');
wakeUp();
}, { passive: false });
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onEnd);
window.addEventListener('touchmove', onMove, { passive: false });
window.addEventListener('touchend', onEnd);
btn.addEventListener('click', (e) => {
if (isDragging) return (isDragging = false);
const { height, target } = getScrollDetails(activeScroller);
target.scrollTo({ top: btn.dataset.state === 'top' ? height : 0, behavior: 'smooth' });
wakeUp();
btn.blur();
});
btn.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.code === 'Space') {
e.preventDefault();
}
});
btn.onmouseenter = () => wakeUp();
window.addEventListener('scroll', handleScroll, { capture: true, passive: true });
updateIcon();
})();