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

Hero Centered

Центрированный первый экран: заголовок выезжает по словам, за текстом дышит аура акцента. Маска на уровне слова, поэтому переносы строк не ломаются.

cssloadrevealstaggerbasic~2 КБreduced-motion ✓
↗

Редактируемые поля

Секция «hero». Весь контент приходит пропсами — ниже то, что конструктор даст поправить, не трогая код.

ПропЧто этоТип
badgeПлашка сверхуtext
titleЗаголовокtext
accentWordsСлов под акцентомtext
subПодзаголовокrichtext
primaryГлавная кнопкаtext
secondaryВторая кнопкаtext
noteСтрока под кнопкамиtext

Все пропсы

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

ПропТипПо умолчаниюЧто это
badge?string"бета открыта"Плашка над заголовком (маленькая строка в пилюле).
title?string"Соберите страницу за вечер"Заголовок целиком — слова выезжают по очереди.
accentWords?number2Последние N слов красятся акцентом.
sub?string"Готовые секции на одной дизайн-системе.…
primary?string"Начать бесплатно"
primaryHref?string"#"
secondary?string"Смотреть примеры"
secondaryHref?string"#"
note?string"без карты · открытый код · MIT"Мелкая строка под кнопками.
background?ReactNode—Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`. Занят — секция снимает собственную заливку, чтобы не спорить за слой.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/hero-centered.json
Что нужно в проекте до установки
  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

Код

const css = `.hce { position: relative; width: 100%; overflow: hidden; text-align: center;
  padding-block: var(--ds-section-y, var(--ds-9, 120px));
  background: var(--ds-bg, oklch(0.14 0.02 275));
  color: var(--ds-fg, oklch(0.95 0.01 265));
  font-family: var(--ds-font, system-ui, sans-serif); }
/* аура: transform + opacity, ни одной перерисовки */
.hce::before { content: ""; position: absolute; left: 50%; top: 30%;
  width: min(900px, 110vw); aspect-ratio: 1; translate: -50% -50%; pointer-events: none;
  border-radius: 50%;
  background: radial-gradient(circle,
    var(--ds-accent-soft, oklch(0.72 0.16 300 / 0.16)), transparent 60%);
  animation: hce-breathe 11s ease-in-out infinite; }

.hce-in { position: relative; max-width: min(var(--ds-container, 1120px), 880px);
  margin-inline: auto; padding-inline: var(--ds-gutter, clamp(18px, 5vw, 54px)); }

.hce-badge { display: inline-flex; align-items: center; gap: var(--ds-2, 8px);
  margin: 0 0 var(--ds-5, 24px); padding: 5px 14px 5px 10px;
  border-radius: var(--ds-radius-pill, 999px);
  border: 1px solid var(--ds-border, oklch(0.3 0.02 275));
  background: var(--ds-surface, oklch(0.18 0.02 275));
  font-family: var(--ds-font-mono, ui-monospace, monospace);
  font-size: var(--ds-text-xs, 0.72rem); color: var(--ds-muted, oklch(0.72 0.02 265));
  animation: hce-fade 0.6s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both; }
.hce-dot { width: 6px; height: 6px; border-radius: 50%;
  background: var(--ds-accent, oklch(0.72 0.16 300)); }

.hce-h { margin: 0; font-size: var(--ds-display, clamp(2.2rem, 1rem + 7vw, 5.4rem));
  line-height: var(--ds-leading-tight, 0.98); font-weight: 800;
  letter-spacing: var(--ds-tracking-display, -0.045em); }
/* маска на уровне СЛОВА: строки переносятся как обычно, выезд не ломает вёрстку */
.hce-w { display: inline-block; overflow: clip; vertical-align: bottom; }
.hce-w > span { display: inline-block;
  animation: hce-rise 0.85s calc(var(--i) * 0.045s)
    var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both; }
.hce-accent { background: linear-gradient(96deg,
    var(--ds-accent, oklch(0.78 0.16 300)), var(--ds-accent-2, oklch(0.76 0.14 220)));
  -webkit-background-clip: text; background-clip: text; color: transparent; }

