Split Reveal
Заголовок, где каждое слово выезжает из-под маски со стаггером — классический вход текста.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| text? | string | "Премиум-анимации, готовые к копированию… |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/split-reveal-text.jsonзависимости: gsap@^3.15.0 · @gsap/react@^2.1.2
Что нужно в проекте до установки
Файл
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 { useRef } from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
/** Текст, где каждое слово выезжает из-под маски со стаггером (GSAP). data-shot/reduced → финальное состояние. */
export function SplitReveal({ text = "Премиум-анимации, готовые к копированию" }: { text?: string }) {
const ref = useRef<HTMLDivElement>(null);
useGSAP(
() => {
const el = ref.current;
if (!el) return;
const words = el.querySelectorAll(".sr-word");
if (
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
gsap.set(words, { yPercent: 0, opacity: 1 });
return;
}
gsap.from(words, { yPercent: 115, opacity: 0, duration: 0.8, ease: "power3.out", stagger: 0.08 });
},
{ scope: ref },
);
return (
<div
ref={ref}
style={{ fontSize: "2rem", fontWeight: 700, lineHeight: 1.15, textAlign: "center", maxWidth: 460, color: "oklch(0.94 0.01 265)" }}
>
{text.split(" ").map((w, i) => (
<span key={i} style={{ display: "inline-block", overflow: "hidden", verticalAlign: "bottom" }}>
<span className="sr-word" style={{ display: "inline-block" }}>
{w}
</span>
</span>
))}
</div>
);
}
import gsap from "gsap";
/**
* Split Reveal — vanilla JS/GSAP (без React). Слова элемента выезжают из-под масок
* со стаггером при появлении во вьюпорте (split делается автоматически).
*
* <h2 id="title">Премиум-анимации, готовые к копированию</h2>
* <script type="module">
* import { initSplitReveal } from './split-reveal-text.vanilla.js';
* const destroy = initSplitReveal(document.querySelector('#title'));
* </script>
*
* @param {HTMLElement} el — текстовый элемент (контент пересобирается в span-маски)
* @param {{ stagger?: number, once?: boolean }} [options]
* @returns {() => void} destroy (возвращает исходный текст)
*/
export function initSplitReveal(el, { stagger = 0.08, once = true } = {}) {
const original = el.textContent ?? "";
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (frozen) return () => {};
el.textContent = "";
const words = original.split(/\s+/).map((w) => {
const mask = document.createElement("span");
mask.style.cssText = "display:inline-block;overflow:hidden;vertical-align:bottom;";
const word = document.createElement("span");
word.style.display = "inline-block";
word.textContent = `${w} `;
mask.appendChild(word);
el.appendChild(mask);
return word;
});
let tween = null;
const io = new IntersectionObserver(
([e]) => {
if (!e?.isIntersecting) return;
if (once) io.disconnect();
tween = gsap.from(words, { yPercent: 115, opacity: 0, duration: 0.8, ease: "power3.out", stagger });
},
{ threshold: 0.4 },
);
io.observe(el);
return () => {
io.disconnect();
tween?.kill();
el.textContent = original;
};
}