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

Assemble Heading

Заголовок собирается по строкам: каждая въезжает из-под своей маски со сдвигом по времени. Строки живут в обычном потоке и клипуют сами себя — поэтому не наезжают и переживают любой перенос по ширине.

gsapscrollclickstaggerbasic~23 КБreduced-motion ✓
↗
↕ демо длиннее кадра — прокрутите его

Пропсы

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

ПропТипПо умолчаниюЧто это
lines?string[]DEFAULT_LINESСтроки заголовка. Каждая едет своей маской, со сдвигом по времени.
sub?string"Каждая строка клипует сама себя и живёт…
stagger?number0.14Задержка между строками, с.
duration?number0.9Длительность въезда строки, с.
replay?booleantrue

Установка

npx shadcn@latest add https://motiva.pages.dev/r/assemble-heading.json

зависимости: gsap@^3.15.0

Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.json.

    Приехавший рядом .tsx можно не трогать — вам нужен .vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import { useEffect, useRef } from "react";
import gsap from "gsap";
const css = `/* Заголовок собирается по строкам: каждая въезжает из-под своей маски. */

.ash {
  width: 100%;
  padding: var(--ds-5, 24px) var(--ds-4, 18px);
  font-family: var(--ds-font, system-ui, -apple-system, sans-serif);
  color: var(--ds-fg, oklch(0.92 0.01 265));
}

.ash-h {
  max-width: 720px;
  margin: 0 auto;
  font-size: clamp(2rem, 1rem + 5vw, 4.2rem);
  font-weight: 680;
  line-height: 1.06;
  letter-spacing: var(--ds-tracking-display, -0.045em);
}

/* Маска строки. Строки лежат в НОРМАЛЬНОМ потоке, друг под другом, и клипует каждая
   сама себя — поэтому они не наезжают и не требуют ни абсолютного позиционирования,
   ни замера высоты. Клип по вертикали, значит именно \`hidden\`, а не \`clip-x\`. */
.ash-line {
  display: block;
  overflow: hidden;
}

.ash-line > span {
  display: block;
  will-change: transform;
}

.ash-sub {
  max-width: 46ch;
  margin: var(--ds-4, 18px) auto 0;
  font-size: var(--ds-text-md, 1rem);
  line-height: 1.55;
  color: var(--ds-muted, oklch(0.7 0.02 265));
}

.ash-replay {
  display: block;
  margin: var(--ds-5, 24px) auto 0;
  padding: 7px 14px;
  border: 1px solid var(--ds-border, oklch(0.3 0.02 270));
  border-radius: 999px;
  background: transparent;
  color: var(--ds-muted, oklch(0.7 0.02 265));
  font-family: inherit;
  font-size: var(--ds-text-sm, 0.82rem);
  cursor: pointer;
}

.ash-replay:hover {
  color: var(--ds-fg, oklch(0.92 0.01 265));
  border-color: var(--ds-accent, oklch(0.72 0.16 300));
}
`;

export interface AssembleHeadingProps {
  /** Строки заголовка. Каждая едет своей маской, со сдвигом по времени. */
  lines?: string[];
  sub?: string;
  /** Задержка между строками, с. */
  stagger?: number;
  /** Длительность въезда строки, с. */
  duration?: number;
  replay?: boolean;
}

const DEFAULT_LINES = ["Заголовок собирается", "по строкам", "из-под маски"];

/**
 * Заголовок собирается по строкам: каждая въезжает из-под своей маски со сдвигом по времени.
 *
 * Строки лежат в НОРМАЛЬНОМ потоке и клипует каждая сама себя. Это и есть суть приёма:
 * при абсолютном позиционировании строки наезжают друг на друга, а высоту приходится
 * мерить и держать вручную — здесь она берётся из потока и переживает любой перенос
 * по ширине окна.
 *
 * Едет `yPercent`, а не пиксели: процент трансформа считается от высоты САМОЙ строки,
 * поэтому один и тот же сдвиг верен и для кегля 2rem, и для 4.2rem. 115 вместо 100 —
 * запас на свисающие элементы букв, иначе у «р» и «у» хвосты выглядывают из-под маски
 * до начала движения.
 *
 * Крючок — `IntersectionObserver`: он нужен ровно на один раз и в покое не стоит ничего.
 */
export function AssembleHeading({
  lines = DEFAULT_LINES,
  sub = "Каждая строка клипует сама себя и живёт в обычном потоке — поэтому они не наезжают друг на друга и переживают любой перенос.",
  stagger = 0.14,
  duration = 0.9,
  replay = true,
}: AssembleHeadingProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const play = useRef<() => void>(() => {});

  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const targets = Array.from(root.querySelectorAll<HTMLElement>(".ash-line > span"));
    if (!targets.length) return;

    const reduced =
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    play.current = () =>
      void gsap.fromTo(
        targets,
        { yPercent: 115 },
        { yPercent: 0, duration, ease: "power4.out", stagger, overwrite: true },
      );

    if (reduced) {
      gsap.set(targets, { yPercent: 0 });
      return;
    }
    gsap.set(targets, { yPercent: 115 });

    const io = new IntersectionObserver(
      (entries) => {
        for (const e of entries) {
          if (!e.isIntersecting) continue;
          io.unobserve(e.target);
          play.current();
        }
      },
      { rootMargin: "0px 0px -22% 0px" }, // тот же порог, что был у `start: "top 78%"`
    );
    io.observe(root);

    return () => {
      io.disconnect();
      gsap.killTweensOf(targets);
    };
  }, [lines, stagger, duration]);

  return (
    <div className="ash" ref={rootRef}>
      <style href="motiva-assemble-heading" precedence="medium">{css}</style>

      <h2 className="ash-h">
        {lines.map((line, i) => (
          <span className="ash-line" key={`${i}-${line}`}>
            <span>{line}</span>
          </span>
        ))}
      </h2>

      {sub ? <p className="ash-sub">{sub}</p> : null}
      {replay ? (
        <button type="button" className="ash-replay" onClick={() => play.current()}>
          ↻ собрать заново
        </button>
      ) : null}
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0