← каталог
Split Text Hero
Хиро-вход: слова заголовка выезжают из overflow-масок снизу stagger-ом (ручной split), затем подзаголовок и CTA.
gsaploadrevealstaggerintermediate~23 КБreduced-motion ✓
Установка
npx shadcn@latest add https://motiva.pages.dev/r/split-text-hero.jsonзависимости: gsap@^3.15.0
Что нужно в проекте до установки
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 { useEffect, useRef } from "react";
import gsap from "gsap";
const LINES = ["Делаем интерфейсы,", "которые чувствуются", "живыми"];
/**
* Хиро-вход: слова заголовка выезжают из overflow-масок снизу stagger-ом (ручной split,
* без плагинов), затем подзаголовок и CTA. Уважает reduced-motion / data-shot (всё видно сразу).
*/
export function SplitTextHero() {
const root = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = root.current;
if (!el) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const frozen = document.documentElement.hasAttribute("data-shot") || reduce;
const words = el.querySelectorAll(".sth-word");
const rest = el.querySelectorAll(".sth-rest");
if (frozen) {
gsap.set([words, rest], { yPercent: 0, opacity: 1, clearProps: "transform" });
return;
}
const tl = gsap
.timeline()
.fromTo(
words,
{ yPercent: 120 },
{ yPercent: 0, duration: 0.9, ease: "power4.out", stagger: 0.07, delay: 0.15 },
)
.fromTo(rest, { opacity: 0, y: 18 }, { opacity: 1, y: 0, duration: 0.6, ease: "power2.out", stagger: 0.12 }, "-=0.4");
return () => {
tl.kill();
};
}, []);
return (
<div ref={root} style={{ textAlign: "center", color: "oklch(0.92 0.01 265)", padding: "1rem" }}>
<h1
style={{
margin: 0,
fontSize: "clamp(2rem, 1rem + 4.4vw, 3.8rem)",
fontWeight: 800,
letterSpacing: "-0.03em",
lineHeight: 1.08,
}}
>
{LINES.map((line) => (
<span key={line} style={{ display: "block" }}>
{line.split(" ").map((w, wi) => (
<span key={wi} style={{ display: "inline-block", overflow: "hidden", verticalAlign: "bottom", paddingBottom: "0.08em", marginBottom: "-0.08em" }}>
<span className="sth-word" style={{ display: "inline-block", willChange: "transform" }}>
{w}
</span>
{" "}
</span>
))}
</span>
))}
</h1>
<p className="sth-rest" style={{ margin: "1.1rem 0 0", color: "oklch(0.7 0.02 265)", fontSize: "0.98rem", opacity: 0 }}>
Слова выезжают из масок — каждая строка со своим стаггером.
</p>
<button
className="sth-rest"
style={{
marginTop: "1.3rem",
padding: "0.7rem 1.5rem",
borderRadius: 999,
border: "none",
background: "oklch(0.72 0.15 300)",
color: "oklch(0.15 0.02 270)",
fontWeight: 700,
fontSize: "0.9rem",
cursor: "pointer",
opacity: 0,
}}
>
Начать проект
</button>
</div>
);
}
import gsap from "gsap";
/**
* Split Text Hero — vanilla JS/GSAP (без React). Разбивает заголовок на слова в
* overflow-масках и выкатывает их снизу stagger-ом; затем — остальные элементы.
*
* <h1 id="hero">Делаем интерфейсы, которые чувствуются живыми</h1>
* <p data-hero-rest>Подзаголовок</p>
* <script type="module">
* import { initSplitTextHero } from './split-text-hero.vanilla.js';
* initSplitTextHero(document.querySelector('#hero'), {
* rest: document.querySelectorAll('[data-hero-rest]'),
* });
* </script>
*
* @param {HTMLElement} headline — заголовок (текст будет пересобран в span-маски)
* @param {{ rest?: Iterable<HTMLElement>, stagger?: number }} [options]
* @returns {() => void} destroy (возвращает исходный текст)
*/
export function initSplitTextHero(headline, { rest = [], stagger = 0.07 } = {}) {
const original = headline.textContent ?? "";
const restEls = [...rest];
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (frozen) return () => {};
// split: каждое слово — в inline-block маске с overflow:hidden
headline.textContent = "";
const words = original.split(/\s+/).map((w) => {
const mask = document.createElement("span");
mask.style.cssText =
"display:inline-block;overflow:hidden;vertical-align:bottom;padding-bottom:0.08em;margin-bottom:-0.08em;";
const word = document.createElement("span");
word.style.cssText = "display:inline-block;will-change:transform;";
word.textContent = w;
mask.appendChild(word);
headline.append(mask, " ");
return word;
});
const tl = gsap
.timeline()
.fromTo(words, { yPercent: 120 }, { yPercent: 0, duration: 0.9, ease: "power4.out", stagger, delay: 0.15 });
if (restEls.length) {
tl.fromTo(
restEls,
{ opacity: 0, y: 18 },
{ opacity: 1, y: 0, duration: 0.6, ease: "power2.out", stagger: 0.12 },
"-=0.4",
);
}
return () => {
tl.kill();
headline.textContent = original;
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0