Arc Marquee
Бегущая строка с провисающей базовой линией: слова висят на гирлянде и наклоняются вместе с ней. Форму даёт textPath — буквы встают по касательной сами. Едет не текст по кривой (startOffset так и не стал CSS-свойством), а связка «кривая + текст» вбок, и стык не виден, потому что кривая периодична.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| items? | string[] | ["ДВИЖЕНИЕ", "ФОРМА", "РИТМ", "ПАУЗА"] | Слова ленты. |
| separator? | string | "✳" | Разделитель между словами. |
| duration? | number | 22 | Секунд на один период ленты. |
| sag? | number | 40 | Глубина провисания в единицах сцены: 0 — прямая, 70 — глубокая дуга. |
| rail? | boolean | true | Показывать саму кривую волоском. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/arc-marquee.jsonЧто нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useId } from "react";
export interface ArcMarqueeProps {
/** Слова ленты. */
items?: string[];
/** Разделитель между словами. */
separator?: string;
/** Секунд на один период ленты. */
duration?: number;
/** Глубина провисания в единицах сцены: 0 — прямая, 70 — глубокая дуга. */
sag?: number;
/** Показывать саму кривую волоском. */
rail?: boolean;
}
/** Полупериод гирлянды в единицах viewBox. Полный период — вдвое больше. */
const HALF = 400;
/**
* Запас над гребнем и под впадиной. Кадр считается ОТ ПРОВИСА, а не задан числом: при
* глубоком провисе гребень уходит выше верхней кромки, и первая версия срезала буквам
* верхушки — видно это было только на крайних значениях крутилки.
*/
const ROOM = 78;
/**
* Бегущая строка, у которой базовая линия ПРОВИСАЕТ: слова висят на гирлянде и наклоняются
* вместе с ней, а не едут по линейке.
*
* Держит это `<textPath>`: текст берёт форму у произвольной кривой, и каждая буква сама
* встаёт по касательной — ни одного поворота руками, ни одного замера. Кривая живёт
* в координатах `viewBox`, поэтому при любой ширине окна провис остаётся тем же
* и пересчитывать нечего.
*
* Как это едет — и почему без скрипта. Сдвинуть текст ВДОЛЬ пути можно только через
* `startOffset`, а он у SVG остался атрибутом и CSS-свойством так и не стал: его нельзя
* ни проанимировать правилом, ни погасить медиазапросом при `prefers-reduced-motion`.
* Поэтому едет здесь не текст по кривой, а вся связка «кривая + текст» вбок — обычным
* `translateX`, который CSS умеет и гасить, и тормозить.
*
* Стык не виден потому, что кривая ПЕРИОДИЧНА: гирлянда собрана из одинаковых провисов,
* и сдвиг ровно на период даёт кадр, неотличимый от исходного. Это и есть цена приёма:
* с одиночной дугой (а не с повторяющейся) фокус не работает — уехав, она обнажит края.
* Поэтому провис задаётся числом, а форма собирается из него, а не рисуется руками.
*/
export function ArcMarquee({
items = ["ДВИЖЕНИЕ", "ФОРМА", "РИТМ", "ПАУЗА"],
separator = "✳",
duration = 22,
sag = 40,
rail = true,
}: ArcMarqueeProps) {
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
const baseline = sag + ROOM;
const vbH = 2 * (sag + ROOM);
// гирлянда: провис вниз, провис вверх, и так далее. Управляющая точка квадратичной
// кривой лежит вдвое глубже провиса — середина такой кривой приходит ровно на sag.
let d = `M ${-2 * HALF} ${baseline}`;
for (let i = -2; i < 6; i++) {
const dir = i % 2 === 0 ? 1 : -1;
d += ` Q ${i * HALF + HALF / 2} ${baseline + dir * 2 * sag} ${(i + 1) * HALF} ${baseline}`;
}
// текста должно хватить на путь целиком: не хватит — лента оборвётся на полуслове,
// лишнее textPath просто не нарисует
const line = Array.from({ length: 6 }, () => items).flat();
return (
<div className="am" data-am={uid}>
<style>{`
[data-am="${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-am="${uid}"] svg { display: block; width: 100%; height: auto; }
[data-am="${uid}"] .am-text {
font-size: 56px; font-weight: 700;
letter-spacing: 1px;
fill: currentColor;
}
[data-am="${uid}"] .am-sep { fill: var(--ds-accent, oklch(0.72 0.16 300)); }
[data-am="${uid}"] .am-rail {
fill: none; stroke: currentColor; stroke-width: 1.5; opacity: 0.22;
}
/* сдвиг ровно на период: кадр после него неотличим от исходного */
@keyframes am-run-${uid} { to { transform: translateX(${-2 * HALF}px); } }
@media (prefers-reduced-motion: no-preference) {
[data-am="${uid}"] .am-track {
animation: am-run-${uid} ${duration}s linear infinite;
}
}
[data-shot] [data-am="${uid}"] .am-track { animation: none; }
`}</style>
<svg viewBox={`0 0 1200 ${vbH}`} preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<g className="am-track">
<path id={`am-arc-${uid}`} d={d} className={rail ? "am-rail" : undefined} fill="none" />
<text className="am-text">
<textPath href={`#am-arc-${uid}`} startOffset="0">
{line.map((t, i) => (
<tspan key={i}>
{` ${t} `}
<tspan className="am-sep">{separator}</tspan>
</tspan>
))}
</textPath>
</text>
</g>
</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>
);
}