LISN.js

Edit on StackBlitz

"use client";
import { useEffect, useRef } from "react";

import { GestureWatcher } from "lisn.js";

import Image from "next/image";

import styles from "./demo.module.css";

export default function Page() {
  const demoRef = useRef(null);

  useEffect(() => {
    const watcher = GestureWatcher.reuse();
    const main = demoRef.current;
    if (main) {
      watcher.trackGesture(main, null, {
        minTotalDeltaY: 0,
        maxTotalDeltaY: 960,
        minTotalDeltaZ: 1,
        maxTotalDeltaZ: 2.4,
      });
    }

    return () => {
      // cleanup
      if (main) {
        watcher.noTrackGesture(main);
      }
    };
  }, []);

  return (
    <>
      <div ref={demoRef} className={[styles.demo, "light-theme"].join(" ")}>
        <h1>Scroll or zoom</h1>

        <div className={styles.plane} data-plane="5">
          <Image
            className={styles.background}
            src="/images/landscape-5.png"
            alt=""
          />
        </div>

        <div className={styles.plane} data-plane="4">
          <Image
            className={styles.background}
            src="/images/landscape-4.png"
            alt=""
          />
        </div>

        <div className={styles.plane} data-plane="3">
          <div className={styles.spacer}></div>
          <Image
            className={styles.background}
            src="/images/landscape-3.png"
            alt=""
          />
        </div>

        <div className={styles.plane} data-plane="2">
          <div className={styles.spacer}></div>
          <Image
            className={styles.background}
            src="/images/landscape-2.png"
            alt=""
          />
        </div>

        <div className={styles.plane} data-plane="1">
          <div className={styles.spacer}></div>
          <Image
            className={styles.background}
            src="/images/landscape-1.png"
            alt=""
          />
        </div>
      </div>
    </>
  );
}
.demo {
  --max-offset: 240px;
  background: var(--bg-color);
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  width: 100dvw;
  height: 100vh;
  height: 100dvh;
  overflow: hidden;
}

.plane[data-plane] {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

.spacer,
.background {
  transition-duration: 0.25s;
  transition-timing-function: linear;
}

.background {
  pointer-events: none;
  width: 100%;
  height: calc(100% + var(--max-offset));
  object-fit: cover;
  overflow-clip-margin: unset;
  transition-property: transform;
  transform-origin: 50% 65%;
}

.spacer {
  transition-property: height;
  min-height: 0px;
}

/* Per-plane transforms */
.plane[data-plane="4"] .background {
  margin-top: 0;
  transform: scale(calc(0.05 * var(--lisn-js--zoom-delta-z, 1) + 0.95));
}

.plane[data-plane="3"] .background {
  margin-top: calc(-1 * var(--max-offset) / 3);
  transform: scale(calc(0.2 * var(--lisn-js--zoom-delta-z, 1) + 0.8));
}

.plane[data-plane="3"] .spacer {
  height: calc(
    var(--max-offset) / 3 - 1px * var(--lisn-js--scroll-delta-y, 0) / 12
  );
  max-height: calc(var(--max-offset) / 3);
}

.plane[data-plane="2"] .background {
  margin-top: calc(-1 * var(--max-offset) / 2);
  transform: scale(calc(0.4 * var(--lisn-js--zoom-delta-z, 1) + 0.6));
}

.plane[data-plane="2"] .spacer {
  height: calc(
    var(--max-offset) / 2 - 1px * var(--lisn-js--scroll-delta-y, 0) / 8
  );
  max-height: calc(var(--max-offset) / 2);
}

.plane[data-plane="1"] .background {
  margin-top: calc(-1 * var(--max-offset));
  transform: scale(calc(0.6 * var(--lisn-js--zoom-delta-z, 1) + 0.4));
}

.plane[data-plane="1"] .spacer {
  height: calc(var(--max-offset) - 1px * var(--lisn-js--scroll-delta-y, 0) / 4);
  max-height: var(--max-offset);
}

/* Misc styles */
.demo h1 {
  text-align: center;
  color: #03505c;
  z-index: 10;
  position: relative;
  font-weight: 400;
  margin-top: 25vh;
  margin-left: 4vw;
  mix-blend-mode: multiply;
}

Edit on CodePen

document.addEventListener("DOMContentLoaded", () => {
  const main = document.getElementById("demo");
  LISN.watchers.GestureWatcher.reuse().trackGesture(main, null, {
    minTotalDeltaY: 0,
    maxTotalDeltaY: 960,
    minTotalDeltaZ: 1,
    maxTotalDeltaZ: 2.4,
  });
});
<div id="demo">
  <h1>Scroll or zoom</h1>

  <div class="landscape-5 plane">
    <!-- static -->
    <img class="background" src="images/landscape-5.png" alt="" />
  </div>

  <div class="landscape-4 plane">
    <!-- static offset -->
    <img class="background" src="images/landscape-4.png" alt="" />
  </div>

  <div class="landscape-3 plane">
    <div class="spacer"></div>
    <img class="background" src="images/landscape-3.png" alt="" />
  </div>

  <div class="landscape-2 plane">
    <div class="spacer"></div>
    <img class="background" src="images/landscape-2.png" alt="" />
  </div>

  <div class="landscape-1 plane">
    <div class="spacer"></div>
    <img class="background" src="images/landscape-1.png" alt="" />
  </div>
</div>
#demo {
  --max-offset: 240px;
  width: 100%;
  height: 100%;
  overflow: hidden;
}

#demo .plane {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

#demo .plane .spacer,
#demo .plane .background {
  transition-duration: 0.25s;
  transition-timing-function: linear;
}

