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

Arc Marquee

Бегущая строка с провисающей базовой линией: слова висят на гирлянде и наклоняются вместе с ней. Форму даёт textPath — буквы встают по касательной сами. Едет не текст по кривой (startOffset так и не стал CSS-свойством), а связка «кривая + текст» вбок, и стык не виден, потому что кривая периодична.

cssautomorphintermediate0reduced-motion ✓
🎛 Настройки — крути и копируй

Пропсы

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

ПропТипПо умолчаниюЧто это
items?string[]["ДВИЖЕНИЕ", "ФОРМА", "РИТМ", "ПАУЗА"]Слова ленты (формат «["МОТИВА","КАТАЛОГ"]»).
separator?string"✳"Разделитель между словами.
duration?number22Секунд на один период ленты (формат «22»).
sag?number40Глубина провисания в единицах сцены: 0 — прямая, 70 — глубокая дуга (формат «40»).
rail?booleantrueПоказывать саму кривую волоском.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/arc-marquee.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 { 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