Motiva
Мои коллекции
← каталог

Timeline Rail

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

cssscrollmouse-moverevealbasic0reduced-motion ✓
↕ демо длиннее кадра — прокрутите его

Редактируемые поля

Секция «timeline». Весь контент приходит пропсами — ниже то, что конструктор даст поправить, не трогая код.

ПропЧто этоТип
eyebrowНадпись сверхуtext
titleЗаголовокtext
subОписаниеrichtext
periodsПериоды и событияlist · до 12

Все пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
eyebrow?string"путь"
title?string"Четыре года, четыре решения"
sub?string"Каждое из них можно было принять иначе …
periods?TimelinePeriod[]PERIODS
background?ReactNodeСокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`. Занят — секция снимает собственную заливку, чтобы не спорить за слой.
TimelinePeriod — что внутри
  • label: string— Подпись периода: год, квартал, этап (формат «2024»).
  • events: TimelineEvent[]

Установка

npx shadcn@latest add https://motiva.pages.dev/r/timeline-rail.json
Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import type { ReactNode } from "react";
import { useEffect, useRef } from "react";

export interface TimelineEvent {
  title: string;
  text: string;
  /** Уточнение слева от заголовка (формат «март» или «12.04»). */
  when?: string;
}

export interface TimelinePeriod {
  /** Подпись периода: год, квартал, этап (формат «2024»). */
  label: string;
  events: TimelineEvent[];
}

export interface TimelineRailProps {
  eyebrow?: string;
  title?: string;
  sub?: string;
  periods?: TimelinePeriod[];
  /**
   * Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`.
   * Занят — секция снимает собственную заливку, чтобы не спорить за слой.
   */
  background?: ReactNode;
  /** Какой текст читается поверх фона; приходит из `mount.tone` подставленной записи. */
  tone?: "dark" | "light";
  /** Плотность скрима между фоном и текстом (формат «0.5»); 0 — выключить. */
  scrim?: number;
}

const PERIODS: TimelinePeriod[] = [
  {
    label: "2022",
    events: [
      { when: "март", title: "Двое в переговорке", text: "Собрали прототип за выходные, чтобы перестать спорить словами. Через месяц им пользовались три соседних отдела." },
      { when: "ноябрь", title: "Первый платящий", text: "Продали до того, как написали лендинг. Договор на салфетке пришлось потом переписывать юристу." },
    ],
  },
  {
    label: "2023",
    events: [
      { when: "февраль", title: "Команда из семи", text: "Наняли тех, кто уже пользовался продуктом. Обучение заняло день вместо квартала." },
      { when: "июль", title: "Публичное API", text: "Отдали наружу то, на чём работали сами. Половина интеграций появилась без нашего участия." },
      { when: "декабрь", title: "Тысяча компаний", text: "Отказались от двух крупных клиентов: их требования увели бы продукт в сторону от остальных." },
    ],
  },
  {
    label: "2025",
    events: [
      { when: "апрель", title: "Своё хранилище", text: "Ушли с чужой инфраструктуры. Счёт за месяц упал вчетверо, задержка ответа — вдвое." },
      { when: "октябрь", title: "Открытый исходник ядра", text: "Опубликовали то, что и так копировали. Вопросов в поддержке стало меньше на треть." },
    ],
  },
  {
    label: "2026",
    events: [
      { when: "сейчас", title: "Двенадцать тысяч команд", text: "Растём без отдела продаж. Следующая цель скучная и правильная: не сломать то, что работает." },
    ],
  },
];

