← каталог
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Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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