Представьте себе не просто «карусель карточек», а временную шкалу, которая уходит в перспективу, карточки выезжают по наклонным линиям, масштабируются как в 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));.

Шаг 2. Превращаем положение ползунка в «скролл по слайдам»

На втором шаге ползунок остаётся тем же, но у него появляется смысл для будущего слайдера. Мы начинаем считать не только проценты, но и условное «расстояние по таймлайну» — scroll value.

Что меняется по сравнению с шагом 1:

  1. Берём реальные данные слайдов
    Из data-slider3d читается sliderData, и по его длине считаем абстрактную ширину таймлайна:

    const SpaceBetweenSlider = 100;
    const sliderWidth = sliderData.length * SpaceBetweenSlider;

    Здесь SpaceBetweenSlider — это не CSS-пиксели, а условное расстояние между слайдами вдоль оси прокрутки (в будущем — по вертикали).

  2. Считаем процент прогресса по треку
    Как и раньше, переводим положение ползунка в значение от 0 до 100:

    const maxOffset = track.clientWidth - thumb.clientWidth;
    status = maxOffset ? Number(((thumbX / maxOffset) * 100).toFixed(2)) : 0;

    Это просто «насколько далеко уехал ползунок по своей дорожке».

  3. Считаем scroll value — виртуальный скролл по слайдам
    Теперь на основе процентов считаем ещё одну величину:

    sliderScrollStatus = Number(((sliderWidth / 100) * status).toFixed(1));

    Логика такая:

    • sliderWidth — это длина всего таймлайна в условных единицах, где каждые SpaceBetweenSlider соответствуют одному слайду.

    • status — прогресс от 0 до 100%.

    • sliderScrollStatusтекущее положение внутри этого виртуального пространства.

    То есть scroll value = «насколько мы продвинулись между слайдами по воображаемой оси прокрутки». Сейчас он отображается в панели переменных как px, но по сути это математическая модель вертикального скролла, которую дальше будем использовать, чтобы двигать реальные слайды.

  4. Выводим обе величины в отладочную панель

    displayVariables([
      { status: `${status}%`, title: "status" },
      { status: `${sliderScrollStatus}px`, title: "scroll value" },
    ]);

    В итоге у нас есть:

    • status — насколько далеко уехал ползунок по треку.

    • scroll value — где мы находимся внутри виртуального ряда слайдов (по расстоянию между ними).

На этом шаге UI ещё не меняется визуально, но у слайдера появляется важная внутренняя ось: от ползунка к абстрактному скроллу по слайдам, на которой дальше будут сидеть 3D-анимации.

Шаг 3. Добавляем инерцию и «реальный» скролл

Демо: 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-анимацию.

Шаг 4. От одной позиции — к набору «живых» слайдов

Демо: 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% и т.п.).

Это пока просто текст, но дальше сюда «подвяжутся» настоящие карточки.

SPACE_SLIDER_DURATION и «окно анимации»

Появляется новая константа:

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,
}));

В итоге на этом шаге мы впервые получаем множество слайдов с индивидуальным прогрессом анимации.

Шаг 5. Настоящие линии-слайды и первые «артефакты оптимизации»

Демо: http://142.111.244.241:3000/timeline3d/step5
Код: https://gitlab.com/alex_kali/education/-/tree/main/src/modules/timeline3d/step5

На этом шаге у нас впервые появляются реальные DOM-элементы слайдов, которые движутся по вертикали, а не просто числа в дебаг-панели.

1. Из данных → непрерывная линейка слайдов

Мы больше не считаем, что карточки идут строго подряд. В данных могут быть «дыры» по позиции, поэтому:

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;
});

2. Прогресс слайдов → CSS-переменные, а не жёсткие стили

В 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 — дальше он пригодится для визуальных эффектов.

3. Считаем только видимые слайды

Ключевой момент этого шага — мы сознательно трогаем только те слайды, которые находятся в «окне видимости», а не все подряд.

Логика такая же, как на предыдущем шаге, но теперь мы не просто собираем массив для дебага, а реально анимируем 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]. Только такие считаем «видимыми».

Это уже маленькая оптимизация: чем больше слайдов в данных, тем дороже каждую анимацию делать по всему списку.

4. «Хвосты» сверху и снизу

