← каталог
Arc Marquee
Бегущая строка с провисающей базовой линией: слова висят на гирлянде и наклоняются вместе с ней. Форму даёт textPath — буквы встают по касательной сами. Едет не текст по кривой (startOffset так и не стал CSS-свойством), а связка «кривая + текст» вбок, и стык не виден, потому что кривая периодична.
cssautomorphintermediate0reduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| items? | string[] | ["ДВИЖЕНИЕ", "ФОРМА", "РИТМ", "ПАУЗА"] | Слова ленты (формат «["МОТИВА","КАТАЛОГ"]»). |
| separator? | string | "✳" | Разделитель между словами. |
| duration? | number | 22 | Секунд на один период ленты (формат «22»). |
| sag? | number | 40 | Глубина провисания в единицах сцены: 0 — прямая, 70 — глубокая дуга (формат «40»). |
| rail? | boolean | true | Показывать саму кривую волоском. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/arc-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 { useId } from "react";
export interface ArcMarqueeProps {
/** Слова ленты (формат «["МОТИВА","КАТАЛОГ"]»). */
items?: string[];
/** Разделитель между словами. */
separator?: string;
/** Секунд на один период ленты (формат «22»). */
duration?: number;
/** Глубина провисания в единицах сцены: 0 — прямая, 70 — глубокая дуга (формат «40»). */
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>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — https://codepen.io/joebentaylor/pen/RNPvGGE (лицензия не указана, код не заимствован)v1.0.0