<div class="page">
  <svg class="svg">
    <defs>
      <mask id="pathMask">
        <path class="path-mask" />
      </mask>
    </defs>
    <path class="path path--bottom" />
    <path class="path path--top" mask="url(#pathMask)" />
    <circle class="path-follower" r="20"/>
  </svg>

  <div class="container">
    <section class="section section--1">
      <div class="point" data-index="101"></div>
      <div class="point" data-index="102"></div>
      <div class="point" data-index="103"></div>
      <div class="point" data-index="104"></div>
      <div class="point" data-index="105"></div>
      <div class="point" data-index="106"></div>
      <div class="point" data-index="107"></div>
      <div class="point" data-index="108"></div>
      <div class="point" data-index="109"></div>
    </section>

    <section class="section section--2">
      <div class="point" data-index="201"></div>
      <div class="point" data-index="202"></div>
      <div class="point" data-index="203"></div>
      <div class="point" data-index="204"></div>
    </section>

    <section class="section section--3">
      <div class="point" data-index="301"></div>
      <div class="point" data-index="302"></div>
      <div class="point" data-index="303"></div>
      <div class="point" data-index="304"></div>
    </section>

    <section class="section section--4">
      <div class="point" data-index="401"></div>
      <div class="point" data-index="402"></div>
      <div class="point" data-index="403"></div>
      <div class="point" data-index="404"></div>
    </section>

    <section class="section section--5">
      <div class="point" data-index="501"></div>
      <div class="point" data-index="502"></div>
      <div class="point" data-index="503"></div>
      <div class="point" data-index="504"></div>
    </section>
  </div>
</div>

<div class="scroll">
  <div class="scroll-spacer"></div>
</div>
body {
  margin: 0;
  position: relative;
}

.page {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  overflow: hidden;
}

.scroll {
  position: fixed;
  right: 0;
  width: 20px;
  height: 100vh;
  overflow: auto;
}

.svg {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 999;
  pointer-events: none;
}

.path {
  fill: none;
  stroke: fuchsia;
  stroke-width: 2px;
  stroke-dasharray: 12px;
}

.path--top {
  stroke: #000;
  stroke-width: 3px;
}

.path-mask {
  stroke: #fff;
  stroke-width: 4px;
}

.container {
  width: 80%;
  margin: 0 auto;
  padding: 50px 0;
}

.section {
  position: relative;
  height: 75vh;
  min-height: 500px;
}

.section--1 {
  background-color: #673ab7;
}

.section--2 {
  background-color: #2196f3;
}

.section--3 {
  background-color: #009688;
}

.section--4 {
  background-color: #4caf50;
}

.section--5 {
  background-color: #ffc107;
}

.point {
  position: absolute;
  width: 12px;
  height: 12px;
  background-color: #000;
  border-radius: 50%;
  transform: translate(-50%, -50%);
  opacity: 0.5;
}

.point[data-index="101"] {
  left: 100%;
  top: 0;
}

.point[data-index="102"] {
  left: 50%;
  top: 30px;
}

.point[data-index="103"] {
  left: 10%;
  top: -10px;
}

.point[data-index="104"] {
  left: 0%;
  top: 10%;
}

.point[data-index="105"] {
  left: 30px;
  top: 50%;
}

.point[data-index="106"] {
  left: -10px;
  top: 90%;
}

.point[data-index="107"] {
  left: 10%;
  top: 100%;
}

.point[data-index="108"] {
  left: 40%;
  top: calc(100% - 30px);
}

.point[data-index="109"] {
  left: 90%;
  top: 100%;
}

.point[data-index="201"] {
  left: 100%;
  top: 15%;
}

.point[data-index="202"] {
  left: 85%;
  top: 50%;
}

.point[data-index="203"] {
  left: 110%;
  top: 90%;
}

.point[data-index="204"] {
  left: 85%;
  top: 100%;
}
import VirtualScroll from "https://cdn.skypack.dev/virtual-scroll@2.2.1";

const lerp = (a, b, t) => a * (1 - t) + b * t;

const page = { el: document.querySelector(".page") };

const scroll = {
  el: document.querySelector(".scroll"),
  spacer: document.querySelector(".scroll-spacer"),
  scrollX: 0,
  scrollY: 0,
  scrollSmoothX: 0,
  scrollSmoothY: 0,
  targetX: 0,
  targetY: 0
};

const scroller = new VirtualScroll();

const svg = document.querySelector(".svg");
const pathFollower = svg.querySelector(".path-follower");

