<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);
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.