/**
 * История по периодам: подпись года ЕДЕТ ВМЕСТЕ с прокруткой, пока в кадре события этого года,
 * и выталкивается подписью следующего.
 *
 * Приём делает `position: sticky`, но работает он только при правильной вложенности, и это
 * ровно то место, где обычно ломается. Наивно вешают липкость на все заголовки внутри ОДНОГО
 * общего контейнера — тогда контейнер для всех один, и подписи просто наслаиваются друг на
 * друга у верхней кромки. Выталкивание берётся не из скрипта, а из того, что каждая подпись
 * заперта в СВОЕЙ группе: доехав до её нижней границы, она уходит вместе с группой.
 * Обработчика прокрутки здесь нет вовсе — считает композитор.
 *
 * Вторая грабля того же приёма: `overflow: hidden` (или `auto`) на ЛЮБОМ предке отменяет
 * липкость молча — без ошибки, просто заголовок едет как обычный. Поэтому секция ничего
 * не клипует: широкие вещи внутри неё обязаны ограничивать себя сами.
 *
 * Линия дорисовывается, а события проявляются на ПРОКРУТОЧНОЙ анимации браузера
 * (`animation-timeline: view()`): прогресс привязан к тому, насколько элемент вошёл в кадр,
 * поэтому он непрерывный и отматывается назад вместе с прокруткой. Наблюдатель, как
 * в `steps-flow`, дал бы ступеньку «уже видно / ещё нет». Подробности и запасной путь —
 * в комментарии у самих правил.
 *
 * Точки на линии выровнены по СЕРЕДИНЕ ПЕРВОЙ СТРОКИ заголовка (`top: 0.5lh`), а не по верху
 * блока: единица `lh` — это его собственная высота строки, поэтому выравнивание переживает
 * и смену кегля, и перенос заголовка на две строки. Константа в пикселях пережила бы только
 * тот кегль, при котором её подобрали.
 */