Так как мы обновляем --progress только у видимых слайдов, у тех, которые только что вышли за пределы окна, их старое значение --progress остаётся в DOM.
В итоге:

  • В центре всё красиво движется.

  • Сверху и снизу остаются «хвосты» — линии, которые уже не должны быть видны, но всё ещё нарисованы, потому что мы их не трогаем.

Это ожидаемый артефакт на этом шаге: мы оптимизируемся и осознанно не сбрасываем состояния у всех слайдов. Позже это можно решить по-разному: отдельным классом видимости, обнулением прогресса вне окна или вообще удалением невидимых элементов.

Итого, на шаге 5 мы:

  • Построили сплошную шкалу слайдов с позициями.

  • Научились обновлять только видимые слайды через CSS-переменную --progress.

  • Получили первую живую картинку с движущимися «линиями-слайдами».

  • В обмен на производительность пока терпим «хвосты» сверху и снизу — артефакт того, что слайдер ещё не умеет сбрасывать состояние элементов, ушедших из зоны видимости.

Шаг 6. Пытаемся «лениво» показывать слайды через CSS — и ловим лаги

Демо: 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 видимых слайдов) всё начинает дико лагать. Давай разберёмся, почему.

Что я делаю на этом шаге

  1. Первый видимый слайд помечаю классом 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");
}
  1. Считаю только видимые и, как и раньше, обновляю им --progress.

  2. На корневой ноде храню, сколько слайдов сейчас попадает в окно видимости:

root.setAttribute("data-count", `${visibleSlides.length}`);
  1. В 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:

  1. Каждый кадр меняется атрибут data-count

root.setAttribute("data-count", `${visibleSlides.length}`);

Как только атрибут меняется, браузер обязан:

  • пересчитать, какие CSS-правила вообще подходят к этому элементу,

  • применить/отменить соответствующие стили,

  • заново оценить, какие правила касаются всех потомков.

То есть каждое движение ползунка → новый data-count → полный пересчёт матчей сложных селекторов.

  1. Селекторы с цепочками + .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 + ...), тем больше работы.

  1. Это всё происходит на каждом шаге анимации

Мы анимируем слайдер через 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 прикольная концептуально, но в финальной версии я от неё откажусь и заменю на более прямолинейную, но быструю логику:

Шаг 7. Подвешиваем реальные карточки и завязываем анимацию на размеры контейнера

Демо: 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. Вся геометрия и позиционирование описаны в стилях.

Шаг 8. Полный цикл анимации карточки по одному progress

Демо: 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, на одном числовом параметре.

ChatGPT сказал:

Шаг 9. Вводим ease-progress — одну переменную для всей анимации

Демо: http://142.111.244.241:3000/timeline3d/step9
Код: https://gitlab.com/alex_kali/education/-/tree/main/src/modules/timeline3d/step9

На этом шаге сама физика скролла не меняется — всё новое происходит в CSS.
Главная идея: мы вводим вторичную шкалу прогресса --ease-progress и завязываем на неё все визуальные эффекты.

progress → 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.

Где используется ease-progress

Теперь вместо 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), а всё поведение визуально меняется синхронно.

Почему это удобно и что можно «подкинуть» вместо t²

--ease-progress — это по сути крючок для любой функции сглаживания:

Сейчас:

--ease-progress: calc(var(--progress) * var(--progress));

Но ничего не мешает:

  • сделать что-то резче в конце: , t^4 (в CSS — через повторное умножение),

  • смягчить старт/финиш комбинацией min/max/clamp,

  • вообще отказаться от вычислений в CSS и считать easeProgress в JS, а сюда прокидывать готовое число.

Главное: вся остальная логика уже завязана не на «сырое» progress, а на абстрактное «как мы хотим, чтобы время ощущалось». Это позволяет экспериментировать с кривыми анимации, не переписывая ни один transform или opacity — достаточно изменить определение --ease-progress.

Шаг 10. Подключаем таймлайн с подписями — и получаем первую рассинхронизацию

Демо: http://142.111.244.241:3000/timeline3d/step10
Код: https://gitlab.com/alex_kali/education/-/tree/main/src/modules/timeline3d/step10

Что появляется на этом шаге:

1. Разделяем данные на карточки и таймлайн

Теперь data-slider3d — это не просто набор слайдов, а объект:

