← Navigation

Auto-hide Header

Scroll down to read and the header slides up out of the way. Scroll back up even a little and it slides straight back, ready when you need it. Near the top of the page it always shows.

BlogSubscribe

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-auto-hide-header to your header. It becomes sticky if it isn’t already.

Options

Add any of these attributes to the same element.

data-offsetHow far from the top it always shows, in px. Default the header’s own height.
data-toleranceHow far you must scroll one way before it reacts, so small jiggles don’t flick it. Default 8 px.
data-durationHow long it takes to slide. Default 380 ms.
data-autoplayInside a scrolling box, it scrolls the box down and back up by itself while in view, pausing when someone scrolls. Handy for demos.

While it’s away the header has the class ahh-hidden, and it fires an autohide event (e.detail.hidden). Tabbing into it brings it back. Works inside scrolling boxes too: it follows the nearest scrolling parent.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Auto-hide Header: the header slides up out of the way as you scroll down
    // to read, and slides back the moment you scroll up, so it's there when
    // you want it and gone when you don't. Near the top of the page it always shows.
    // Usage: <header data-auto-hide-header>…</header>
    // It makes the header sticky if it isn't already. Works inside scrolling
    // boxes too: it follows the nearest scrolling parent.
    // Optional: data-offset (px from the top within which it always shows;
    // default the header's height), data-tolerance (px you must scroll one way
    // before it reacts, so small jiggles don't flick it; default 8),
    // data-duration (ms, default 380), data-autoplay (inside a scrolling box,
    // it scrolls the box down and back up by itself while in view, pausing when
    // someone scrolls; handy for demos).
    // The header gets the class "ahh-hidden" while it's away, and fires an
    // "autohide" event (e.detail.hidden).
    var DEFAULTS = { tolerance: 8, duration: 380 };

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function scroller(el) {
      for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
        var o = getComputedStyle(p).overflowY;
        if (o === 'auto' || o === 'scroll') return p;
      }
      return null;
    }

    function setup(el) {
      var d = el.dataset;
      var cs = getComputedStyle(el);
      if (cs.position === 'static' || cs.position === 'relative') {
        el.style.position = 'sticky';
        el.style.top = '0px';
      }
      el.style.zIndex = el.style.zIndex || '10';
      var box = scroller(el);
      var tolerance = d.tolerance !== undefined ? +d.tolerance : DEFAULTS.tolerance;
      var duration = +d.duration || DEFAULTS.duration;
      var hidden = false, last = 0, turn = 0, dir = 0;

      function pos() { return box ? box.scrollTop : window.scrollY; }
      function offset() { return d.offset !== undefined ? +d.offset : el.offsetHeight; }

      function set(hide) {
        if (hide === hidden) return;
        hidden = hide;
        el.classList.toggle('ahh-hidden', hide);
        anime.utils.remove(el);
        // Far enough up to clear its own shadow too.
        var away = -(el.offsetHeight + (parseFloat(el.style.top || getComputedStyle(el).top) || 0) + 16);
        if (reduced()) anime.utils.set(el, { y: hide ? away : 0 });
        else anime.animate(el, { y: hide ? away : 0, duration: hide ? duration : duration * 1.1, ease: hide ? 'in(2)' : 'out(4)' });
        el.dispatchEvent(new CustomEvent('autohide', { bubbles: true, detail: { hidden: hide } }));
      }

      function check() {
        var y = pos();
        var delta = y - last;
        last = y;
        if (!delta) return;
        var now = delta > 0 ? 1 : -1;
        // Measure how far it has gone since the scroll last changed direction.
        if (now !== dir) { dir = now; turn = y - delta; }
        if (y <= offset()) { set(false); return; }
        if (Math.abs(y - turn) < tolerance) return;
        set(dir > 0);
      }

      // Moving focus into a hidden header (keyboard users) brings it back.
      el.addEventListener('focusin', function () { set(false); });
      last = pos();
      (box || window).addEventListener('scroll', check, { passive: true });

      if (box && d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) autoplay(el, box);
    }

    // Scroll the box down in stretches, nudge it back up a little now and then
    // (the header returns), and finally glide back to the top.
    function autoplay(el, box) {
      var plan = [0.3, 0.2, 0.62, 0.5, 1, 0];
      var step = 0, timer = null, raf = 0, idleUntil = 0, visible = false;
      var hold = function () { idleUntil = Date.now() + 8000; cancelAnimationFrame(raf); };
      ['wheel', 'touchstart', 'pointerdown', 'keydown'].forEach(function (t) { box.addEventListener(t, hold, { passive: true }); });

      function glide(to, ms) {
        var from = box.scrollTop, t0 = performance.now();
        cancelAnimationFrame(raf);
        var frame = function (now) {
          var t = Math.min(1, (now - t0) / ms);
          box.scrollTop = from + (to - from) * (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
          if (t < 1) raf = requestAnimationFrame(frame);
        };
        raf = requestAnimationFrame(frame);
      }

      function tick() {
        if (!visible) return;
        var wait = 1500;
        if (Date.now() > idleUntil && !(window.matchMedia('(hover: hover)').matches && box.matches(':hover'))) {
          var max = box.scrollHeight - box.clientHeight;
          var to = plan[step] * max;
          var ms = Math.min(1600, 500 + Math.abs(to - box.scrollTop) * 2.5);
          glide(to, ms);
          wait = ms + (plan[step] === 0 ? 1100 : 800);
          step = (step + 1) % plan.length;
        }
        timer = setTimeout(tick, wait);
      }

      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        clearTimeout(timer);
        if (visible) timer = setTimeout(tick, 800);
        else cancelAnimationFrame(raf);
      }).observe(box);
    }

    function init() {
      document.querySelectorAll('[data-auto-hide-header]').forEach(function (el) {
        if (el._ahh) return;
        el._ahh = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<header data-auto-hide-header>
  <a href="/">Logo</a>
  <nav>…</nav>
</header>