3D-таймлайн на чистом JavaScript: как я собирал этот слайдер по шагам
- понедельник, 17 ноября 2025 г. в 00:00:03
Представьте себе не просто «карусель карточек», а временную шкалу, которая уходит в перспективу, карточки выезжают по наклонным линиям, масштабируются как в 3D-сцене, а под всем этим — настраиваемый скроллбар с годами и плавной анимацией смены категорий. Всё это — без WebGL, только HTML, CSS и JavaScript.
Чтобы сразу было понятно, о чём речь, вот финальный результат, который мы будем разбирать в статье:
демо: http://142.111.244.241:3000/timeline3d/step14
Если у вас сейчас открыт десктопный браузер — покрутите колёсико мыши, поводите ползунок, переключите категории сверху. Обратите внимание на несколько вещей:
Таймлайн не просто линейно скроллится: шкала «на самом деле» нерегулярная, но визуально метки распределены равномерно.
Карточки привязаны к воображаемым линиям сетки, которые сходятся в перспективе — отсюда ощущение глубины.
При переключении категорий не просто меняются данные: плавно анимируется и сам ползунок, и сами слайды.
В этой статье я разберу, как всё это устроено изнутри и как из простого набора дивов и стилей получить нечто похожее на лёгкую 3D-сцену. Код я писал итеративно: всего получилось 14 шагов, и на каждом шаге у меня была рабочая версия слайдера. В финальной части мы придём к тому коду, который сейчас крутится на демо.
О структуре статьи
Я разбил путь от нуля до финального варианта на 14 логичных шагов: от простого «линейного» таймлайна до многокатегорийного 3D-слайдера с метриками. На каждом шаге можно остановиться и получить рабочую версию, если вам не нужен весь «жир» анимаций.
Шаг 1. Живой ползунок и панель переменных
Демо: http://142.111.244.241:3000/timeline3d/step1
Код: https://gitlab.com/alex_kali/education/-/tree/main/src/modules/timeline3d/step1
На первом шаге я вообще не трогаю 3D и карточки — мне важно получить надёжный источник прогресса, от которого потом будет плясать весь таймлайн.
Что здесь происходит:
Верстаю базовый каркас: блоки под категории, будущий слайдер, кастомный скроллбар и панель переменных (.timeline3d__variables).
Делаю кастомный ползунок на pointer events:
при pointerdown запоминаю стартовую позицию, при pointermove считаю смещение dx и обновляю thumbX.
Ограничиваю движение ползунка с помощью clamp, чтобы он не уезжал за края трека.
Положение ползунка конвертирую в абстрактный status от 0 до 100%:
const maxOffset = track.clientWidth - thumb.clientWidth;
status = maxOffset ? Math.round((thumbX / maxOffset) * 100) : 0;Через displayVariables вывожу status внизу — это такая мини-панель для отладки, куда дальше можно будет добавлять другие внутренние параметры.
В CSS использую кастомную переменную --thumb-x, чтобы не трогать left, а просто двигать ползунок через transform: translateX(var(--thumb-x));.
На втором шаге ползунок остаётся тем же, но у него появляется смысл для будущего слайдера. Мы начинаем считать не только проценты, но и условное «расстояние по таймлайну» — scroll value.
Что меняется по сравнению с шагом 1:
Берём реальные данные слайдов
Из data-slider3d читается sliderData, и по его длине считаем абстрактную ширину таймлайна:
const SpaceBetweenSlider = 100;
const sliderWidth = sliderData.length * SpaceBetweenSlider;Здесь SpaceBetweenSlider — это не CSS-пиксели, а условное расстояние между слайдами вдоль оси прокрутки (в будущем — по вертикали).
Считаем процент прогресса по треку
Как и раньше, переводим положение ползунка в значение от 0 до 100:
const maxOffset = track.clientWidth - thumb.clientWidth;
status = maxOffset ? Number(((thumbX / maxOffset) * 100).toFixed(2)) : 0;Это просто «насколько далеко уехал ползунок по своей дорожке».
Считаем scroll value — виртуальный скролл по слайдам
Теперь на основе процентов считаем ещё одну величину:
sliderScrollStatus = Number(((sliderWidth / 100) * status).toFixed(1));Логика такая:
sliderWidth — это длина всего таймлайна в условных единицах, где каждые SpaceBetweenSlider соответствуют одному слайду.
status — прогресс от 0 до 100%.
sliderScrollStatus — текущее положение внутри этого виртуального пространства.
То есть scroll value = «насколько мы продвинулись между слайдами по воображаемой оси прокрутки». Сейчас он отображается в панели переменных как px, но по сути это математическая модель вертикального скролла, которую дальше будем использовать, чтобы двигать реальные слайды.
Выводим обе величины в отладочную панель
displayVariables([
{ status: `${status}%`, title: "status" },
{ status: `${sliderScrollStatus}px`, title: "scroll value" },
]);В итоге у нас есть:
status — насколько далеко уехал ползунок по треку.
scroll value — где мы находимся внутри виртуального ряда слайдов (по расстоянию между ними).
На этом шаге UI ещё не меняется визуально, но у слайдера появляется важная внутренняя ось: от ползунка к абстрактному скроллу по слайдам, на которой дальше будут сидеть 3D-анимации.
Демо: http://142.111.244.241:3000/timeline3d/step3
Код: https://gitlab.com/alex_kali/education/-/tree/main/src/modules/timeline3d/step3
На этом шаге ползунок превращается из «телепорта» в объект с физикой: у нас появляется цель, реальное положение и скорость, которые плавно приводят систему в нужную точку.
Ключевые идеи:
Две позиции вместо одной
targetStatus — куда хотим попасть на таймлайне (рассчитывается из положения ползунка).
status — где слайдер реально находится сейчас.
Оба измеряются в условных «пикселях таймлайна» от 0 до sliderWidth (sliderWidth = количество слайдов * SPACE_BETWEEN_SLIDER).
Скорость и замедление
Мы вычисляем расстояние до цели:
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;
}
}Пока цель далеко — летим с максимальной скоростью MAX_SPEED. Ближе чем SLOWDOWN_RANGE — плавно тормозим.
Ограничение рывков
Чтобы движение не было дёрганым, ограничиваем изменение скорости:
const deltaSpeed = clamp(
desiredSpeed - speed,
-MAX_CHANGE_SPEED,
MAX_CHANGE_SPEED,
);
speed += deltaSpeed;
status = clamp(status + speed, 0, sliderWidth);
MAX_CHANGE_SPEED — это максимум, на сколько мы можем «добавить» или «убрать» скорость за кадр.
Ползунок теперь подчиняется статусу
Пользователь по-прежнему двигает ползунок мышкой (thumbX в onPointerMove), но внутри это только меняет targetStatus.
Само положение ползунка на экране мы пересчитываем из реального статуса:
sliderScrollStatus = (status / sliderWidth) * 100;
root.style.setProperty(
"--thumb-x",
`${(sliderScrollStatus / 100) * maxOffset}px`,
);То есть thumb визуально догоняет «физическую» модель, а не наоборот.
Анимация по кадрам
requestAnimationFrame + FRAME_DURATION ≈ 16.7 ms держат апдейты около 60 FPS:
if (time - lastFrameTime >= FRAME_DURATION) {
lastFrameTime = time;
render();
}Когда разница между targetStatus и status становится совсем маленькой и скорость почти нулевая — мы останавливаем анимацию.
В отладочной панели теперь видно четыре важные величины: цель (targetStatus), текущее положение (realStatus), скорость (speed) и процент прогресса (sliderScrollStatus). Это уже полноценный «движок прокрутки» с инерцией, к которому дальше можно будет подвесить реальные слайды и 3D-анимацию.
Демо: http://142.111.244.241:3000/timeline3d/step4
Код: https://gitlab.com/alex_kali/education/-/tree/main/src/modules/timeline3d/step4
На третьем шаге у нас была только «физика скролла» — одна непрерывная позиция status и инерция.
На четвёртом шаге я добавляю ещё один важный слой: понимание того, какие слайды сейчас «в работе» и на сколько процентов проиграна их анимация.
Теперь displayVariables рисует два блока:
#params — общие параметры движка (targetStatus, realStatus, speed, sliderScrollStatus, activePosition, endPosition).
#slides — список видимых слайдов с их прогрессом (slide_5 — 37% и т.п.).
Это пока просто текст, но дальше сюда «подвяжутся» настоящие карточки.
Появляется новая константа:
const SPACE_SLIDER_DURATION = 1000;Она задаёт длину анимационного окна в тех же условных единицах, что и SPACE_BETWEEN_SLIDER (расстояние между слайдами):
const slidesPerView = Math.round(
SPACE_SLIDER_DURATION / SPACE_BETWEEN_SLIDER,
);При текущих значениях 1000 / 100 = 10, то есть в одном «окне» одновременно участвуют примерно 10 слайдов.
После расчёта физики (та же логика, что в шаге 3) мы добавляем:
activePosition = Math.round(status / SPACE_BETWEEN_SLIDER);
endPosition =
activePosition + Math.round(SPACE_SLIDER_DURATION / SPACE_BETWEEN_SLIDER);activePosition — индекс «центрального» слайда, который соответствует текущему status.
endPosition — последний индекс слайда, который ещё может попадать в наше анимационное окно.
То есть мы превращаем одну непрерывную координату status в диапазон целых индексов слайдов.
Самое интересное — как появляется массив visibleSlides:
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}` });
}Что тут по смыслу:
Для каждого слайда i мы считаем, с какого значения status начинается его анимация:
const start = (i - slidesPerView) * SPACE_BETWEEN_SLIDER;Это точка, где progress этого слайда будет 0.
Потом переводим текущий status в локальный прогресс слайда:
rawProgress = (status - start) / SPACE_SLIDER_DURATION;Если rawProgress в диапазоне [0; 1] — этот слайд сейчас «играет» свою анимацию, и мы запоминаем его progress.
Результат отправляется во вторую панель:
const result = visibleSlides.map(({ progress, title }) => ({
status: `${Math.round(progress * 100)}%`,
title,
}));В итоге на этом шаге мы впервые получаем множество слайдов с индивидуальным прогрессом анимации.
Демо: http://142.111.244.241:3000/timeline3d/step5
Код: https://gitlab.com/alex_kali/education/-/tree/main/src/modules/timeline3d/step5
На этом шаге у нас впервые появляются реальные DOM-элементы слайдов, которые движутся по вертикали, а не просто числа в дебаг-панели.
Мы больше не считаем, что карточки идут строго подряд. В данных могут быть «дыры» по позиции, поэтому:
const slidesPerView = Math.round(SPACE_SLIDER_DURATION / SPACE_BETWEEN_SLIDER);
const maxPosition =
Math.max(...sliderData.map((item) => item.position)) + slidesPerView + 1;
const byPos = new Map<number, TimelineSlide>(
sliderData.map((card) => [card.position, card]),
);
const slides: TimelineSlide[] = Array.from(
{ length: maxPosition + 1 },
(_, pos) => byPos.get(pos) ?? { position: pos },
);Идея:
sliderData — это реальные карточки с их position.
Мы строим сплошную шкалу позиций от 0 до maxPosition.
Для каждой позиции либо берём реальный слайд, либо создаём пустую заглушку { position }.
Дальше через renderSliders рендерим все эти позиции в DOM:
const renderSliders = (data: TimelineSlide[]): string =>
data
.map(
(slide) => `
<div class="slide" data-position="${slide.position}"></div>
`,
)
.join("");И сразу кэшируем ссылку на элемент:
slides.forEach((slide) => {
slide.element =
container.querySelector<HTMLElement>(
`.slide[data-position="${slide.position}"]`,
) ?? undefined;
});В renderCards мы больше не трогаем top, transform и т.п. руками — только прокидываем процент прогресса в CSS-переменную:
const renderCards = (cardsProgress: ProgressData[]): void => {
if (!cardsProgress.length) return;
if (!firstPositionCard) {
firstPositionCard = slides[cardsProgress[0].position];
firstPositionCard.element?.classList.add("card-first");
} else if (firstPositionCard.position !== cardsProgress[0].position) {
firstPositionCard.element?.classList.remove("card-first");
firstPositionCard = slides[cardsProgress[0].position];
firstPositionCard.element?.classList.add("card-first");
}
cardsProgress.forEach(({ progress, position }) => {
const card = slides[position];
card.element?.style.setProperty("--progress", progress.toString());
});
};А всё движение описано в CSS:
.slide {
position: absolute;
width: 100%;
height: 2px;
background: rgba(0,0,0,0.5);
left: 0;
top: 0;
transform: translateY(calc(var(--progress,0) * 800px));
}То есть JS считает только число progress (от 0 до 1), а как именно оно превращается в движение — знает CSS.
Плюс мы отмечаем первый видимый слайд классом card-first — дальше он пригодится для визуальных эффектов.
Ключевой момент этого шага — мы сознательно трогаем только те слайды, которые находятся в «окне видимости», а не все подряд.
Логика такая же, как на предыдущем шаге, но теперь мы не просто собираем массив для дебага, а реально анимируем DOM:
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, position: i });
}Здесь важно:
Мы не проходимся по всем slides, а только по диапазону [activePosition; endPosition].
Для каждого такого i проверяем, попадает ли его rawProgress в диапазон [0; 1]. Только такие считаем «видимыми».
Это уже маленькая оптимизация: чем больше слайдов в данных, тем дороже каждую анимацию делать по всему списку.
Так как мы обновляем --progress только у видимых слайдов, у тех, которые только что вышли за пределы окна, их старое значение --progress остаётся в DOM.
В итоге:
В центре всё красиво движется.
Сверху и снизу остаются «хвосты» — линии, которые уже не должны быть видны, но всё ещё нарисованы, потому что мы их не трогаем.
Это ожидаемый артефакт на этом шаге: мы оптимизируемся и осознанно не сбрасываем состояния у всех слайдов. Позже это можно решить по-разному: отдельным классом видимости, обнулением прогресса вне окна или вообще удалением невидимых элементов.
Итого, на шаге 5 мы:
Построили сплошную шкалу слайдов с позициями.
Научились обновлять только видимые слайды через CSS-переменную --progress.
Получили первую живую картинку с движущимися «линиями-слайдами».
В обмен на производительность пока терпим «хвосты» сверху и снизу — артефакт того, что слайдер ещё не умеет сбрасывать состояние элементов, ушедших из зоны видимости.
Демо: http://142.111.244.241:3000/timeline3d/step6
Код: https://gitlab.com/alex_kali/education/-/tree/main/src/modules/timeline3d/step6
На этом шаге я пробую оптимизировать отображение слайдов: вместо того чтобы руками ходить по всем элементам и менять display, я хочу показывать только нужные слайды через CSS, опираясь на один класс slide__first и атрибут data-count на корневом .timeline3d.
Идея кажется аккуратной, но на больших значениях (порядка 50 видимых слайдов) всё начинает дико лагать. Давай разберёмся, почему.
Первый видимый слайд помечаю классом slide__first:
if (!firstPositionCard) {
firstPositionCard = slides[cardsProgress[0].position];
firstPositionCard.element?.classList.add("slide__first");
} else if (firstPositionCard.position !== cardsProgress[0].position) {
firstPositionCard.element?.classList.remove("slide__first");
firstPositionCard = slides[cardsProgress[0].position];
firstPositionCard.element?.classList.add("slide__first");
}Считаю только видимые и, как и раньше, обновляю им --progress.
На корневой ноде храню, сколько слайдов сейчас попадает в окно видимости:
root.setAttribute("data-count", `${visibleSlides.length}`);В Sass описываю, сколько соседей после slide__first нужно показать, в зависимости от data-count:
$min-visible: 5;
$max-visible: 15;
@function chain($i) {
$sel: "";
@for $k from 1 through $i {
$sel: "#{$sel} + .slide";
}
@return $sel;
}
.timeline3d {
.slide {
display: none;
...
}
@for $visible from $min-visible through $max-visible {
&[data-count='#{$visible}'] {
.slide__first { display: block; }
@for $i from 1 through $visible - 1 {
.slide__first#{chain($i)} { display: block; }
}
}
}
}По-человечески:
если data-count="10", то:
показываем .slide__first,
показываем следующую, следующую, следующую… ещё 9 штук через цепочки вида
.slide__first + .slide,
.slide__first + .slide + .slide,
.slide__first + .slide + .slide + .slide, и так далее.
На небольшом количестве слайдов это выглядит довольно изящно. Но…
Симптом: когда в зоне видимости оказывается ~50 слайдов, анимация начинает резко проседать по FPS.
Причин несколько, и все они связаны с тем, как браузер работает с CSS:
Каждый кадр меняется атрибут data-count
root.setAttribute("data-count", `${visibleSlides.length}`);Как только атрибут меняется, браузер обязан:
пересчитать, какие CSS-правила вообще подходят к этому элементу,
применить/отменить соответствующие стили,
заново оценить, какие правила касаются всех потомков.
То есть каждое движение ползунка → новый data-count → полный пересчёт матчей сложных селекторов.
Селекторы с цепочками + .slide очень тяжёлые
Sass генерирует много вот таких цепочек:
.timeline3d[data-count="10"] .slide__first { display: block; }
.timeline3d[data-count="10"] .slide__first + .slide { display: block; }
.timeline3d[data-count="10"] .slide__first + .slide + .slide { display: block; }
/* ... и так далее */Для visible = V получается:
1 правило для .slide__first
V–1 правил вида .slide__first + .slide + ... + .slide
Итого для одного значения data-count ≈ V правил.
Если у тебя есть диапазон 5…50, то:
для 5 — 4 цепочки,
для 6 — 5 цепочек,
...
для 50 — 49 цепочек,
В сумме это O(V²) по количеству селекторов.
Примерно: 1 + 2 + 3 + ... + (V-1) ≈ V² / 2.
При каждом изменении data-count движок стилей должен:
пробежать по этим десяткам/сотням правил,
проверить для каждого, какие элементы им соответствуют,
обновить display у целевых .slide.
Чем больше слайдов в DOM и чем длиннее цепочки (.slide__first + .slide + .slide + ...), тем больше работы.
Это всё происходит на каждом шаге анимации
Мы анимируем слайдер через requestAnimationFrame, то есть каждый кадр:
status меняется,
набор видимых слайдов чуть двигается,
visibleSlides.length может прыгать (например: 14 → 15 → 16),
мы обновляем data-count,
браузер заново прогоняет матчи по CSS.
В итоге вместо того, чтобы просто:
пройтись по 10–50 видимым слайдам в JS,
у нужных поставить display: block, у остальных — снять,
мы заставляем браузер:
каждый кадр продираться через целую «матрицу» CSS-правил,
проверять сложные селекторы с последовательностями соседей.
На малом количестве слайдов это незаметно, но при ~50 видимых элементов цена такой «красивой» CSS-магии становится очень ощутимой.
Если сказать совсем по-простому:
Пусть V — количество видимых слайдов.
Селекторов у нас примерно V² / 2.
При каждом изменении data-count браузер должен перебрать весь этот «квадрат» селекторов и проверить, кто им соответствует.
То есть:
JS-решение: пройтись по V элементам → условно O(V).
Текущее CSS-решение: куча селекторов, которые в сумме ведут себя как O(V²) по работе.
При V = 50 разница между «прошёлся по 50 элементам» и «по факту прогнал сотни/тысячи селекторных комбинаций» уже отлично чувствуется глазами.
Этот шаг полезен тем, что на нём хорошо видно: слишком умный CSS может убить производительность даже без тяжёлого JS.
Идея с slide__first и data-count прикольная концептуально, но в финальной версии я от неё откажусь и заменю на более прямолинейную, но быструю логику:
Демо: http://142.111.244.241:3000/timeline3d/step7
Код: https://gitlab.com/alex_kali/education/-/tree/main/src/modules/timeline3d/step7
Что именно делаем на этом шаге:
Добавляем карточки внутрь линий-слайдов
В renderSliders теперь для слайдов с данными рисуется разметка:
<div
class="slide"
data-position="…"
style="${slide.card ? `--offset-position: ${slide.card.offset}` : ``}"
>
${slide.card ? `<div class="slide__card"></div>` : ``}
</div>То есть у «настоящих» слайдов появляется блок .slide__card и CSS-переменная --offset-position из данных (card.offset).
Описываем карточку в CSS и используем offset для горизонтального смещения
.slide__card {
width: 250px;
height: 300px;
background: rgba(0,0,0,0.3);
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(
calc(
-50% +
var(--offset-position, 0) *
(var(--slider-width, 0) - 250) / 2 * 1px
)
);
}Здесь --offset-position — условное значение (например, в диапазоне [-1; 1]), а выражение через var(--slider-width) и ширину карточки (250) раскладывает её по горизонтали внутри слайдера. Логика положения полностью в CSS, JS только прокидывает число.
Переводим вертикальное движение на реальные размеры контейнера
Вместо жёстких 800px теперь опираемся на измеренный размер:
В JS:
const updateSliderSize = () => {
const width = sliderEl.offsetWidth
const height = sliderEl.clientHeight
root.style.setProperty('--slider-width', `${width}`)
root.style.setProperty('--slider-height', `${height}`)
}В CSS:
.slide {
transform: translateY(
calc(var(--progress, 0) * var(--slider-height, 0) * 1px)
);
}Высота и ширина слайдера теперь приходят из реального DOM, а не захардкожены — и вертикальная анимация автоматически подстраивается под размер блока.
Сохраняем подход с CSS-переменными
Как и раньше, JS не трогает конкретные top/left/transform у карточек и линий: он только обновляет --progress, --slider-width, --slider-height и --offset-position. Вся геометрия и позиционирование описаны в стилях.
Демо: http://142.111.244.241:3000/timeline3d/step8
Код: https://gitlab.com/alex_kali/education/-/tree/main/src/modules/timeline3d/step8
Что добавлено на этом шаге:
Вертикальная линия теперь тоже анимируется через ::before
.slide:before {
opacity: clamp(0, calc(var(--progress, 0) * 2), 1);
}Линия «вырастает» по мере движения progress от 0 к 1 и больше не выглядит статичной.
Горизонтальное смещение карточки завязано на progress
transform: translateX(
calc(
-50% +
var(--progress, 0) *
var(--offset-position, 0) *
(var(--slider-width, 0) - 250) / 2 * 1px
)
);В начале (progress ≈ 0) все карточки стартуют из центра, дальше уходят влево/вправо к своей целевой позиции, задаваемой --offset-position.
Появление/исчезновение по времени (fade-in / fade-out)
opacity: min(
1,
max(0, calc(var(--progress) * 5)),
max(0, calc((1 - var(--progress)) * 5))
);Приблизительно:
0–0.2 — плавное появление с 0 до 1,
0.2–0.8 — держим 1,
0.8–1 — плавное исчезновение с 1 до 0.
Масштаб карточки через slide__card-inner
.slide__card-inner {
transform: scale(calc(0.6 + var(--progress, 0) * 0.6));
transform-origin: center 100%;
}При progress = 0 карточка уменьшена (0.6), к концу анимации — увеличена (~1.2) и «растёт» от нижнего края.
JS-часть не меняется логически: всё так же считает progress для видимых слайдов и прокидывает его в --progress. Вся визуальная сложность — в CSS, на одном числовом параметре.
Демо: http://142.111.244.241:3000/timeline3d/step9
Код: https://gitlab.com/alex_kali/education/-/tree/main/src/modules/timeline3d/step9
На этом шаге сама физика скролла не меняется — всё новое происходит в CSS.
Главная идея: мы вводим вторичную шкалу прогресса --ease-progress и завязываем на неё все визуальные эффекты.
Вместо того чтобы в каждом месте использовать «сырое» --progress, мы один раз в CSS считаем сглаженное значение:
.slide {
--ease-progress: calc(var(--progress) * var(--progress));
...
}То есть:
--progress — линейный прогресс от 0 до 1, который считает JS.
--ease-progress — результат функции f(t) = t², где t = progress.
Интуитивно:
В начале (0 → 0.5) значение растёт медленнее, чем progress.
Ближе к концу (0.5 → 1) — быстрее.
Анимация получается с мягким стартом и ускорением к финалу.
JS по-прежнему просто делает:
card.element?.style.setProperty('--progress', progress.toString())Все «красивости» происходят уже в CSS.
Теперь вместо var(--progress) почти везде подставляется var(--ease-progress):
.slide {
transform: translateY(
calc(var(--ease-progress, 0) * var(--slider-height, 0) * 1px)
);
}
.slide:before {
opacity: clamp(0, calc(var(--ease-progress, 0) * 2), 1);
}
.slide__card {
transform: translateX(
calc(
-50% +
var(--ease-progress, 0) *
var(--offset-position, 0) *
(var(--slider-width, 0) - 250) / 2 * 1px
)
);
opacity: min(
1,
max(0, calc(var(--ease-progress) * 5)),
max(0, calc((1 - var(--ease-progress)) * 5))
);
}
.slide__card-inner {
transform: scale(calc(0.6 + var(--ease-progress, 0) * 0.6));
}Эффект:
Линия, вертикальный выезд, горизонтальное смещение, прозрачность и масштаб — всё подчиняется одной общей кривой времени.
Мы меняем характер анимации в одном месте (--ease-progress), а всё поведение визуально меняется синхронно.
--ease-progress — это по сути крючок для любой функции сглаживания:
Сейчас:
--ease-progress: calc(var(--progress) * var(--progress));Но ничего не мешает:
сделать что-то резче в конце: t³, t^4 (в CSS — через повторное умножение),
смягчить старт/финиш комбинацией min/max/clamp,
вообще отказаться от вычислений в CSS и считать easeProgress в JS, а сюда прокидывать готовое число.
Главное: вся остальная логика уже завязана не на «сырое» progress, а на абстрактное «как мы хотим, чтобы время ощущалось». Это позволяет экспериментировать с кривыми анимации, не переписывая ни один transform или opacity — достаточно изменить определение --ease-progress.
Демо: http://142.111.244.241:3000/timeline3d/step10
Код: https://gitlab.com/alex_kali/education/-/tree/main/src/modules/timeline3d/step10
Что появляется на этом шаге:
Теперь data-slider3d — это не просто набор слайдов, а объект:
type SliderPayload = {
cards: TimelineSlide[];
timeline: TimelineMeta[];
};cards — события/карточки с position, card, title и т.п.
timeline — метки для скроллбара (годы, этапы и т.д.).
Карточкам мы всё так же увеличиваем position на SLIDE_GAP, а для таймлайна используем отдельный рендер.
Появляется отдельная функция:
export const renderTimeline = (data: TimelineMeta[]): string => {
return data
.map((timeline, blockIndex) => {
const isLastBlock = blockIndex === data.length - 1;
const linesCount = isLastBlock ? 1 : 10;
const linesMarkup = Array.from({ length: linesCount }, (_, lineIndex) => {
if (lineIndex === 0) {
return `
<div class="scrollbar__line scrollbar__line_with-title">
<div class="scrollbar__title">${timeline.title}</div>
</div>
`;
}
return '<div class="scrollbar__line"></div>';
}).join('');
return `
<div class="scrollbar__block">
${linesMarkup}
</div>
`;
})
.join('');
};И в разметке:
<div class="scrollbar" id="timeline3d-scrollbar">
<div class="scrollbar__thumb" id="timeline3d-scrollbar-thumb"></div>
<div class="scrollbar__inner" id="scrollbar-inner">
${renderTimeline(sliderTimeline)}
</div>
</div>CSS двигает этот «пояс» с годами через корневую переменную --progress:
.scrollbar__inner {
transform: translateX(
calc(
var(--progress, 0) *
(-100% + var(--slider-width, 0) * 1px)
)
);
}А в JS:
root.style.setProperty('--progress', `${sliderScrollStatus / 100}`);То есть таймлайн снизу едет линейно по 0…1 в зависимости от положения ползунка.
Сами карточки по-прежнему живут в своей оси:
их позиции — это position * SPACE_BETWEEN_SLIDER (+ SLIDE_GAP),
мы считаем status с инерцией,
вокруг status вычисляем окно [activePosition; endPosition],
и только для этих позиций считаем progress.
Формально это тоже линейная шкала, но распределение карточек по ней может быть неравномерным (дыры, плотные зоны и т.д.). Логически это «физический» скролл по позициям, а не аккуратная равномерная шкала времени.
Таймлайн снизу двигается строго линейно: --progress от 0 до 1, блоки с подписями распределены равномерно.
Карточки сверху появляются и исчезают по своей «позиционной» логике, которая не знает ничего о timeline и не компенсирует неравномерность.
В результате в каких-то местах:
карточки уже «приехали» в центр, а подпись таймлайна к ним ещё не доехала,
или наоборот — таймлайн показывает середину диапазона, а по факту видны события из одной его части.
На этом шаге я специально оставляю такую рассинхронизацию: слайдер живёт на своей оси, таймлайн — на своей, и это заметно глазом. В следующем шаге эта проблема будет решена через неконстантное (нелинейное) преобразование прогресса.
Демо: http://142.111.244.241:3000/timeline3d/step11
Код: https://gitlab.com/alex_kali/education/-/tree/main/src/modules/timeline3d/step11
На шаге 10 таймлайн снизу двигался линейно, а карточки — по своей оси position. В результате подписи (годы/этапы) и реальные события не всегда совпадали по восприятию.
На шаге 11 я ввожу progress map — маленькую карту соответствия между «реальным» прогрессом слайдера и тем, как он должен выглядеть на таймлайне.
real — прогресс в «координатах слайдера», то есть по позициям карточек:
sliderScrollStatus = (status / sliderWidth) * 100; // 0–100%
const real = sliderScrollStatus / 100; // 0–1Здесь распределение событий может быть неравномерным: где-то карточки густо, где-то пусто.
visual — прогресс в «координатах таймлайна», где метки (timeline) должны быть равномерно разложены по ширине скроллбара, чтобы визуально не было скученных/растянутых блоков.
Задача progress map — научиться переводить real ↔ visual.
export function buildProgressMap(markers: TimelineMeta[]): MapNode[] {
if (markers.length < 2) throw new Error("Need at least two year markers");
const sorted = [...markers].sort((a, b) => a.position - b.position);
const lastPos = sorted.at(-1)!.position;
const segments = sorted.length - 1;
return sorted.map(
(m, idx): MapNode => ({
real: m.position / lastPos,
visual: idx / segments,
}),
);
}Идея:
markers — это таймлайн-метки вида { position, title }, где position живёт в той же системе координат, что и карточки.
Сначала сортируем их по position.
real:
нормализуем position в диапазон [0; 1]:
real = m.position / lastPos;это говорит, где по оси слайдера находится эта метка.
visual:
делим весь таймлайн на segments = markers.length - 1 равных кусков;
каждой метке даём visual = idx / segments:
первая → 0,
последняя → 1,
все промежуточные — равномерно между ними.
В итоге MapNode — это табличка вроде:
метка | real | visual |
|---|---|---|
A | 0.00 | 0.0 |
B | 0.15 | 0.25 |
C | 0.60 | 0.5 |
D | 1.00 | 1.0 |
Здесь видно: по «реальной» оси B и C сидят неравномерно, а по визуальной — ровно по четвертям.
export function realToVisual(real: number, map: MapNode[]): number {
if (real <= 0) return 0;
if (real >= 1) return 1;
const i = map.findIndex((n, idx) => real < map[idx + 1].real);
if (i === -1 || i === map.length - 1) return 1;
const a = map[i];
const b = map[i + 1];
const t = (real - a.real) / (b.real - a.real);
return lerp(a.visual, b.visual, t);
}Что здесь происходит:
Берём текущий real (0–1) — это «где мы находимся» по оси слайдов.
Находим отрезок карты, внутри которого лежит этот real:
между map[i].real и map[i+1].real.
Линейно интерполируем по этому отрезку:
t — локальный прогресс внутри сегмента,
lerp(a.visual, b.visual, t) даёт соответствующее визуальное значение.
Это даёт кусочно-линейную функцию: внутри каждого интервала между метками она линейна, но масштаб отличается в зависимости от того, как далеко стоят метки друг от друга по «реальной» оси.
Функция симметрична:
export function visualToReal(visual: number, map: MapNode[]): number {
if (visual <= 0) return 0;
if (visual >= 1) return 1;
const i = map.findIndex((n, idx) => visual < map[idx + 1].visual);
if (i === -1 || i === map.length - 1) return 1;
const a = map[i];
const b = map[i + 1];
const t = (visual - a.visual) / (b.visual - a.visual);
return lerp(a.real, b.real, t);
}Она нужна, когда пользователь будет взаимодействовать со шкалой таймлайна (клики, переходы по годам), а нам нужно уйти обратно в координаты слайдов.
Ключевой момент в шаге 11 — мы больше не двигаем таймлайн напрямую по sliderScrollStatus.
Вместо этого:
sliderScrollStatus = (status / sliderWidth) * 100;
root.style.setProperty(
"--timeline-visual-progress",
`${realToVisual(sliderScrollStatus / 100, progressMap)}`,
);А в CSS:
.scrollbar__thumb {
transform: translateX(
calc(
var(--timeline-visual-progress, 0) *
(var(--scrollbar-width, 0) * 1px - 100%)
)
);
}
.scrollbar__inner {
transform: translateX(
calc(
var(--timeline-visual-progress, 0) *
(var(--scrollbar-width, 0) * 1px - 100%)
)
);
}То есть:
карточки живут в своём «реальном» прогрессе по осям position / status,
таймлайн и бегунок снизу живут в «визуальном» прогрессе,
progress map аккуратно стыкует одно с другим.
В результате метки на таймлайне двигаются равномерно и приятно, а карточки при этом всё равно привязаны к своим реальным позициям и плотности данных.
Демо: http://142.111.244.241:3000/timeline3d/step12
Код: https://gitlab.com/alex_kali/education/-/tree/main/src/modules/timeline3d/step12
На этом шаге логика почти не меняется — мы просто подвязываем клики по таймлайну к скроллу слайдера:
Вешаем обработчик на scrollbar__inner:
const inner = container.querySelector<HTMLDivElement>('#scrollbar-inner')!
inner.addEventListener('click', (e: MouseEvent): void => {
const rect = inner.getBoundingClientRect()
const vProgress = clamp((e.clientX - rect.left) / rect.width, 0, 1)
const realProgress = visualToReal(vProgress, progressMap)
targetStatus = sliderWidth * realProgress
const maxOffset = track.clientWidth - thumb.clientWidth
thumbX = realProgress * maxOffset
sliderScrollStatus = realProgress * 100
startAnimated()
})По клику:
считаем визуальный прогресс внутри таймлайна (vProgress от 0 до 1),
через visualToReal переводим его в реальный прогресс по слайдеру,
обновляем targetStatus, положение ползунка thumbX и sliderScrollStatus,
запускаем плавную анимацию теми же инерционными правилами, что и при перетаскивании.
Вся тяжёлая логика (progress map, инерция, видимые слайды) уже была реализована на прошлых шагах — здесь мы только добавляем ещё один способ управлять тем же самым скроллом, но теперь через клики по таймлайну.
Демо: http://142.111.244.241:3000/timeline3d/step13
Код: https://gitlab.com/alex_kali/education/-/tree/main/src/modules/timeline3d/step13
Ниже — именно про то, как устроена смена категорий.
Теперь data-slider3d — это массив таймлайнов:
type SliderValue = {
cards: TimelineSlide[]
timeline: TimelineMeta[]
}
type SliderPayload = {
title: string
value: SliderValue
}[]То есть у нас несколько наборов:
cards — слайды для 3D-части
timeline — подписи/линии внизу
title — название категории (кнопка сверху)
const renderCategory = (data: SliderPayload): string =>
data
.map((item, index) => `
<div class="timeline3d__category-item ${
index === 0 ? 'timeline3d__category-item_active' : ''
}">${item.title}</div>
`)
.join('')Для каждой категории — своя «табка».
Первая сразу помечается классом timeline3d__category-item_active.
В runScript после разметки:
let activeSliderData = data[0] // текущая категория
let sliderTimeline = activeSliderData.value.timeline
let progressMap = buildProgressMap(sliderTimeline)
inner.innerHTML = renderTimeline(sliderTimeline)
initCards() // строим слайды для первой категорииПри смене категории мы не пересоздаём всю логику, а просто пересобираем набор слайдов:
const buildSlides = (cards: TimelineSlide[]): void => {
slides.clear()
...
// вычисляем minPos / maxPos
// создаём сплошную линию позиций от minPos до maxPosWithTail
// для каждой позиции рендерим <div class="slide" data-position="...">
// сохраняем в Map: position → TimelineSlide (с element)
sliderWidth = ...
}slides хранится как Map<number, TimelineSlide>, а доступ к конкретному слайду идёт через:
const getSlide = (position: number): TimelineSlide | undefined => slides.get(position)Это ядро механики смены категории.
const changeCategory = (index: number): void => {
if (index < 0 || index >= data.length) return
if (data[index] === activeSliderData) return
prevActiveElement.classList.remove('timeline3d__category-item_active')
prevActiveElement = categoryElements[index]
prevActiveElement.classList.add('timeline3d__category-item_active')Игнорируем некорректный индекс и повторный клик на текущую категорию.
Переключаем класс активной «табки».
const visibleNow = Array.from(currentVisible).sort((a, b) => a - b)
const hasVisible = visibleNow.length > 0
const firstVisiblePos = hasVisible ? visibleNow[0] : activePositioncurrentVisible — позиции слайдов, которые сейчас реально видны.
Берём самый первый (firstVisiblePos), при его отсутствии — fallback activePosition.
Дальше мы считаем, как далеко этот слайд прошёл внутри своего окна анимации:
const startFirst = (firstVisiblePos - slidesPerView) * SPACE_BETWEEN_SLIDER
const rawFirst =
(status - startFirst) / SPACE_SLIDER_DURATION >= 0 &&
(status - startFirst) / SPACE_SLIDER_DURATION <= 1
? (status - startFirst) / SPACE_SLIDER_DURATION
: 0status — текущее положение «камеры» по вертикали (фактически абстрактный scroll).
startFirst — начало анимационного окна для этого слайда.
rawFirst ∈ [0..1] — где внутри этого окна сейчас находится первый видимый слайд.
Это нужно, чтобы после смены категории анимация не прыгнула, а продолжилась с того же прогресса.
Дальше мы готовим область слайдов с отрицательными позициями, которые будут чуть «над» текущим видимым окном — чтобы сделать плавный переход:
const negativeZone = (hasVisible ? visibleNow.length : slidesPerView) + ANIMATION_GAP
const negStart = -negativeZone
const usedNeg = new Set<number>()
const negativeSlides: TimelineSlide[] = []negativeZone — сколько слоёв будет над экраном: все текущие видимые + небольшой запас ANIMATION_GAP.
negStart — первая отрицательная позиция (например -15).
Если сейчас есть видимые слайды, мы копируем их в отрицательную область:
if (hasVisible) {
visibleNow.forEach((pos, i) => {
const original = getSlide(pos)
const newPos = negStart + i
usedNeg.add(newPos)
negativeSlides.push(
original ? { ...original, position: newPos, element: undefined } : { position: newPos },
)
})
}Берём каждый видимый слайд, ставим его в новую позицию newPos < 0.
element сбрасываем — DOM будет создан заново в buildSlides.
Визуально это выглядит так, будто старые слайды «улетают вверх» при переключении.
Оставшиеся отрицательные позиции просто забиваем пустышками:
for (let p = negStart; p <= -1; p += 1) {
if (!usedNeg.has(p)) {
negativeSlides.push({ position: p })
}
}activeSliderData = data[index]
sliderTimeline = activeSliderData.value.timeline
progressMap = buildProgressMap(sliderTimeline)
inner.innerHTML = renderTimeline(sliderTimeline)Обновили «активный» набор карт + таймлайн.
Пересобрали progressMap, чтобы таймлайн и слайдер опять были синхронны.
Перерисовали нижний таймлайн (inner.innerHTML).
Формируем новый набор карт:
const baseCards = activeSliderData.value.cards.map((card) => ({
...card,
position: card.position + SLIDE_GAP,
}))
const combined = [...negativeSlides, ...baseCards]Все реальные карты новой категории сдвигаем на SLIDE_GAP, чтобы не прилипать к нулю.
В одном массиве: сначала отрицательные клоны старых слайдов, потом новые карты.
И полностью пересобираем слайдер:
buildSlides(combined)const statusForFirst =
rawFirst * SPACE_SLIDER_DURATION + (negStart - slidesPerView) * SPACE_BETWEEN_SLIDERЭто ключевой момент:
Мы хотим, чтобы первый видимый после переключения слайд имел тот же rawFirst, что и до переключения.
Его новая позиция теперь — negStart (отрицательная).
Формулой выше мы вычисляем такое status, при котором:
окно для позиции negStart начинается в (negStart - slidesPerView) * SPACE_BETWEEN_SLIDER
а текущий status находится на rawFirst внутри этого окна.
Результат: анимация продолжается с того же визуального прогресса, просто теперь в кадр заезжают другие слайды.
currentVisible = new Set<number>()
firstPositionCard = undefined
thumbX = 0
sliderScrollStatus = 0
targetStatus = 0
status = statusForFirst
render()
startAnimated()Чистим кеш видимых слайдов и «первую карточку».
Обнуляем thumb/target, но ставим status на рассчитанную позицию — переход получается плавным.
Вызываем render() и анимация сама дотянет всё нужное.
categoryElements.forEach((el, index) => {
el.addEventListener('click', () => changeCategory(index))
})Каждая «табка» сверху просто вызывает changeCategory(index), а всё остальное — логика плавного переноса текущего состояния и перестройки слайдов.
В итоге на этом шаге смена категории работает так:
при клике мы аккуратно переносим текущую фазу анимации, создаём небольшой хвост из старых слайдов «над экраном», пересобираем карты и таймлайн под новую категорию и продолжаем движение без резкого скачка.
Демо: http://142.111.244.241:3000/timeline3d/step14
Код: https://gitlab.com/alex_kali/education/-/tree/main/src/modules/timeline3d/step14
В шаге 13 горизонтальный оффсет карточки был чем-то вроде «произвольного коэффициента» от -1 до 1, который просто подмешивался в формулу смещения. В шаге 14 он стал частью геометрически корректной 3D-сцены.
Сначала мы приводим все возможные варианты оффсета к диапазону [0, 1]:
function getOffsetNorm(slide: TimelineSlide | undefined): number {
if (!slide) return 0.5
const anySlide: any = slide
const raw =
(anySlide.card && typeof anySlide.card.offset === 'number'
? anySlide.card.offset
: undefined) ??
(typeof anySlide.offset === 'number' ? anySlide.offset : undefined) ??
(typeof anySlide.titleOffset === 'number' ? anySlide.titleOffset : undefined)
if (typeof raw !== 'number' || Number.isNaN(raw)) return 0.5
// из [-1; 1] в [0; 1]
const t = (raw + 1) / 2
return clamp(t, 0, 1)
}Идея простая:
raw = -1 → t = 0 → крайняя левая траектория,
raw = 0 → t = 0.5 → середина,
raw = 1 → t = 1 → крайняя правая траектория.
Но важно: это не привязка к дискретным позициям, а параметр для последующей интерполяции.
Сама сетка задаётся как набор N диагональных линий, у каждой из которых есть:
X-координата в верхней части сцены: lineXTop[i],
X-координата в нижней части сцены: lineXBottom[i].
Карточка не живёт «на одной линии навсегда». Мы интерполируем её положение между двумя соседними линиями в зависимости от нормализованного оффсета:
const maxIdx = GRID_LINES_COUNT - 1
const tOffset = getOffsetNorm(slide) // 0..1
const idxFloat = tOffset * maxIdx // например 2.37
const i0 = idxFloat | 0 // 2
const i1 = i0 === maxIdx ? maxIdx : i0 + 1 // 3
const localT = i0 === i1 ? 0 : idxFloat - i0 // 0.37
const topX = lerp(lineXTop[i0], lineXTop[i1], localT)
const bottomX = lerp(lineXBottom[i0], lineXBottom[i1], localT)Что это даёт:
линии сетки сами по себе дискретны (0, 1, 2, …, N−1),
но карточка может находиться в любой точке между ними, потому что мы берём дробную позицию idxFloat и линейно интерполируем.
Дальше эти topX / bottomX кладём в CSS-переменные:
cardEl.style.setProperty('--timeline3d-card-x-top', String(topX))
cardEl.style.setProperty('--timeline3d-card-x-bottom', String(bottomX))А уже в CSS карточка двигается вдоль прямой между ними:
.slide__card,
.slide__card-title {
transform:
translateX(
calc(
(
var(--timeline3d-card-x-top, 0) +
(var(--timeline3d-card-x-bottom, var(--timeline3d-card-x-top, 0)) - var(--timeline3d-card-x-top, 0))
* var(--ease-progress, 0)
) * 1px
)
)
translateX(-50%);
}Где --ease-progress — прогресс движения по вертикали (0 вверху, 1 внизу). В результате:
при progress = 0 карточка стоит в точке topX,
при progress = 1 — в точке bottomX,
между ними — ровно на отрезке между двумя линиями сетки.
Именно поэтому позиционирование остаётся непрерывным: любой offset → уникальная траектория между двумя соседними линиями.
Сетка — не просто «на глаз подрисованные наклонные палки». Для каждой линии мы заранее считаем:
X вверху (topX),
X внизу (bottomX),
угол наклона,
реальную длину с учётом этого угла.
Ключевой фрагмент:
const scale = Math.min(1, width / GRID_BASE_WIDTH)
const topGap = GRID_TOP_GAP_BASE * scale
const bottomGap = GRID_BOTTOM_GAP_BASE * scale
const center = width / 2
const segments = GRID_LINES_COUNT - 1
const topTotal = topGap * segments
const bottomTotal = bottomGap * segments
const topStart = center - topTotal / 2
const bottomStart = center - bottomTotal / 2
for (let index = 0; index < GRID_LINES_COUNT; index += 1) {
const topX = topStart + topGap * index
const bottomX = bottomStart + bottomGap * index
lineXTop.push(topX)
lineXBottom.push(bottomX)
const dx = bottomX - topX
const angle = Math.atan(dx / height)
const cos = Math.cos(angle)
const len = cos !== 0 ? height / cos : height
line.style.height = `${len}px`
line.style.left = `${bottomX}px`
line.style.transformOrigin = 'bottom center'
line.style.transform = `translateX(-50%) rotate(${-angleDeg}deg)`
}По шагам:
Разное расстояние между линиями вверху и внизу
topGap и bottomGap отличаются — сверху линии ближе друг к другу, снизу — дальше.
Это и создаёт эффект перспективы: вдали «сходится», ближе — «расходится».
Точные координаты верхней и нижней точки каждой линии
topX — где линия пересекает воображаемую верхнюю границу сцены,
bottomX — где пересекает нижнюю.
Реальный угол наклона и длина линии
Зная dx и высоту сцены, считаем:
угол: angle = atan(dx / height),
длину: len = height / cos(angle) — так, чтобы вертикальная проекция была ровно height.
Применение к DOM-элементу
Линия ставится нижней точкой в bottomX, поворачивается на нужный угол и получает длину, которая точно дотягивается до верхней точки.
Ключевой момент: карточка движется по той же самой геометрии, по которой построены линии.
Сетка задаёт набор отрезков «верх–низ» (topX / bottomX).
Для карточки мы берём topX / bottomX, интерполированные между соседними линиями.
В CSS карточка линейно едет от topX к bottomX при progress от 0 до 1.
И линии, и карточки используют один и тот же набор координат, поэтому:
если посадить карточку строго на линию — её траектория совпадёт с линией,
если взять промежуточный оффсет — карточка пойдёт ровно посередине между двумя линиями.
Перспектива не «примерно подходит», а математически согласована.
Раньше линия движения и карточки жили в одном слое.
В шаге 14 сетка вынесена в отдельный контейнер:
timeline3d__grid — фон с перспективными линиями,
timeline3d__slides — сами карточки.
Это упрощает управление:
можно отдельно настраивать сетку и карточки,
порядок наложения контролируется явно,
логика рендера карточек не смешивается с обслуживающей геометрией.
Логика сопоставления:
положения ползунка,
реального прогресса по таймлайну,
и визуального прогресса по маркерам
была доработана. Теперь скроллбар лучше учитывает неравномерное распределение событий: движение ощущается как перемещение по реальным точкам таймлайна, а не просто по «линейке от 0 до 100%».
Смена категории теперь сопровождается отдельной анимацией:
старый ползунок клонируется и «уезжает» в сторону,
новый приезжает с противоположной,
на время анимации клики по категориям и шкале блокируются.
За счёт этого смена набора карточек воспринимается как цельная сцена, а не резкий перескок.
К управлению добавился ещё один способ:
при наведении на 3D-таймлайн колесо мыши двигает сцену вперёд/назад,
работает и с deltaY, и с deltaX (трекпады),
движение сглаживается коэффициентом чувствительности.
Теперь у пользователя три сценария навигации: ползунок, клики по шкале и скролл.
Геометрия сетки завязана на реальную ширину и высоту контейнера:
расстояние между линиями сверху/снизу масштабируется под текущую ширину,
угол линий пересчитывается при ресайзе,
допустимое смещение ползунка тоже заново вычисляется.
Сетка не ломается на узких/широких экранах, а карточки продолжают летать по корректной траектории с сохранённой перспективой.
Параллельно были сделаны небольшие доработки:
блокировка категорий и скроллбара на время анимации,
аккуратное управление видимостью слайдов,
кэширование DOM-элементов для снижения нагрузки.
Они не меняют API, но делают анимацию более стабильной и плавной.
Спасибо, что дочитали эту статью до конца 🙌
Если вы делаете продукт и вам нужен React-разработчик, который умеет собирать сложные интерактивные интерфейсы (типа такого таймлайна) — можем пообщаться.
Telegram: https://t.me/lexa29031999