type SliderPayload = {
  cards: TimelineSlide[];
  timeline: TimelineMeta[];
};
  • cards — события/карточки с position, card, title и т.п.

  • timeline — метки для скроллбара (годы, этапы и т.д.).

Карточкам мы всё так же увеличиваем position на SLIDE_GAP, а для таймлайна используем отдельный рендер.

2. Рисуем «настоящий» таймлайн под слайдером

Появляется отдельная функция:

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 в зависимости от положения ползунка.

3. Слайдер остаётся «физическим» и не линейным

Сами карточки по-прежнему живут в своей оси:

  • их позиции — это position * SPACE_BETWEEN_SLIDER (+ SLIDE_GAP),

  • мы считаем status с инерцией,

  • вокруг status вычисляем окно [activePosition; endPosition],

  • и только для этих позиций считаем progress.

Формально это тоже линейная шкала, но распределение карточек по ней может быть неравномерным (дыры, плотные зоны и т.д.). Логически это «физический» скролл по позициям, а не аккуратная равномерная шкала времени.

4. Где именно возникает рассинхрон

  • Таймлайн снизу двигается строго линейно: --progress от 0 до 1, блоки с подписями распределены равномерно.

  • Карточки сверху появляются и исчезают по своей «позиционной» логике, которая не знает ничего о timeline и не компенсирует неравномерность.

В результате в каких-то местах:

  • карточки уже «приехали» в центр, а подпись таймлайна к ним ещё не доехала,

  • или наоборот — таймлайн показывает середину диапазона, а по факту видны события из одной его части.

На этом шаге я специально оставляю такую рассинхронизацию: слайдер живёт на своей оси, таймлайн — на своей, и это заметно глазом. В следующем шаге эта проблема будет решена через неконстантное (нелинейное) преобразование прогресса.

Шаг 11. Приводим к общему знаменателю «реальный» прогресс и визуальный таймлайн

Демо: 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 и visual

  • real — прогресс в «координатах слайдера», то есть по позициям карточек:

    sliderScrollStatus = (status / sliderWidth) * 100; // 0–100%
    const real = sliderScrollStatus / 100;             // 0–1

    Здесь распределение событий может быть неравномерным: где-то карточки густо, где-то пусто.

  • visual — прогресс в «координатах таймлайна», где метки (timeline) должны быть равномерно разложены по ширине скроллбара, чтобы визуально не было скученных/растянутых блоков.

Задача progress map — научиться переводить realvisual.

Построение карты: buildProgressMap

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 сидят неравномерно, а по визуальной — ровно по четвертям.

Прямое преобразование: realToVisual

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);
}

Что здесь происходит:

  1. Берём текущий real (0–1) — это «где мы находимся» по оси слайдов.

  2. Находим отрезок карты, внутри которого лежит этот real:

    • между map[i].real и map[i+1].real.

  3. Линейно интерполируем по этому отрезку:

    • t — локальный прогресс внутри сегмента,

    • lerp(a.visual, b.visual, t) даёт соответствующее визуальное значение.

Это даёт кусочно-линейную функцию: внутри каждого интервала между метками она линейна, но масштаб отличается в зависимости от того, как далеко стоят метки друг от друга по «реальной» оси.

Обратное преобразование: visualToReal

Функция симметрична:

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 аккуратно стыкует одно с другим.

В результате метки на таймлайне двигаются равномерно и приятно, а карточки при этом всё равно привязаны к своим реальным позициям и плотности данных.

Шаг 12. Делаем таймлайн кликабельным

Демо: 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, инерция, видимые слайды) уже была реализована на прошлых шагах — здесь мы только добавляем ещё один способ управлять тем же самым скроллом, но теперь через клики по таймлайну.

Шаг 13 — добавляем несколько категорий и плавную смену таймлайна

Демо: http://142.111.244.241:3000/timeline3d/step13
Код: https://gitlab.com/alex_kali/education/-/tree/main/src/modules/timeline3d/step13

Ниже — именно про то, как устроена смена категорий.

1. Данные превращаем в набор категорий

Теперь data-slider3d — это массив таймлайнов:

type SliderValue = {
  cards: TimelineSlide[]
  timeline: TimelineMeta[]
}

type SliderPayload = {
  title: string
  value: SliderValue
}[]

