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

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

Что нужно в проекте до установки
  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, 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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0