← Scroll

Scroll Tilt-to-flat

A screenshot starts tilted back in 3D, leaning away like a screen on a desk, and swings up to lie flat and full size as you scroll down to it. Scroll back up and it leans away again. A favourite for product hero sections.

Scroll inside ↓

Meet your dashboardEverything you need, in one place.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-scroll-tilt-to-flat to a screenshot, a framed image, a video or any element.

Options

Add any of these attributes to the same element.

data-tiltHow far it leans back to start with, in degrees. Default 48.
data-scaleIts starting size. Default 0.86.
data-startWhere its top is in the view as it starts to swing up, from 0 (top) to 1 (bottom). Default 0.95.
data-endWhere its top is once it lies flat. Default 0.3.
data-perspectiveDepth of the 3D, in px. Default 1.2 times its width; smaller looks more dramatic.

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 () {
    // Scroll Tilt-to-flat: a screenshot starts tilted back in 3D, leaning away
    // from you like a screen on a desk, and lies flat (facing you, full size)
    // as you scroll down to it. Tied to the scroll, so it tilts back again if
    // you scroll up.
    // Usage: <img data-scroll-tilt-to-flat src="dashboard.png" alt="…">
    //   (or on any element: a framed screenshot, a video, a card).
    // Optional: data-tilt (starting tilt in degrees, default 48), data-scale
    // (starting size, default 0.86), data-start and data-end (where the
    // element's top is in the view, 0 top to 1 bottom, as it starts and
    // finishes lying flat; default 0.95 and 0.3), data-perspective (in px,
    // default 1.2 times its width: smaller looks more dramatic).
    // Works inside scrolling boxes too: it follows the nearest scrolling parent.
    var DEFAULTS = { tilt: 48, scale: 0.86, start: 0.95, end: 0.3 };

    var css = '.stf{transform-origin:50% 60%}';
    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 setup(el) {
      var d = el.dataset;
      var tilt = num(d.tilt, DEFAULTS.tilt), scale = num(d.scale, DEFAULTS.scale);
      var start = num(d.start, DEFAULTS.start), end = num(d.end, DEFAULTS.end);
      var persp = num(d.perspective, 0);
      var box = scroller(el);
      var queued = false, last = -1;
      el.classList.add('stf');

      function update() {
        queued = false;
        // Measure without the tilt, so it doesn't feed back into the progress.
        var saved = el.style.transform;
        el.style.transform = 'none';
        var r = el.getBoundingClientRect();
        el.style.transform = saved;
        var top = 0, h = window.innerHeight;
        if (box) { var b = box.getBoundingClientRect(); top = b.top + box.clientTop; h = box.clientHeight; }
        var p = (top + h * start - r.top) / (h * (start - end) || 1);
        p = Math.max(0, Math.min(1, p));
        if (Math.abs(p - last) < 0.0005) return;
        last = p;
        var e = p * p * (3 - 2 * p);
        var a = tilt * (1 - e), s = scale + (1 - scale) * e;
        el.style.transform = e >= 1 ? 'none'
          : 'perspective(' + Math.round(persp || Math.max(400, el.offsetWidth * 1.2)) + 'px) rotateX(' + a.toFixed(2) + 'deg) scale(' + s.toFixed(4) + ')';
      }

      function onScroll() {
        if (queued) return;
        queued = true;
        requestAnimationFrame(update);
      }
      (box || window).addEventListener('scroll', onScroll, { passive: true });
      window.addEventListener('resize', onScroll);
      update();
    }

    function init() {
      if (reduced()) return;
      document.querySelectorAll('[data-scroll-tilt-to-flat]').forEach(function (el) {
        if (el._stf) return;
        el._stf = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<img data-scroll-tilt-to-flat src="dashboard.png" alt="The dashboard">

<!-- a steeper lean: -->
<div class="frame" data-scroll-tilt-to-flat data-tilt="45">…</div>