export function TimelineRail({
  eyebrow = "путь",
  title = "Четыре года, четыре решения",
  sub = "Каждое из них можно было принять иначе — и продукт был бы другим.",
  periods = PERIODS,
  background,
  tone,
  scrim = 0.5,
}: TimelineRailProps) {
  const body = useRef<HTMLDivElement>(null);

  /**
   * Курсор ведёт метку по рельсу и ПРИТЯГИВАЕТСЯ к ближайшему событию: лента перестаёт быть
   * картинкой, по которой можно только скроллить, и отвечает на то, куда человек смотрит.
   *
   * Три правила, без которых это стоило бы дорого. Первое: обработчик указателя не считает
   * НИЧЕГО — он записывает координату и будит цикл; вся математика в кадре. Второе: чтения
   * и записи не перемешаны — в кадре сперва один замер рамки и центров, потом одна запись
   * переменной. Третье: цикл ЗАСЫПАЕТ, когда метка доехала (и когда курсор ушёл): держать
   * rAF ради неподвижной точки — та же утечка, только незаметная.
   *
   * Центры событий меряются не на каждое движение, а на первое пробуждение и на изменение
   * размеров (ResizeObserver): при переносе текста они уезжают, а от движения мыши — нет.
   */
  useEffect(() => {
    const el = body.current;
    if (!el) return;
    // грубому указателю метка не нужна: на тач-экране «наведения» не существует
    const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
    const frozen =
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (!fine || frozen) return;

    const evs = [...el.querySelectorAll<HTMLElement>(".tml-ev")];
    if (!evs.length) return;

    /**
     * Позиция РАСКЛАДКИ, а не рамки на экране. `getBoundingClientRect` учитывает трансформы,
     * а события въезжают с `translate: 0 14px` — центры, снятые в момент появления, оказывались
     * на 14 px ниже, и метка липла мимо точки (замер: 679 вместо 665). `offsetTop` про
     * трансформы не знает и даёт то место, где элемент лежит на самом деле.
     */
    const offsetIn = (node: HTMLElement) => {
      let y = 0;
      let n: HTMLElement | null = node;
      while (n && n !== el) {
        y += n.offsetTop;
        n = n.offsetParent as HTMLElement | null;
      }
      return y;
    };

    let centers: number[] = [];
    let stale = true;
    let clientY = 0;
    let cur = 0;
    let near = -1;
    let raf = 0;

    const frame = () => {
      raf = 0;
      // ФАЗА ЧТЕНИЯ: одна рамка на кадр, центры — только если разъехались
      const box = el.getBoundingClientRect();
      if (stale) {
        centers = evs.map((ev) => {
          const t = ev.querySelector<HTMLElement>(".tml-t");
          if (!t) return 0;
          return offsetIn(t) + parseFloat(getComputedStyle(t).lineHeight) / 2;
        });
        stale = false;
      }
      const y = clientY - box.top;
      let idx = 0;
      for (let i = 1; i < centers.length; i++) {
        if (Math.abs(centers[i]! - y) < Math.abs(centers[idx]! - y)) idx = i;
      }
      const target = centers[idx] ?? 0;
      const d = target - cur;
      cur += d * 0.2;

      // ФАЗА ЗАПИСИ
      el.style.setProperty("--cy", cur.toFixed(1));
      if (idx !== near) {
        if (near >= 0) evs[near]?.removeAttribute("data-near");
        evs[idx]?.setAttribute("data-near", "");
        near = idx;
      }
      if (Math.abs(d) > 0.4) raf = requestAnimationFrame(frame);
      else el.style.setProperty("--cy", target.toFixed(1)); // доехали — цикл засыпает
    };

    const wake = () => {
      if (!raf) raf = requestAnimationFrame(frame);
    };
    const onMove = (e: PointerEvent) => {
      clientY = e.clientY; // в обработчике ни одного чтения раскладки
      wake();
    };
    const onEnter = () => {
      el.style.setProperty("--on", "1");
      wake();
    };
    const onLeave = () => {
      el.style.setProperty("--on", "0");
      if (near >= 0) evs[near]?.removeAttribute("data-near");
      near = -1;
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
    };

    const ro = new ResizeObserver(() => {
      stale = true;
      wake();
    });
    ro.observe(el);
    el.addEventListener("pointermove", onMove);
    el.addEventListener("pointerenter", onEnter);
    el.addEventListener("pointerleave", onLeave);
    return () => {
      ro.disconnect();
      el.removeEventListener("pointermove", onMove);
      el.removeEventListener("pointerenter", onEnter);
      el.removeEventListener("pointerleave", onLeave);
      if (raf) cancelAnimationFrame(raf);
    };
  }, [periods]);

  return (
    <section
      className="tml"
      data-slot-bg={background ? "" : undefined}
      data-tone={tone}
      style={{ textAlign: "start", ["--tml-scrim" as string]: scrim }}
    >
      <style>{`
        .tml { position: relative; isolation: isolate; width: 100%;
          padding-block: var(--ds-section-y, var(--ds-8, 80px));
          background: var(--ds-bg, oklch(0.14 0.02 275));
          color: var(--ds-fg, oklch(0.95 0.01 265));
          font-family: var(--ds-font, system-ui, sans-serif); }
        .tml-in { max-width: var(--ds-container, 1120px); margin-inline: auto;
          padding-inline: var(--ds-gutter, clamp(18px, 5vw, 54px)); }

        .tml-eyebrow { margin: 0 0 var(--ds-3, 12px); text-transform: uppercase;
          font-family: var(--ds-font-mono, ui-monospace, monospace);
          font-size: var(--ds-text-xs, 0.75rem); letter-spacing: 0.14em;
          color: var(--ds-accent, oklch(0.78 0.16 300)); }
        .tml-h { margin: 0; max-width: 20ch; text-wrap: balance;
          font-size: var(--ds-text-xl, 2rem); font-weight: 780; line-height: 1.1;
          letter-spacing: var(--ds-tracking-tight, -0.03em); }
        .tml-sub { margin: var(--ds-3, 12px) 0 0; max-width: 52ch;
          font-size: var(--ds-text-md, 1rem); line-height: var(--ds-leading-body, 1.55);
          color: var(--ds-muted, oklch(0.72 0.02 265)); }

        /* --rail: где проходит линия. От неё же считаются отступ текста и точки —
           одно число двигает всю конструкцию. */
        .tml-body { position: relative; margin-top: var(--ds-7, 54px); --rail: 17px;
          --cy: 0; --on: 0; }

        /* Метка курсора на рельсе. Едет ТРАНСФОРМОМ (композитор), а не сменой top —
           иначе каждое движение мыши пересчитывало бы раскладку всей ленты. */
        .tml-cursor { position: absolute; left: var(--rail); top: 0; width: 30px; height: 30px;
          margin: -15px 0 0 -15px; border-radius: 50%; pointer-events: none;
          translate: 0 calc(var(--cy, 0) * 1px);
          opacity: var(--on, 0);
          background: radial-gradient(circle,
            var(--ds-accent-soft, oklch(0.72 0.16 300 / 0.1)) 0 42%, transparent 72%);
          box-shadow: 0 0 0 1px var(--ds-accent-ring, oklch(0.72 0.16 300 / 0.35)) inset;
          transition: opacity var(--ds-base, 0.32s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }

        /* Событие под указателем: точка крупнее, заголовок в акценте, текст ярче.
           Это и есть ответ на «куда я смотрю» — метка сама по себе только украшение. */
        .tml-ev[data-near] .tml-t { color: var(--ds-accent, oklch(0.78 0.16 300)); }
        .tml-ev[data-near] .tml-x { color: var(--ds-fg, oklch(0.95 0.01 265)); }
        .tml-ev[data-near] .tml-t::before { scale: 1.45; }
        .tml-t, .tml-x { transition: color var(--ds-base, 0.32s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
        .tml-t::before { transition: scale var(--ds-base, 0.32s) var(--ds-ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1)); }
        .tml-body::before { content: ""; position: absolute; left: var(--rail); top: 4px; bottom: 4px;
          width: 2px; border-radius: 2px; translate: -1px 0;
          background: linear-gradient(180deg,
            var(--ds-border, oklch(0.3 0.02 275)) 0 92%, transparent 100%); }

        .tml-group { position: relative; }
        .tml-group + .tml-group { margin-top: var(--ds-5, 24px); }

        /* подпись периода липнет к верхней кромке, но ТОЛЬКО пока её группа в кадре:
           контейнер липкости — .tml-group, а не весь список */
        .tml-year { position: sticky; top: 10px; z-index: 2; width: max-content;
          margin: 0 0 var(--ds-4, 18px); padding: 5px 14px;
          border-radius: var(--ds-radius-pill, 999px);
          background: var(--ds-surface, oklch(0.18 0.02 275));
          border: 1px solid var(--ds-border, oklch(0.3 0.02 275));
          font-family: var(--ds-font-mono, ui-monospace, monospace);
          font-size: var(--ds-text-sm, 0.86rem); font-weight: 600;
          font-variant-numeric: tabular-nums; letter-spacing: 0.04em;
          color: var(--ds-accent, oklch(0.78 0.16 300)); }

        .tml-events { margin: 0; padding: 0 0 var(--ds-6, 36px) calc(var(--rail) + var(--ds-5, 24px));
          list-style: none; display: grid; gap: var(--ds-5, 24px); }
        .tml-ev { position: relative; }

        /* ДОРИСОВКА РЕЛЬСА И ПОЯВЛЕНИЕ СОБЫТИЙ — на прокруточной анимации, которую ведёт
           сам браузер: animation-timeline: view() привязывает прогресс к тому, насколько
           элемент вошёл в кадр. Ни обработчика прокрутки, ни наблюдателя, ни единой строки
           JS — в отличие от steps-flow, где то же ощущение собрано на IntersectionObserver
           и переменной --done. Разница не косметическая: наблюдатель знает только «уже
           видно / ещё нет», поэтому даёт ступеньку, а таймлайн даёт НЕПРЕРЫВНЫЙ прогресс —
           линия тянется ровно за движением пальца и отматывается назад вместе с ним.

           Каждое событие рисует СВОЙ отрезок рельса, а не общая полоса тянется сверху вниз:
           у общей полосы прогресс пришлось бы считать от всего блока, а он выше экрана,
           и «сколько уже пройдено» перестало бы совпадать с тем, что человек видит.

           Всё это внутри @supports: где прокруточных анимаций нет, элементы остаются
           в конечном состоянии (видимые, рельс дорисован), а не пропадают навсегда. */
        @keyframes tml-in { from { opacity: 0; translate: 0 14px; } to { opacity: 1; translate: none; } }
        @keyframes tml-draw { from { scale: 1 0; } to { scale: 1 1; } }
        @supports (animation-timeline: view()) {
          @media (prefers-reduced-motion: no-preference) {
            .tml-ev { animation: tml-in linear both;
              animation-timeline: view(); animation-range: entry 0% entry 92%; }
            .tml-ev::before { content: ""; position: absolute; top: 0; bottom: calc(-1 * var(--ds-5, 24px));
              left: calc(-1 * var(--ds-5, 24px) - 1px); width: 2px; border-radius: 2px;
              transform-origin: top; scale: 1 0;
              background: linear-gradient(180deg,
                var(--ds-accent, oklch(0.78 0.16 300)), var(--ds-accent-2, oklch(0.76 0.14 220)));
              animation: tml-draw linear both;
              animation-timeline: view(); animation-range: entry 0% entry 92%; }
            .tml-group:last-child .tml-ev:last-child::before { bottom: 0; }
          }
        }

        .tml-t { position: relative; margin: 0; font-size: var(--ds-text-lg, 1.25rem);
          font-weight: 660; line-height: var(--ds-leading-tight, 1.15);
          letter-spacing: var(--ds-tracking-tight, -0.03em); }
        /* точка на линии: 0.5lh — середина ПЕРВОЙ строки этого заголовка, а не «примерно 12px» */
        .tml-t::before { content: ""; position: absolute; top: 0.5lh;
          left: calc(-1 * var(--ds-5, 24px)); width: 9px; height: 9px;
          translate: -50% -50%; border-radius: 50%;
          background: var(--ds-accent, oklch(0.78 0.16 300));
          box-shadow: 0 0 0 4px var(--ds-bg, oklch(0.14 0.02 275)); }
        .tml-when { display: block; margin-bottom: 2px;
          font-family: var(--ds-font-mono, ui-monospace, monospace);
          font-size: var(--ds-text-xs, 0.75rem); letter-spacing: 0.06em;
          color: var(--ds-muted, oklch(0.72 0.02 265)); }
        .tml-x { margin: var(--ds-2, 8px) 0 0; max-width: 62ch;
          font-size: var(--ds-text-md, 1rem); line-height: var(--ds-leading-body, 1.55);
          color: var(--ds-muted, oklch(0.72 0.02 265)); }

        /* СОКЕТ ФОНА. Слой на z-index -1 внутри своего stacking context: поверх заливки
           секции, но под содержимым — вёрстке ленты знать о нём не нужно. */
        .tml-bg { position: absolute; inset: 0; z-index: -1; }
        .tml[data-slot-bg] { background: transparent; }
        .tml-bg::after { content: ""; position: absolute; inset: 0; pointer-events: none; }
        /* поверх живого фона ободок точки берёт цвет скрима, а не заливки секции */
        .tml[data-slot-bg] .tml-t::before { box-shadow: none;
          outline: 3px solid var(--ds-bg, oklch(0.14 0.02 275)); outline-offset: -1px; }
        .tml[data-tone="dark"] { --ds-fg: oklch(0.96 0.01 265); --ds-muted: oklch(0.84 0.02 265);
          --ds-border: oklch(0.98 0.01 265 / 0.16); --ds-surface: oklch(0.16 0.02 275 / 0.7);
          color: var(--ds-fg); }
        .tml[data-tone="dark"] .tml-bg::after {
          background: oklch(0.09 0.02 265 / calc(var(--tml-scrim, 0.5) * 0.9)); }
        .tml[data-tone="light"] { --ds-fg: oklch(0.2 0.02 265); --ds-muted: oklch(0.42 0.02 265);
          --ds-border: oklch(0.2 0.02 265 / 0.14); --ds-surface: oklch(1 0 0 / 0.72);
          color: var(--ds-fg); }
        .tml[data-tone="light"] .tml-bg::after {
          background: oklch(0.99 0 0 / calc(var(--tml-scrim, 0.5) * 0.9)); }

        @media (prefers-reduced-motion: reduce) {
          .tml-cursor { display: none; }
          .tml-t, .tml-x, .tml-t::before { transition: none; }
        }
      `}</style>

      {background && <div className="tml-bg">{background}</div>}

      <div className="tml-in">
        {eyebrow && <p className="tml-eyebrow">{eyebrow}</p>}
        <h2 className="tml-h">{title}</h2>
        {sub && <p className="tml-sub">{sub}</p>}

        <div className="tml-body" ref={body}>
          <span className="tml-cursor" aria-hidden="true" />
          {periods.map((p) => (
            <section className="tml-group" key={p.label}>
              <h3 className="tml-year">{p.label}</h3>
              <ol className="tml-events">
                {p.events.map((e) => (
                  <li className="tml-ev" key={e.title}>
                    {e.when && <span className="tml-when">{e.when}</span>}
                    <h4 className="tml-t">{e.title}</h4>
                    <p className="tml-x">{e.text}</p>
                  </li>
                ))}
              </ol>
            </section>
          ))}
        </div>
      </div>
    </section>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0