← Scroll

Hero Logo to Header

A big logo sits in the hero. As you scroll it shrinks and flies up into its spot in the sticky header, which then shows the rest of its buttons. Scroll back to the top and it grows back down into the hero.

WorkAboutHire us
Studio for small, lovely things.

How to use it

  1. Paste the snippet just before </head>.
  2. Give your sticky header an empty placeholder <span data-logo-slot>, sized like the small logo.
  3. Add data-hero-logo-to-header to the big logo in your hero.
  4. Optionally mark header items with data-logo-reveal to fade them in as the logo lands.

Options

Add any of these attributes to the same element.

data-slotA selector for the slot. Default the first [data-logo-slot].
data-distanceHow far you scroll before it lands, in px. Default: until the big logo would reach the header.
data-smoothHow quickly it catches up with the scroll, from 0 to 1. Default 0.25; 1 follows it exactly.

The header gets the class hlth-docked once the logo has landed. The logo flies over the header, so the hero can’t have overflow: hidden or its own z-index. With reduced motion the logo stays in the hero. Works inside scrolling boxes too.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Hero Logo to Header: a big logo in the hero shrinks and flies up into its
    // place in a sticky header as you scroll, and grows back down when you
    // return to the top.
    // Usage: <header style="position:sticky;top:0">
    //          <span data-logo-slot style="width:90px;height:28px"></span>   (where it lands)
    //          …
    //        </header>
    //        <section class="hero">
    //          <div data-hero-logo-to-header>Acme</div>    (the big logo)
    //        </section>
    // The slot is an empty placeholder sized like the small logo. The header
    // gets the class hlth-docked once the logo has landed (for a shadow, say),
    // and anything in it marked data-logo-reveal fades in as the logo arrives.
    // Optional: data-slot (a selector for the slot, default the first
    // [data-logo-slot] on the page or in the same scrolling box), data-distance
    // (how far you scroll before it lands, px, default: until the big logo
    // would reach the header), data-smooth (how quickly it catches up with the
    // scroll, 0 to 1, default 0.25; 1 follows exactly).
    // Works inside scrolling boxes too: it follows the nearest scrolling parent.
    var DEFAULTS = { smooth: 0.25 };

    var css =
      '.hlth{position:relative;z-index:50;transform-origin:0 0;will-change:transform}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function num(v, fallback) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : fallback; }

    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 ease(t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }

    function setup(el) {
      var d = el.dataset;
      var box = scroller(el);
      var slot = d.slot ? document.querySelector(d.slot) : (box || document).querySelector('[data-logo-slot]');
      if (!slot) return;
      var header = slot.closest('header') || slot.parentElement;
      var reveals = header.querySelectorAll('[data-logo-reveal]');
      var smooth = Math.max(0.01, Math.min(1, num(d.smooth, DEFAULTS.smooth)));
      el.classList.add('hlth');

      var top0 = 0, left0 = 0, w0 = 1, h0 = 1, dist = 1, current = 0, target = 0, running = false;

      function scrollTop() { return box ? box.scrollTop : window.pageYOffset; }
      function viewTop() { return box ? box.getBoundingClientRect().top + box.clientTop : 0; }

      function measure() {
        el.style.transform = '';
        var r = el.getBoundingClientRect();
        var vt = viewTop();
        top0 = r.top - vt + scrollTop();      // the logo's top in scroll coordinates
        left0 = r.left;
        w0 = r.width || 1;
        h0 = r.height || 1;
        var s = slot.getBoundingClientRect();
        dist = num(d.distance, Math.max(40, top0 - (s.top - vt)));
        target = current = Math.max(0, Math.min(1, scrollTop() / dist));
        draw();
      }

      function draw() {
        var vt = viewTop();
        var s = slot.getBoundingClientRect();
        // Where the logo sits untransformed right now (it scrolls with the hero).
        var natTop = top0 - scrollTop() + vt;
        var e = ease(current);
        // Fit the logo into the slot, keeping its proportions and centring it there.
        var k = Math.min(s.width / w0, s.height / h0);
        var tx = s.left + (s.width - w0 * k) / 2;
        var ty = s.top + (s.height - h0 * k) / 2;
        var x = left0 + (tx - left0) * e;
        var y = natTop + (ty - natTop) * e;
        var sc = 1 + (k - 1) * e;
        // Before it lands it follows the hero up; after, it stays put in the slot.
        el.style.transform = e <= 0 ? '' : 'translate3d(' + (x - left0).toFixed(2) + 'px,' + (y - natTop).toFixed(2) + 'px,0) scale(' + sc.toFixed(4) + ')';
        header.classList.toggle('hlth-docked', current >= 0.999);
        for (var i = 0; i < reveals.length; i++) reveals[i].style.opacity = Math.max(0, (current - 0.6) / 0.4).toFixed(3);
      }

      function tick() {
        current += (target - current) * smooth;
        if (Math.abs(target - current) < 0.001) current = target;
        draw();
        if (current !== target) requestAnimationFrame(tick);
        else running = false;
      }

      function onScroll() {
        target = Math.max(0, Math.min(1, scrollTop() / dist));
        if (!running) {
          running = true;
          requestAnimationFrame(tick);
        }
        draw();   // keep it glued to the hero even between eased frames
      }

      measure();
      (box || window).addEventListener('scroll', onScroll, { passive: true });
      window.addEventListener('resize', measure);
      if (window.ResizeObserver) {
        var ro = new ResizeObserver(function () { measure(); });
        ro.observe(el.parentElement || el);
        if (box) ro.observe(box);
      }
    }

    function init() {
      if (reduced()) return;   // no motion: the hero logo stays in the hero
      document.querySelectorAll('[data-hero-logo-to-header]').forEach(function (el) {
        if (el._hlth) return;
        el._hlth = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<header style="position: sticky; top: 0">
  <span data-logo-slot style="width: 96px; height: 28px"></span>
  <nav>…</nav>
</header>
<section class="hero">
  <div data-hero-logo-to-header><img src="logo.svg" alt="Acme"></div>
</section>