То есть у нас несколько наборов:

  • cards — слайды для 3D-части

  • timeline — подписи/линии внизу

  • title — название категории (кнопка сверху)

2. Рисуем список категорий

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() // строим слайды для первой категории

3. Подготовка общего слайдера

buildSlides

При смене категории мы не пересоздаём всю логику, а просто пересобираем набор слайдов:

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)

4. Основная магия — changeCategory(index)

Это ядро механики смены категории.

4.1. Защита и переключение активной кнопки

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')
  • Игнорируем некорректный индекс и повторный клик на текущую категорию.

  • Переключаем класс активной «табки».

4.2. Фиксируем, где сейчас находимся в анимации

const visibleNow = Array.from(currentVisible).sort((a, b) => a - b)
const hasVisible = visibleNow.length > 0
const firstVisiblePos = hasVisible ? visibleNow[0] : activePosition
  • currentVisible — позиции слайдов, которые сейчас реально видны.

  • Берём самый первый (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
    : 0
  • status — текущее положение «камеры» по вертикали (фактически абстрактный scroll).

  • startFirst — начало анимационного окна для этого слайда.

  • rawFirst ∈ [0..1] — где внутри этого окна сейчас находится первый видимый слайд.

Это нужно, чтобы после смены категории анимация не прыгнула, а продолжилась с того же прогресса.

4.3. Создаём «негативную зону» — хвост из слайдов выше экрана

Дальше мы готовим область слайдов с отрицательными позициями, которые будут чуть «над» текущим видимым окном — чтобы сделать плавный переход:

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 })
  }
}

4.4. Переключаем данные категории

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)

4.5. Считаем стартовое status, чтобы анимация не дёргалась

const statusForFirst =
  rawFirst * SPACE_SLIDER_DURATION + (negStart - slidesPerView) * SPACE_BETWEEN_SLIDER

Это ключевой момент:

  • Мы хотим, чтобы первый видимый после переключения слайд имел тот же rawFirst, что и до переключения.

  • Его новая позиция теперь — negStart (отрицательная).

  • Формулой выше мы вычисляем такое status, при котором:

    • окно для позиции negStart начинается в (negStart - slidesPerView) * SPACE_BETWEEN_SLIDER

    • а текущий status находится на rawFirst внутри этого окна.

Результат: анимация продолжается с того же визуального прогресса, просто теперь в кадр заезжают другие слайды.

4.6. Сбрасываем состояние и запускаем анимацию

currentVisible = new Set<number>()
firstPositionCard = undefined

thumbX = 0
sliderScrollStatus = 0
targetStatus = 0
status = statusForFirst

render()
startAnimated()
  • Чистим кеш видимых слайдов и «первую карточку».

  • Обнуляем thumb/target, но ставим status на рассчитанную позицию — переход получается плавным.

  • Вызываем render() и анимация сама дотянет всё нужное.

5. Навешиваем обработчики на категории

categoryElements.forEach((el, index) => {
  el.addEventListener('click', () => changeCategory(index))
})

Каждая «табка» сверху просто вызывает changeCategory(index), а всё остальное — логика плавного переноса текущего состояния и перестройки слайдов.

В итоге на этом шаге смена категории работает так:
при клике мы аккуратно переносим текущую фазу анимации, создаём небольшой хвост из старых слайдов «над экраном», пересобираем карты и таймлайн под новую категорию и продолжаем движение без резкого скачка.

Шаг 14. Перспективная сетка и точная геометрия движения карточек

Демо: http://142.111.244.241:3000/timeline3d/step14
Код: https://gitlab.com/alex_kali/education/-/tree/main/src/modules/timeline3d/step14

1. Как теперь считается горизонтальная позиция карточек

В шаге 13 горизонтальный оффсет карточки был чем-то вроде «произвольного коэффициента» от -1 до 1, который просто подмешивался в формулу смещения. В шаге 14 он стал частью геометрически корректной 3D-сцены.

1.1. Нормализация оффсетов

