JS CSS HTML
const SPACE_BETWEEN_SLIDER = 100;
const SPACE_SLIDER_DURATION = 1000;
const MAX_SPEED = 20;
const SLOWDOWN_RANGE = 200;
const MAX_CHANGE_SPEED = 1.2;
const FRAME_DURATION = 1000 / 60;
const displayVariables = (
data: { status: string; title: string }[],
slides: { status: string; title: string }[],
): void => {
const variablesElement = document.querySelector("#params");
const slidesElement = document.querySelector("#slides");
if (!variablesElement || !slidesElement) return;
variablesElement.innerHTML = data
.map(
({ status, title }) => `
<div class="timeline3d__variable">
<span>${title}</span>
<span>${status}</span>
</div>`,
)
.join("");
if (slides) {
slidesElement.innerHTML = slides
.map(
({ status, title }) => `
<div class="timeline3d__variable-small">
<span>${title}</span>
<span>${status}</span>
</div>`,
)
.join("");
}
};
export const runScript = (): void => {
const container = document.getElementById("3dTimeline") as HTMLElement | null;
if (!container) return;
const raw = container.dataset.slider3d;
const sliderData = raw ? JSON.parse(raw) : null;
if (!sliderData) return;
container.innerHTML = `
<div class="timeline3d">
<div class="timeline3d__category"></div>
<div class="timeline3d__slider">
</div>
<div class="scrollbar" id="timeline3d-scrollbar">
<div class="scrollbar__thumb" id="timeline3d-scrollbar-thumb"></div>
</div>
<div class="timeline3d__variables" id="params">
</div>
<div class="timeline3d__variables" id="slides">
</div>
</div>
`;
const track = container.querySelector<HTMLDivElement>(
"#timeline3d-scrollbar",
)!;
const thumb = container.querySelector<HTMLDivElement>(
"#timeline3d-scrollbar-thumb",
)!;
const root = container.querySelector<HTMLDivElement>(".timeline3d")!;
const clamp = (val: number, min: number, max: number) =>
Math.min(Math.max(val, min), max);
let thumbX = 0;
let startX = 0;
let startOffset = 0;
let activeId: number | null = null;
let targetStatus = 0;
const sliderWidth = sliderData.length * SPACE_BETWEEN_SLIDER;
const slidesPerView = Math.round(
SPACE_SLIDER_DURATION / SPACE_BETWEEN_SLIDER,
);
let sliderScrollStatus = 0;
let status = 0;
let speed = 0;
let isAnimated = false;
let lastFrameTime = 0;
let activePosition = 0;
let endPosition = 0;
let visibleSlides;
const startAnimated = () => {
if (!isAnimated) {
isAnimated = true;
lastFrameTime = 0;
requestAnimationFrame(animate);
}
};
const render = (): void => {
const maxOffset = track.clientWidth - thumb.clientWidth;
sliderScrollStatus = maxOffset ? (thumbX / maxOffset) * 100 : 0;
targetStatus = (sliderWidth / 100) * sliderScrollStatus;
const distance = targetStatus - status;
const absDistance = Math.abs(distance);
let desiredSpeed = 0;
if (absDistance > 0) {
desiredSpeed = Math.sign(distance) * MAX_SPEED;
if (absDistance < SLOWDOWN_RANGE) {
desiredSpeed *= absDistance / SLOWDOWN_RANGE;
}
}
const deltaSpeed = clamp(
desiredSpeed - speed,
-MAX_CHANGE_SPEED,
MAX_CHANGE_SPEED,
);
speed += deltaSpeed;
status = clamp(status + speed, 0, sliderWidth);
sliderScrollStatus = (status / sliderWidth) * 100;
root.style.setProperty(
"--thumb-x",
`${(sliderScrollStatus / 100) * maxOffset}px`,
);
if (absDistance < 0.5 && Math.abs(speed) < 0.05) {
status = targetStatus;
speed = 0;
}
activePosition = Math.round(status / SPACE_BETWEEN_SLIDER);
endPosition =
activePosition + Math.round(SPACE_SLIDER_DURATION / SPACE_BETWEEN_SLIDER);
visibleSlides = [];
for (let i = activePosition; i <= endPosition; i += 1) {
const start = (i - slidesPerView) * SPACE_BETWEEN_SLIDER;
const rawProgress = (status - start) / SPACE_SLIDER_DURATION;
if (rawProgress > 1 || rawProgress < 0) continue;
const progress = Math.round(rawProgress * 1000) / 1000;
visibleSlides.push({ progress, title: `slide_${i}` });
}
const result = visibleSlides.map(({ progress, title }) => ({
status: `${Math.round(progress * 100)}%`,
title,
}));
displayVariables(
[
{ title: "targetStatus", status: `${targetStatus.toFixed(2)}px` },
{ title: "realStatus", status: `${status.toFixed(2)}px` },
{ title: "speed", status: `${speed.toFixed(2)}px` },
{
title: "sliderScrollStatus",
status: `${sliderScrollStatus.toFixed(2)}%`,
},
{ title: "activePosition", status: `${activePosition}` },
{ title: "endPosition", status: `${endPosition}` },
],
result,
);
};
const animate = (time: number) => {
if (time - lastFrameTime >= FRAME_DURATION) {
lastFrameTime = time;
render();
}
if (Math.abs(targetStatus - status) > 0.01 || Math.abs(speed) > 0.01) {
requestAnimationFrame(animate);
} else {
speed = 0;
isAnimated = false;
}
};
const onPointerMove = (e: PointerEvent) => {
const dx = e.clientX - startX;
const maxOffset = track.clientWidth - thumb.clientWidth;
thumbX = clamp(startOffset + dx, 0, maxOffset);
startAnimated();
};
const onPointerUp = () => {
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
if (activeId !== null) {
thumb.releasePointerCapture(activeId);
activeId = null;
}
};
thumb.addEventListener("pointerdown", (e: PointerEvent) => {
activeId = e.pointerId;
thumb.setPointerCapture(activeId);
startX = e.clientX;
startOffset = thumbX;
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
});
startAnimated();
};
Высчитали активный слайд, добавили параметр SPACE_SLIDER_DURATION длину прокрутки слайдера, условно слайдер будет анимироваться от 0 до 1000 или от 100 до 1100 или от 200 до 1200 и тд, высчитали прогресс для каждого слайда.