← каталог
Path Marquee
Лента, которая едет ПО САМОЙ кривой: буквы стекают в провис, выныривают на гребень и наклоняются по касательной весь путь. startOffset не анимируется CSS — его пишет десяток строк rAF; бесшовность даёт периодичность ТЕКСТА (сдвиг на длину одного набора слов, длина меряется, а не угадывается).
cssautomorphintermediate0reduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| items? | string[] | ["ДВИЖЕНИЕ", "ФОРМА", "РИТМ", "ПАУЗА"] | Слова ленты (формат «["МОТИВА","КАТАЛОГ"]»). |
| separator? | string | "✳" | Разделитель между словами. |
| duration? | number | 16 | Секунд на один период текста (формат «16»). |
| sag? | number | 34 | Глубина провисания в единицах сцены: 0 — прямая, 70 — глубокая дуга (формат «34»). |
| rail? | boolean | true | Показывать саму кривую волоском. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/path-marquee.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 { useEffect, useId, useRef } from "react";
export interface PathMarqueeProps {
/** Слова ленты (формат «["МОТИВА","КАТАЛОГ"]»). */
items?: string[];
/** Разделитель между словами. */
separator?: string;
/** Секунд на один период текста (формат «16»). */
duration?: number;
/** Глубина провисания в единицах сцены: 0 — прямая, 70 — глубокая дуга (формат «34»). */
sag?: number;
/** Показывать саму кривую волоском. */
rail?: boolean;
}
/** Полупериод гирлянды в единицах viewBox. */
const HALF = 400;
/** Запас над гребнем и под впадиной — кадр считается от провиса (грабля arc-marquee). */
const ROOM = 78;
/** Сколько раз повторить набор слов: текста должно хватить на путь плюс один период сдвига. */
const REPEATS = 8;
/**
* Лента, которая едет ПО САМОЙ кривой: буквы стекают в провис, выныривают на гребень
* и наклоняются по касательной на всём пути — в отличие от `arc-marquee`, где по экрану
* едет неподвижная связка «кривая + текст».
*
* Цена приёма честная и записана в паре с ним: `startOffset` у SVG так и не стал
* CSS-свойством, правилом его не проанимировать. Поэтому здесь есть СКРИПТ — десяток
* строк rAF, которые пишут один атрибут. Скрипт обязан за собой ухаживать: спит вне
* кадра и на скрытой вкладке, гаснет при prefers-reduced-motion/data-shot.
*
* Бесшовность другая, чем у arc-marquee: там периодична КРИВАЯ (сдвиг на её период
* неотличим от исходного кадра), здесь периодичен ТЕКСТ — набор слов повторён N раз,
* и когда лента уехала ровно на длину одного набора, k-й набор встал в точности там,
* где был (k−1)-й. Длина набора не угадывается, а МЕРЯЕТСЯ: getComputedTextLength
* делится на число повторов; после загрузки шрифтов замер повторяется — метрики другие.
*/
export function PathMarquee({
items = ["ДВИЖЕНИЕ", "ФОРМА", "РИТМ", "ПАУЗА"],
separator = "✳",
duration = 16,
sag = 34,
rail = true,
}: PathMarqueeProps) {
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
const textRef = useRef<SVGTextElement>(null);
const tpRef = useRef<SVGTextPathElement>(null);
const hostRef = useRef<HTMLDivElement>(null);
const durRef = useRef(duration);
durRef.current = duration;
const baseline = sag + ROOM;
const vbH = 2 * (sag + ROOM);
// гирлянда начинается за левой кромкой: буквы «рождаются» вне кадра
let d = `M ${-HALF} ${baseline}`;
for (let i = -1; i < 4; i++) {
const dir = i % 2 === 0 ? 1 : -1;
d += ` Q ${i * HALF + HALF / 2} ${baseline + dir * 2 * sag} ${(i + 1) * HALF} ${baseline}`;
}
const line = Array.from({ length: REPEATS }, () => items).flat();
useEffect(() => {
const tp = tpRef.current;
const textEl = textRef.current;
const host = hostRef.current;
if (!tp || !textEl || !host) return;
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (frozen) {
tp.setAttribute("startOffset", "0");
return;
}
let seq = 1;
const measure = () => {
const total = textEl.getComputedTextLength();
if (total > 0) seq = total / REPEATS;
};
measure();
// шрифт доехал позже — метрики другие, период надо перемерить
document.fonts?.ready.then(measure).catch(() => {});
let raf = 0;
let off = -seq;
let last = 0;
const step = (now: number) => {
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
last = now;
off += (seq / Math.max(1, durRef.current)) * dt;
if (off >= 0) off -= seq;
tp.setAttribute("startOffset", off.toFixed(2));
raf = requestAnimationFrame(step);
};
const wake = () => {
if (raf) return;
last = 0;
raf = requestAnimationFrame(step);
};
const sleep = () => {
cancelAnimationFrame(raf);
raf = 0;
};
// непрерывной ленте можно жить — но только В КАДРЕ и на видимой вкладке
const io = new IntersectionObserver(([e]) => (e!.isIntersecting ? wake() : sleep()));
io.observe(host);
const onVis = () => (document.hidden ? sleep() : wake());
document.addEventListener("visibilitychange", onVis);
return () => {
sleep();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
};
}, []);
return (
<div className="pm" data-pm={uid} ref={hostRef}>
<style>{`
[data-pm="${uid}"] {
position: relative;
width: 100%;
overflow: clip;
color: var(--ds-fg, oklch(0.95 0.01 265));
font-family: var(--ds-font, system-ui, sans-serif);
}
[data-pm="${uid}"] svg { display: block; width: 100%; height: auto; }
[data-pm="${uid}"] .pm-text {
font-size: 56px; font-weight: 700;
letter-spacing: 1px;
fill: currentColor;
}
[data-pm="${uid}"] .pm-sep { fill: var(--ds-accent, oklch(0.72 0.16 300)); }
[data-pm="${uid}"] .pm-rail {
fill: none; stroke: currentColor; stroke-width: 1.5; opacity: 0.22;
}
`}</style>
<svg viewBox={`0 0 1200 ${vbH}`} preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<path id={`pm-arc-${uid}`} d={d} className={rail ? "pm-rail" : undefined} fill="none" />
<text className="pm-text" ref={textRef}>
<textPath href={`#pm-arc-${uid}`} startOffset="0" ref={tpRef}>
{line.map((t, i) => (
<tspan key={i}>
{` ${t} `}
<tspan className="pm-sep">{separator}</tspan>
</tspan>
))}
</textPath>
</text>
</svg>
{/* декоративный svg скрыт от дикторов; слова отданы текстом один раз */}
<span
style={{
position: "absolute",
width: 1,
height: 1,
margin: -1,
padding: 0,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
border: 0,
}}
>
{items.join(", ")}
</span>
</div>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); парная запись к arc-marquee — тот же textPath, противоположный способ движенияv1.0.0