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

Path Marquee

Лента, которая едет ПО САМОЙ кривой: буквы стекают в провис, выныривают на гребень и наклоняются по касательной весь путь. startOffset не анимируется CSS — его пишет десяток строк rAF; бесшовность даёт периодичность ТЕКСТА (сдвиг на длину одного набора слов, длина меряется, а не угадывается).

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

Пропсы

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

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

Установка

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