.hce-sub { margin: var(--ds-5, 24px) auto 0; max-width: 52ch;
  font-size: var(--ds-text-md, 1.02rem); line-height: var(--ds-leading-body, 1.55);
  color: var(--ds-muted, oklch(0.72 0.02 265));
  animation: hce-fade 0.6s 0.3s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both; }

.hce-row { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--ds-3, 12px);
  margin-top: var(--ds-6, 36px);
  animation: hce-fade 0.6s 0.42s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both; }
.hce-btn { display: inline-block; text-decoration: none; cursor: pointer; border: 0;
  border-radius: var(--ds-radius-pill, 999px); padding: 14px 28px;
  font: 650 var(--ds-text-sm, 0.9rem) / 1 var(--ds-font, system-ui, sans-serif);
  transition: transform var(--ds-fast, 0.18s) var(--ds-ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1)),
              background var(--ds-fast, 0.18s); }
.hce-btn:hover { transform: translateY(-2px); }
.hce-btn:focus-visible { outline: 2px solid var(--ds-accent-ring, oklch(0.72 0.16 300 / 0.35));
  outline-offset: 3px; }
.hce-primary { color: var(--ds-accent-fg, oklch(0.14 0.02 275));
  background: var(--ds-accent, oklch(0.72 0.16 300));
  box-shadow: var(--ds-shadow, 0 10px 34px oklch(0 0 0 / 0.45)); }
.hce-primary:hover { background: var(--ds-accent-hover, oklch(0.78 0.16 300)); }
.hce-ghost { color: var(--ds-fg, oklch(0.95 0.01 265)); background: transparent;
  box-shadow: inset 0 0 0 1px var(--ds-border, oklch(0.3 0.02 275)); }
.hce-ghost:hover { background: var(--ds-surface, oklch(0.18 0.02 275)); }

.hce-note { margin: var(--ds-4, 16px) 0 0;
  font-family: var(--ds-font-mono, ui-monospace, monospace);
  font-size: var(--ds-text-xs, 0.72rem); color: var(--ds-muted, oklch(0.72 0.02 265));
  opacity: 0.75;
  animation: hce-fade 0.6s 0.52s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both; }

@keyframes hce-rise { from { transform: translateY(110%); } }
@keyframes hce-fade { from { opacity: 0; transform: translateY(14px); } }
@keyframes hce-breathe {
  0%, 100% { transform: scale(0.92); opacity: 0.8; }
  50% { transform: scale(1.08); opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .hce::before, .hce-badge, .hce-w > span, .hce-sub, .hce-row, .hce-note { animation: none; }
  .hce-btn { transition: none; }
}
html[data-shot] .hce::before, html[data-shot] .hce-badge, html[data-shot] .hce-w > span,
html[data-shot] .hce-sub, html[data-shot] .hce-row, html[data-shot] .hce-note { animation: none; }

/* СОКЕТ ФОНА. Слой лежит на z-index -1 внутри своего stacking context: он рисуется
   поверх заливки секции, но под всем содержимым — поэтому контенту не нужно ни
   position, ни z-index, и вёрстка секции не меняется вовсе.
   Тон переопределяет ТОКЕНЫ, а не перечисляет классы: все дети и так читают
   --ds-fg/--ds-muted, значит пара фон+текст чинится в одном месте.
   Скрим обязателен: у живого фона яркость гуляет по кадру, и цвет текста, читаемый
   и на провале, и на вспышке, подобрать нельзя — контраст надо создавать. */
.hce { position: relative; isolation: isolate; }
.hce-bg { position: absolute; inset: 0; z-index: -1; }
.hce[data-slot-bg] { background: transparent; }
.hce-bg::after { content: ""; position: absolute; inset: 0; pointer-events: none; }
.hce[data-tone="dark"] { --ds-fg: oklch(0.96 0.01 265); --ds-muted: oklch(0.84 0.02 265);
  --ds-border: oklch(0.98 0.01 265 / 0.16); color: var(--ds-fg); }
.hce[data-tone="dark"] .hce-bg::after {
  background: oklch(0.09 0.02 265 / calc(var(--hce-scrim, 0.5) * 0.9)); }
.hce[data-tone="light"] { --ds-fg: oklch(0.2 0.02 265); --ds-muted: oklch(0.42 0.02 265);
  --ds-border: oklch(0.2 0.02 265 / 0.14); color: var(--ds-fg); }
.hce[data-tone="light"] .hce-bg::after {
  background: oklch(0.99 0 0 / calc(var(--hce-scrim, 0.5) * 0.9)); }
`;
import type { ReactNode } from "react";
export interface HeroCenteredProps {
  /** Плашка над заголовком (маленькая строка в пилюле). */
  badge?: string;
  /** Заголовок целиком — слова выезжают по очереди. */
  title?: string;
  /** Последние N слов красятся акцентом. */
  accentWords?: number;
  sub?: string;
  primary?: string;
  primaryHref?: string;
  secondary?: string;
  secondaryHref?: string;
  /** Мелкая строка под кнопками. */
  note?: string;
  /**
   * Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`.
   * Занят — секция снимает собственную заливку, чтобы не спорить за слой.
   */
  background?: ReactNode;
  /** Какой текст читается поверх фона; приходит из `mount.tone` подставленной записи. */
  tone?: "dark" | "light";
  /** Плотность скрима между фоном и текстом; 0 — выключить. */
  scrim?: number;
}