#demo .plane .background {
  pointer-events: none;
  width: 100%;
  height: calc(100% + var(--max-offset));
  object-fit: cover;
  overflow-clip-margin: unset;
  transition-property: transform;
  transform-origin: 50% 65%;
}

#demo .plane .spacer {
  transition-property: height;
  min-height: 0px;
}

/* Per-plane transforms */
#demo .landscape-4 .background {
  margin-top: 0;
  transform: scale(calc(0.05 * var(--lisn-js--zoom-delta-z, 1) + 0.95));
}

#demo .landscape-3 .background {
  margin-top: calc(-1 * var(--max-offset) / 3);
  transform: scale(calc(0.2 * var(--lisn-js--zoom-delta-z, 1) + 0.8));
}

#demo .landscape-3 .spacer {
  height: calc(
    var(--max-offset) / 3 - 1px * var(--lisn-js--scroll-delta-y, 0) / 12
  );
  max-height: calc(var(--max-offset) / 3);
}

#demo .landscape-2 .background {
  margin-top: calc(-1 * var(--max-offset) / 2);
  transform: scale(calc(0.4 * var(--lisn-js--zoom-delta-z, 1) + 0.6));
}

#demo .landscape-2 .spacer {
  height: calc(
    var(--max-offset) / 2 - 1px * var(--lisn-js--scroll-delta-y, 0) / 8
  );
  max-height: calc(var(--max-offset) / 2);
}

#demo .landscape-1 .background {
  margin-top: calc(-1 * var(--max-offset));
  transform: scale(calc(0.6 * var(--lisn-js--zoom-delta-z, 1) + 0.4));
}

#demo .landscape-1 .spacer {
  height: calc(
    var(--max-offset) - 1px * var(--lisn-js--scroll-delta-y, 0) / 4
  );
  max-height: var(--max-offset);
}

/* Misc styles */
#demo h1 {
  text-align: center;
  color: #03505c;
  z-index: 10;
  position: relative;
  font-weight: 400;
  margin-top: 25vh;
  margin-left: 4vw;
  mix-blend-mode: multiply;
}

#license {
  margin: 10px 20px;
  color: #6bb6c4;
  font-size: 14px;
}

Edit on CodePen

<div
  id="demo"
  data-lisn-track-gesture="min-delta-y=0
                           | max-delta-y=960
                           | min-delta-z=1
                           | max-delta-z=2.4"
>
  <h1>Scroll or zoom</h1>

  <div class="landscape-5 plane">
    <!-- static -->
    <img class="background" src="images/landscape-5.png" alt="" />
  </div>

  <div class="landscape-4 plane">
    <!-- static offset -->
    <img class="background" src="images/landscape-4.png" alt="" />
  </div>

  <div class="landscape-3 plane">
    <div class="spacer"></div>
    <img class="background" src="images/landscape-3.png" alt="" />
  </div>

  <div class="landscape-2 plane">
    <div class="spacer"></div>
    <img class="background" src="images/landscape-2.png" alt="" />
  </div>

  <div class="landscape-1 plane">
    <div class="spacer"></div>
    <img class="background" src="images/landscape-1.png" alt="" />
  </div>
</div>
#demo {
  --max-offset: 240px;
  width: 100%;
  height: 100vh;
  overflow: hidden;
}

#demo .plane {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

#demo .plane .spacer,
#demo .plane .background {
  transition-duration: 0.25s;
  transition-timing-function: linear;
}

#demo .plane .background {
  pointer-events: none;
  width: 100%;
  height: calc(100% + var(--max-offset));
  object-fit: cover;
  overflow-clip-margin: unset;
  transition-property: transform;
  transform-origin: 50% 65%;
}

#demo .plane .spacer {
  transition-property: height;
  min-height: 0px;
}

/* Per-plane transforms */
#demo .landscape-4 .background {
  margin-top: 0;
  transform: scale(calc(0.05 * var(--lisn-js--zoom-delta-z, 1) + 0.95));
}

#demo .landscape-3 .background {
  margin-top: calc(-1 * var(--max-offset) / 3);
  transform: scale(calc(0.2 * var(--lisn-js--zoom-delta-z, 1) + 0.8));
}

#demo .landscape-3 .spacer {
  height: calc(
    var(--max-offset) / 3 - 1px * var(--lisn-js--scroll-delta-y, 0) / 12
  );
  max-height: calc(var(--max-offset) / 3);
}

#demo .landscape-2 .background {
  margin-top: calc(-1 * var(--max-offset) / 2);
  transform: scale(calc(0.4 * var(--lisn-js--zoom-delta-z, 1) + 0.6));
}

#demo .landscape-2 .spacer {
  height: calc(
    var(--max-offset) / 2 - 1px * var(--lisn-js--scroll-delta-y, 0) / 8
  );
  max-height: calc(var(--max-offset) / 2);
}

#demo .landscape-1 .background {
  margin-top: calc(-1 * var(--max-offset));
  transform: scale(calc(0.6 * var(--lisn-js--zoom-delta-z, 1) + 0.4));
}

#demo .landscape-1 .spacer {
  height: calc(
    var(--max-offset) - 1px * var(--lisn-js--scroll-delta-y, 0) / 4
  );
  max-height: var(--max-offset);
}

/* Misc styles */
#demo h1 {
  text-align: center;
  color: #03505c;
  z-index: 10;
  position: relative;
  font-weight: 400;
  margin-top: 25vh;
  margin-left: 4vw;
  mix-blend-mode: multiply;
}

#license {
  margin: 10px 20px;
  color: #6bb6c4;
  font-size: 14px;
}

Scroll or zoom