F**k sticky header

Automatically handle sticky/fixed top headers, show/hide based on scroll

Від 16.09.2025. Дивіться остання версія.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         F**k sticky header
// @name:zh-CN   去你的固定顶栏
// @name:zh-TW   去你的固定頂欄
// @namespace    fuck.sticky.header
// @version      1.2
// @description  Automatically handle sticky/fixed top headers, show/hide based on scroll
// @description:zh-CN  自动处理固定或粘性定位的顶部导航栏,根据滚动状态智能显示/隐藏,提升浏览体验
// @description:zh-TW  自動處理固定或粘性定位的頂部導航欄,根據滾動狀態智能顯示/隱藏,提升瀏覽體驗
// @author       You
// @match        *://*/*
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @license      MIT
// ==/UserScript==


(function() {
    'use strict';

    // Configuration parameters
    const CONFIG = {
        scrollThreshold: 5,        // Minimum scroll distance to trigger action
        topThreshold: 100,         // Top area where header should always show
        transitionDuration: '0.3s', // Animation duration
        maxTopValue: 40            // Accept elements with top value up to this (pixels)
    };

    // Get whitelist from storage
    let whitelist = GM_getValue('whitelist', []);

    // Check if current domain is whitelisted
    function isWhitelisted() {
        const currentDomain = window.location.hostname;
        return whitelist.some(domain => currentDomain.includes(domain));
    }

    // Add current site to whitelist
    function addToWhitelist() {
        const currentDomain = window.location.hostname;
        if (!whitelist.includes(currentDomain)) {
            whitelist.push(currentDomain);
            GM_setValue('whitelist', whitelist);
            alert(`Added ${currentDomain} to whitelist`);
            window.location.reload();
        } else {
            alert(`${currentDomain} is already in whitelist`);
        }
    }

    // Remove current site from whitelist
    function removeFromWhitelist() {
        const currentDomain = window.location.hostname;
        const index = whitelist.indexOf(currentDomain);
        if (index !== -1) {
            whitelist.splice(index, 1);
            GM_setValue('whitelist', whitelist);
            alert(`Removed ${currentDomain} from whitelist`);
            window.location.reload();
        } else {
            alert(`${currentDomain} is not in whitelist`);
        }
    }

    // Register Tampermonkey menu commands
    GM_registerMenuCommand('Add current site to whitelist', addToWhitelist);
    GM_registerMenuCommand('Remove current site from whitelist', removeFromWhitelist);

    // Exit if site is whitelisted
    if (isWhitelisted()) {
        return;
    }

    // Helper function to parse pixel values
    function parsePixelValue(value) {
        if (value.endsWith('px')) {
            return parseFloat(value);
        }
        return 0;
    }

    // Detect eligible header elements
    function detectHeaderElements() {
        // Collect all potential header elements
        const candidates = new Set();

        // 1. Add <header> tags
        const headerTags = document.querySelectorAll('header, nav');
        if (headerTags.length > 0) {
            Array.from(headerTags).forEach(el => candidates.add(el));
        }

        // 2. Add elements with relevant keywords
        const keywords = ['nav', 'banner', 'header'];
        const allElements = document.querySelectorAll('*:not(html, body)');

        for (const el of allElements) {
            const className = typeof el.className === 'string' ? el.className : '';
            const hasKeyword = keywords.some(keyword =>
                (el.id && el.id.toLowerCase().includes(keyword)) ||
                (className && className.toLowerCase().includes(keyword))
            );

            if (hasKeyword) {
                candidates.add(el);
            }
        }

        // 3. Find full-width elements (100vw) regardless of keywords
        for (const el of allElements) {
            const computed = window.getComputedStyle(el);
            const bodyWidth = window.getComputedStyle(document.body).width;
            if (['100vw', bodyWidth].includes(computed.width)) {
                candidates.add(el);
            }
        }

        // Filter to find valid top headers
        const validHeaders = [];
        for (const el of candidates) {
            // Check positioning
            const computed = window.getComputedStyle(el);
            const isStickyOrFixed = computed.position === 'sticky' || computed.position === 'fixed';
            if (!isStickyOrFixed) continue;

            // Check top value (allow small values up to maxTopValue)
            const topValue = parsePixelValue(computed.top);
            if (topValue > CONFIG.maxTopValue) continue;

            // Check visual position and dimensions
            const rect = el.getBoundingClientRect();
            const isWideEnough = rect.width >= window.innerWidth * 0.8 || computed.width === '100vw';
            const isNearTop = rect.top <= CONFIG.topThreshold;

            if (isWideEnough && isNearTop) {
                validHeaders.push(el);
            }
        }

        return validHeaders;
    }

    // Get all eligible headers
    const headerElements = detectHeaderElements();
    if (headerElements.length === 0) {
        return; // No eligible headers found
    }

    // Add necessary styles
    function addStyles() {
        const styleSheet = document.createElement('style');
        styleSheet.id = 'fuck-sticky-header-style';

        // Generate unique selectors
        const selectors = headerElements.map(el => {
            if (el.id) return `#${CSS.escape(el.id)}`;
            return Array.from(el.classList).map(cls => `.${CSS.escape(cls)}`).join(', ');
        }).join(', ');

        styleSheet.textContent = `
            ${selectors} {
                will-change: transform, opacity !important;
                transition: transform ${CONFIG.transitionDuration} cubic-bezier(0.4, 0, 0.2, 1),
                            opacity ${CONFIG.transitionDuration} cubic-bezier(0.4, 0, 0.2, 1) !important;
            }

            html {
                overscroll-behavior-y: contain;
            }
        `;
        document.head.appendChild(styleSheet);
    }

    // Initialize variables
    let lastScrollTop = window.pageYOffset || document.documentElement.scrollTop;

    // Update header visibility
    function updateHeaders(shouldShow) {
        headerElements.forEach(el => {
            el.style.transform = shouldShow ? 'translateY(0)' : 'translateY(-100%)';
            el.style.opacity = shouldShow ? '1' : '0';
        });
    }

    // Scroll handler function
    function handleScroll() {
        const scrollTop = window.pageYOffset || document.documentElement.scrollTop;

        // Handle Safari overscroll bounce
        const documentHeight = document.documentElement.scrollHeight;
        const viewportHeight = Math.min(document.documentElement.clientHeight, document.body.clientHeight);
        const maxValidScrollTop = documentHeight - viewportHeight;
        if (scrollTop < 0 || scrollTop > maxValidScrollTop) return;

        // Calculate scroll difference
        const scrollDiff = scrollTop - lastScrollTop;

        // Only act on significant scroll movements
        if (Math.abs(scrollDiff) >= CONFIG.scrollThreshold) {
            // Special handling for top area
            if (scrollTop <= CONFIG.topThreshold) {
                updateHeaders(true);
            }
            // Show on upward scroll
            else if (scrollDiff < 0) {
                updateHeaders(true);
            }
            // Hide on downward scroll
            else if (scrollDiff > 0) {
                updateHeaders(false);
            }

            // Update last scroll position
            lastScrollTop = scrollTop;
        }
    }

    // Initialization
    function init() {
        addStyles();

        // Set initial state
        const initialScrollTop = window.pageYOffset || document.documentElement.scrollTop;
        updateHeaders(initialScrollTop <= CONFIG.topThreshold);

        // Add scroll listener
        window.addEventListener('scroll', () => {
            requestAnimationFrame(handleScroll);
        }, { passive: true });
    }

    // Initialize when page is loaded
    if (document.readyState === 'complete') {
        init();
    } else {
        window.addEventListener('load', init);
    }

})();