Сначала мы приводим все возможные варианты оффсета к диапазону [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 = -1t = 0 → крайняя левая траектория,

  • raw = 0t = 0.5 → середина,

  • raw = 1t = 1 → крайняя правая траектория.

Но важно: это не привязка к дискретным позициям, а параметр для последующей интерполяции.

1.2. Интерполяция между линиями сетки

Сама сетка задаётся как набор 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 → уникальная траектория между двумя соседними линиями.

1.3. Как считаются вертикальные линии сетки

Сетка — не просто «на глаз подрисованные наклонные палки». Для каждой линии мы заранее считаем:

  • 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)`
}

По шагам:

  1. Разное расстояние между линиями вверху и внизу
    topGap и bottomGap отличаются — сверху линии ближе друг к другу, снизу — дальше.
    Это и создаёт эффект перспективы: вдали «сходится», ближе — «расходится».

  2. Точные координаты верхней и нижней точки каждой линии

    • topX — где линия пересекает воображаемую верхнюю границу сцены,

    • bottomX — где пересекает нижнюю.

  3. Реальный угол наклона и длина линии
    Зная dx и высоту сцены, считаем:

    • угол: angle = atan(dx / height),

    • длину: len = height / cos(angle) — так, чтобы вертикальная проекция была ровно height.

  4. Применение к DOM-элементу
    Линия ставится нижней точкой в bottomX, поворачивается на нужный угол и получает длину, которая точно дотягивается до верхней точки.

1.4. Почему перспектива получается «1 в 1»

Ключевой момент: карточка движется по той же самой геометрии, по которой построены линии.

  • Сетка задаёт набор отрезков «верх–низ» (topX / bottomX).

  • Для карточки мы берём topX / bottomX, интерполированные между соседними линиями.

  • В CSS карточка линейно едет от topX к bottomX при progress от 0 до 1.

И линии, и карточки используют один и тот же набор координат, поэтому:

  • если посадить карточку строго на линию — её траектория совпадёт с линией,

  • если взять промежуточный оффсет — карточка пойдёт ровно посередине между двумя линиями.

Перспектива не «примерно подходит», а математически согласована.

2. Отдельный слой под сетку

Раньше линия движения и карточки жили в одном слое.
В шаге 14 сетка вынесена в отдельный контейнер:

  • timeline3d__grid — фон с перспективными линиями,

  • timeline3d__slides — сами карточки.

Это упрощает управление:

  • можно отдельно настраивать сетку и карточки,

  • порядок наложения контролируется явно,

  • логика рендера карточек не смешивается с обслуживающей геометрией.

3. Более точная связь скроллбара и таймлайна

Логика сопоставления:

  • положения ползунка,

  • реального прогресса по таймлайну,

  • и визуального прогресса по маркерам

была доработана. Теперь скроллбар лучше учитывает неравномерное распределение событий: движение ощущается как перемещение по реальным точкам таймлайна, а не просто по «линейке от 0 до 100%».

4. Анимация при смене категории

Смена категории теперь сопровождается отдельной анимацией:

  • старый ползунок клонируется и «уезжает» в сторону,

  • новый приезжает с противоположной,

  • на время анимации клики по категориям и шкале блокируются.

За счёт этого смена набора карточек воспринимается как цельная сцена, а не резкий перескок.

5. Прокрутка колесиком мыши

К управлению добавился ещё один способ:

  • при наведении на 3D-таймлайн колесо мыши двигает сцену вперёд/назад,

  • работает и с deltaY, и с deltaX (трекпады),

  • движение сглаживается коэффициентом чувствительности.

Теперь у пользователя три сценария навигации: ползунок, клики по шкале и скролл.

6. Адаптация геометрии под размер экрана

Геометрия сетки завязана на реальную ширину и высоту контейнера:

  • расстояние между линиями сверху/снизу масштабируется под текущую ширину,

  • угол линий пересчитывается при ресайзе,

  • допустимое смещение ползунка тоже заново вычисляется.

Сетка не ломается на узких/широких экранах, а карточки продолжают летать по корректной траектории с сохранённой перспективой.

7. Мелкие UX-улучшения

Параллельно были сделаны небольшие доработки:

  • блокировка категорий и скроллбара на время анимации,

  • аккуратное управление видимостью слайдов,

  • кэширование DOM-элементов для снижения нагрузки.

Они не меняют API, но делают анимацию более стабильной и плавной.

Спасибо, что дочитали эту статью до конца ?

Если вы делаете продукт и вам нужен React-разработчик, который умеет собирать сложные интерактивные интерфейсы (типа такого таймлайна) — можем пообщаться.

Telegram: https://t.me/lexa29031999

Комментарии (0)