Assemble Heading
Заголовок собирается по строкам: каждая въезжает из-под своей маски со сдвигом по времени. Строки живут в обычном потоке и клипуют сами себя — поэтому не наезжают и переживают любой перенос по ширине.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| lines? | string[] | DEFAULT_LINES | Строки заголовка. Каждая едет своей маской, со сдвигом по времени. |
| sub? | string | "Каждая строка клипует сама себя и живёт… | |
| stagger? | number | 0.14 | Задержка между строками, с. |
| duration? | number | 0.9 | Длительность въезда строки, с. |
| replay? | boolean | true |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/assemble-heading.jsonзависимости: gsap@^3.15.0
Что нужно в проекте до установки
Файл
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 { 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>
);
}