const path = {
  topPath: document.querySelector(".path--top"),
  bottomPath: document.querySelector(".path--bottom"),
  maskPath: document.querySelector(".path-mask"),
  totalLength: 0,
  offsetLength: 0
};

const svgRect = { x: 0, y: 0, width: 0, height: 0 };

const points = Array.from(document.querySelectorAll(".point"), (el) => ({
  el,
  index: Number(el.dataset.index),
  x: 0,
  y: 0,
  width: 0,
  height: 0
}));

points.sort((a, b) => a.index - b.index);

function onResize() {
  const svgR = svg.getBoundingClientRect();
  svgRect.x = svgR.x + scroll.scrollX;
  svgRect.y = svgR.y + scroll.scrollY;
  svgRect.width = svgR.width;
  svgRect.height = svgR.height;

  points.forEach((p) => {
    const rect = p.el.getBoundingClientRect();
    p.x = rect.x + scroll.scrollX;
    p.y = rect.y + scroll.scrollY;
    p.width = rect.width;
    p.height = rect.height;
  });

  updatePath(points);

  scroll.spacer.style.height = `${page.el.offsetHeight}px`;
}

onResize();
window.addEventListener("resize", onResize);

function buildPath(points) {
  let pathData = `M ${points[0].x} ${points[0].y}`;

  for (let i = 1; i < points.length - 1; i++) {
    const x = (points[i].x + points[i + 1].x) / 2;
    const y = (points[i].y + points[i + 1].y) / 2;
    pathData += `Q ${points[i].x} ${points[i].y} ${x} ${y}`;
  }

  return pathData;
}

function updatePath(points) {
  const pathData = buildPath(points);
  path.topPath.setAttributeNS(null, "d", pathData);
  path.bottomPath.setAttributeNS(null, "d", pathData);
  path.maskPath.setAttributeNS(null, "d", pathData);
  path.totalLength = path.topPath.getTotalLength();

  path.maskPath.setAttributeNS(null, "stroke-dasharray", path.totalLength);
  updatePathProgress();
}

function updatePathProgress() {
  path.maskPath.setAttributeNS(
    null,
    "stroke-dashoffset",
    path.totalLength * (1 - path.offsetLength)
  );
}

function onScroll(force = false) {
  if (!scroll.needsUpdate && !force) {
    return;
  }

  const t =
    scroll.scrollSmoothY / (scroll.el.scrollHeight - scroll.el.offsetHeight);
  path.offsetLength = t;
  updatePathProgress();

  const p = path.topPath.getPointAtLength(t * path.totalLength);
  // scroll.scrollX = Math.max(0, p.x - window.innerWidth / 2);
  scroll.scrollY = Math.max(0, p.y - window.innerHeight / 2);

  page.el.style.transform = `translate(0, ${-scroll.scrollY}px)`;

  pathFollower.setAttributeNS(null, "cx", p.x);
  pathFollower.setAttributeNS(null, "cy", p.y);
}

scroll.el.addEventListener("scroll", () => {
  scroll.targetX = scroll.el.scrollLeft;
  scroll.targetY = scroll.el.scrollTop;
});

onScroll(true);

scroller.on(({ deltaX, deltaY }) => {
  const x = Math.max(0, Math.min(scroll.el.scrollWidth, scroll.targetX - deltaX));
  const y = Math.max(0, Math.min(scroll.el.scrollHeight, scroll.targetY - deltaY));
  scroll.targetX = x;
  scroll.targetY = y;
  scroll.el.scrollLeft = x;
  scroll.el.scrollTop = y;
});

function rafLoop(now) {
  scroll.needsUpdate = true;
  scroll.scrollSmoothX = lerp(scroll.scrollSmoothX, scroll.targetX, 0.05);
  scroll.scrollSmoothY = lerp(scroll.scrollSmoothY, scroll.targetY, 0.05);

  if (Math.abs(scroll.scrollSmoothX - scroll.targetX) < 0.5) {
    scroll.scrollSmoothX = scroll.targetX;
  }

  if (Math.abs(scroll.scrollSmoothY - scroll.targetY) < 0.5) {
    scroll.scrollSmoothY = scroll.targetY;
  }

  if (
    scroll.scrollSmoothX - scroll.targetX === 0 &&
    scroll.scrollSmoothY - scroll.targetY === 0
  ) {
    scroll.needsUpdate = false;
  }

  onScroll();
  requestAnimationFrame(rafLoop);
}
requestAnimationFrame(rafLoop);

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.