/**
 * Центрированный герой — второй характер первого экрана: симметричный, «продуктовый»,
 * в противовес левому выключенному `hero-split`.
 *
 * Заголовок разбирается на слова прямо в разметке, и каждое получает свой порядковый
 * номер в переменной `--i`. Правило на появление при этом ОДНО: задержка считается из
 * номера. Добавил слово — оно встало в очередь само, ни JS, ни правки CSS.
 *
 * Разрывы строк при этом не ломаются: слова остаются инлайновыми, а «выезд из-под маски»
 * даёт вложенный `span` с `overflow: clip` — маска на уровне слова, а не строки.
 *
 * Аура за текстом дышит только `transform` и `opacity` — то есть целиком в композиторе.
 * Тот же вид через `background-position` или `filter` стоил бы перерисовки полэкрана
 * в каждом кадре.
 */
export function HeroCentered({
  badge = "бета открыта",
  title = "Соберите страницу за вечер",
  accentWords = 2,
  sub = "Готовые секции на одной дизайн-системе. Меняете текст и один цвет — получаете свою страницу, а не чужой шаблон.",
  primary = "Начать бесплатно",
  primaryHref = "#",
  secondary = "Смотреть примеры",
  secondaryHref = "#",
  note = "без карты · открытый код · MIT",
  background,
  tone,
  scrim = 0.5,
}: HeroCenteredProps) {
  const words = title.split(" ");
  const from = Math.max(0, words.length - accentWords);

  return (
    <section
      className="hce"
      data-slot-bg={background ? "" : undefined}
      data-tone={tone}
      style={{ ["--hce-scrim" as string]: scrim }}
    >
      <style href="motiva-hero-centered" precedence="medium">{css}</style>

      {background && <div className="hce-bg">{background}</div>}

      <div className="hce-in">
        {/* data-slot: адрес слота для inline-правки в конструкторе (/build) */}
        {badge && (
          <p className="hce-badge" data-slot="badge">
            <span className="hce-dot" aria-hidden />
            {badge}
          </p>
        )}

        <h1 className="hce-h">
          {words.map((w, i) => (
            <span key={`${w}-${i}`} className="hce-w" style={{ ["--i" as string]: i }}>
              <span className={i >= from ? "hce-accent" : undefined}>{w}</span>
              {i < words.length - 1 ? " " : ""}
            </span>
          ))}
        </h1>

        {sub && <p className="hce-sub" data-slot="sub">{sub}</p>}

        <div className="hce-row">
          {primary && (
            <a className="hce-btn hce-primary" href={primaryHref} data-slot="primary">
              {primary}
            </a>
          )}
          {secondary && (
            <a className="hce-btn hce-ghost" href={secondaryHref} data-slot="secondary">
              {secondary}
            </a>
          )}
        </div>

        {note && <p className="hce-note" data-slot="note">{note}</p>}
      </div>
    </section>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0