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

Hero Product

Продуктовый первый экран в духе больших витрин: крупная плотная типографика, две ссылки-пилюли и продукт, который выплывает снизу. Параллакс — по таймлайну прокрутки, без единого обработчика scroll.

cssloadscrollrevealparallaxintermediate~2 КБreduced-motion ✓
↗
↕ демо длиннее кадра — прокрутите его

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

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

ПропЧто этоТип
eyebrowНадпись сверхуtext
titleНазвание продуктаtext
accentLineАкцентная строкаtext
taglineПодзаголовокrichtext
priceСтрока про ценуtext
primaryГлавная кнопкаtext
secondaryСсылка рядомtext
imageИзображение продуктаimage

Все пропсы

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

ПропТипПо умолчаниюЧто это
eyebrow?string"Новинка"Мелкая строка над заголовком.
title?string"Aurora Pro"Заголовок — обычно название продукта.
accentLine?string"Свет, который слушается"Вторая строка заголовка, под акцентом.
tagline?string"Матрица нового поколения, шесть часов а…
price?string"от $1 299 · доставка со среды"Строка про цену/условия.
primary?string"Купить"
primaryHref?string"#"
secondary?string"Смотреть обзор"
secondaryHref?string"#"
image?string"https://motiva.pages.dev/img/1082-1600x…Изображение продукта.
imageAlt?string"Aurora Pro"
background?ReactNode—Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`. Занят — секция снимает собственную заливку, чтобы не спорить за слой.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/hero-product.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 = `/* overflow-x: clip, а НЕ overflow: hidden. hidden делает секцию скролл-контейнером,
   и тогда именно она становится scrollport для animation-timeline: view() ниже —
   а секция не прокручивается (её содержимое это она сама), поэтому параллакс
   намертво стоял на 0% прогресса при любой прокрутке страницы. clip клипует,
   но контейнером прокрутки не делает. */
.hpr { position: relative; width: 100%; overflow-x: clip; text-align: center;
  padding-block: var(--ds-section-y, var(--ds-9, 120px)) 0;
  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); }
.hpr-in { position: relative; max-width: var(--ds-container, 1120px); margin-inline: auto;
  padding-inline: var(--ds-gutter, clamp(18px, 5vw, 54px)); }

.hpr-eyebrow { margin: 0 0 var(--ds-3, 12px); font-size: var(--ds-text-md, 1.05rem);
  font-weight: 600; color: var(--ds-accent, oklch(0.72 0.16 300));
  animation: hpr-in 0.9s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both; }

/* кегль огромный, вес средний, трекинг сжатый — так «дорого» и выглядит */
.hpr-h { margin: 0; font-size: var(--ds-display, clamp(2.6rem, 1rem + 8vw, 6rem));
  font-weight: 640; line-height: 1.05;
  letter-spacing: var(--ds-tracking-display, -0.045em);
  animation: hpr-in 0.9s 0.08s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both; }
.hpr-h span { display: block; }
.hpr-accent { background: linear-gradient(100deg,
    var(--ds-accent, oklch(0.72 0.16 300)), var(--ds-accent-2, oklch(0.7 0.14 220)));
  -webkit-background-clip: text; background-clip: text; color: transparent; }

.hpr-tag { margin: var(--ds-5, 24px) auto 0; max-width: 40ch;
  font-size: var(--ds-text-lg, 1.3rem); font-weight: 500; line-height: 1.4;
  letter-spacing: var(--ds-tracking-tight, -0.02em);
  color: var(--ds-muted, oklch(0.72 0.02 265));
  animation: hpr-in 0.9s 0.16s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both; }
.hpr-price { margin: var(--ds-3, 12px) 0 0; font-size: var(--ds-text-sm, 0.9rem);
  color: var(--ds-muted, oklch(0.72 0.02 265)); opacity: 0.8;
  animation: hpr-in 0.9s 0.22s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both; }

.hpr-row { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--ds-3, 12px);
  margin-top: var(--ds-6, 36px);
  animation: hpr-in 0.9s 0.3s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both; }
.hpr-btn { display: inline-flex; align-items: center; gap: 6px; text-decoration: none;
  border-radius: var(--ds-radius-pill, 999px); padding: 12px 24px;
  font: 600 var(--ds-text-sm, 0.95rem) / 1 var(--ds-font, system-ui, sans-serif);
  transition: background var(--ds-fast, 0.18s), color var(--ds-fast, 0.18s); }
.hpr-buy { color: var(--ds-accent-fg, oklch(0.14 0.02 275));
  background: var(--ds-accent, oklch(0.72 0.16 300)); }
.hpr-buy:hover { background: var(--ds-accent-hover, oklch(0.78 0.16 300)); }
/* вторая ссылка — не кнопка, а именно ссылка: у Apple это всегда текст со стрелкой */
.hpr-more { color: var(--ds-accent, oklch(0.72 0.16 300)); padding-inline: 4px; }
.hpr-more:hover { text-decoration: underline; }
.hpr-btn:focus-visible { outline: 2px solid var(--ds-accent-ring, oklch(0.72 0.16 300 / 0.35));
  outline-offset: 3px; }

.hpr-stage { margin-top: var(--ds-8, 80px); }
.hpr-shot { display: block; width: 100%; max-width: 1000px; margin-inline: auto;
  border-radius: var(--ds-radius-lg, 20px);
  box-shadow: var(--ds-shadow-lg, 0 26px 60px oklch(0 0 0 / 0.6));
  animation: hpr-rise 1.1s 0.34s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both; }

/* параллакс по таймлайну прокрутки: ни одного обработчика scroll.
   Не поддержано — продукт просто стоит, ветка не нужна */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .hpr-shot { animation: hpr-rise 1.1s 0.34s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both,
                           hpr-drift linear both;
      animation-timeline: auto, view();
      /* Диапазон именно cover, а не contain: contain открывается только когда
         снимок ЦЕЛИКОМ помещается в scrollport, а он почти во весь кадр (606 из 620),
         так что окно вырождалось в 14 px и прогресс за всю прокрутку доползал
         с −49% лишь до −0.04%, ни разу не войдя в активный диапазон. */
      animation-range: normal, cover; }
  }
}

@keyframes hpr-in { from { opacity: 0; transform: translateY(24px); } }
@keyframes hpr-rise { from { opacity: 0; transform: translateY(64px) scale(0.97); } }
@keyframes hpr-drift { to { transform: translateY(-40px) scale(1.02); } }

@media (prefers-reduced-motion: reduce) {
  .hpr-eyebrow, .hpr-h, .hpr-tag, .hpr-price, .hpr-row, .hpr-shot { animation: none; }
}
html[data-shot] .hpr-eyebrow, html[data-shot] .hpr-h, html[data-shot] .hpr-tag,
html[data-shot] .hpr-price, html[data-shot] .hpr-row, html[data-shot] .hpr-shot { animation: none; }

/* СОКЕТ ФОНА. Слой лежит на z-index -1 внутри своего stacking context: он рисуется
   поверх заливки секции, но под всем содержимым — поэтому контенту не нужно ни
   position, ни z-index, и вёрстка секции не меняется вовсе.
   Тон переопределяет ТОКЕНЫ, а не перечисляет классы: все дети и так читают
   --ds-fg/--ds-muted, значит пара фон+текст чинится в одном месте.
   Скрим обязателен: у живого фона яркость гуляет по кадру, и цвет текста, читаемый
   и на провале, и на вспышке, подобрать нельзя — контраст надо создавать. */
.hpr { position: relative; isolation: isolate; }
.hpr-bg { position: absolute; inset: 0; z-index: -1; }
.hpr[data-slot-bg] { background: transparent; }
.hpr-bg::after { content: ""; position: absolute; inset: 0; pointer-events: none; }
.hpr[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); }
.hpr[data-tone="dark"] .hpr-bg::after {
  background: oklch(0.09 0.02 265 / calc(var(--hpr-scrim, 0.5) * 0.9)); }
.hpr[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); }
.hpr[data-tone="light"] .hpr-bg::after {
  background: oklch(0.99 0 0 / calc(var(--hpr-scrim, 0.5) * 0.9)); }
`;
import type { ReactNode } from "react";
export interface HeroProductProps {
  /** Мелкая строка над заголовком. */
  eyebrow?: string;
  /** Заголовок — обычно название продукта. */
  title?: string;
  /** Вторая строка заголовка, под акцентом. */
  accentLine?: string;
  tagline?: string;
  /** Строка про цену/условия. */
  price?: string;
  primary?: string;
  primaryHref?: string;
  secondary?: string;
  secondaryHref?: string;
  /** Изображение продукта. */
  image?: string;
  imageAlt?: string;
  /**
   * Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`.
   * Занят — секция снимает собственную заливку, чтобы не спорить за слой.
   */
  background?: ReactNode;
  /** Какой текст читается поверх фона; приходит из `mount.tone` подставленной записи. */
  tone?: "dark" | "light";
  /** Плотность скрима между фоном и текстом; 0 — выключить. */
  scrim?: number;
}

/**
 * Продуктовый первый экран в духе больших витрин Apple: крупная плотная типографика,
 * две ссылки-пилюли и продукт, который выплывает снизу.
 *
 * Три вещи, из которых складывается «дорого», и ни одна не про украшательства:
 *
 * 1. ТИПОГРАФИКА. Кегль огромный, а насыщенность средняя (600–650), межбуквенное сжатое.
 *    Жирный шрифт в таком размере читается как крик; сжатие и умеренный вес — как спокойная
 *    уверенность. Интерлиньяж 1.05, иначе строки «разъезжаются».
 * 2. ВОЗДУХ. Секция дышит вертикальными отступами, а не рамками. Разделителей нет вовсе —
 *    блоки отделены расстоянием и сменой фона.
 * 3. ДВИЖЕНИЕ ОДНОЙ ВЕЩИ ЗА РАЗ. Сначала текст, потом продукт — с задержкой, а не всё
 *    вместе. Кривая длинная и почти без ускорения в конце: движение доезжает, а не тормозит.
 *
 * Параллакс продукта сделан анимацией по таймлайну прокрутки (`animation-timeline: view()`) —
 * это ноль JS и ноль обработчиков `scroll`. Где не поддержано, продукт просто стоит:
 * ветку писать не нужно, `@supports` выключает только сам параллакс.
 */
export function HeroProduct({
  eyebrow = "Новинка",
  title = "Aurora Pro",
  accentLine = "Свет, который слушается",
  tagline = "Матрица нового поколения, шесть часов автономной работы и корпус, который не боится дороги.",
  price = "от $1 299 · доставка со среды",
  primary = "Купить",
  primaryHref = "#",
  secondary = "Смотреть обзор",
  secondaryHref = "#",
  image = "https://motiva.pages.dev/img/1082-1600x1000.jpg",
  imageAlt = "Aurora Pro",
  background,
  tone,
  scrim = 0.5,
}: HeroProductProps) {
  return (
    <section
      className="hpr"
      data-slot-bg={background ? "" : undefined}
      data-tone={tone}
      style={{ ["--hpr-scrim" as string]: scrim }}
    >
      <style href="motiva-hero-product" precedence="medium">{css}</style>

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

      <div className="hpr-in">
        {/* data-slot: адрес слота для inline-правки в конструкторе (/build) */}
        {eyebrow && <p className="hpr-eyebrow" data-slot="eyebrow">{eyebrow}</p>}
        <h1 className="hpr-h">
          {title && <span data-slot="title">{title}</span>}
          {accentLine && <span className="hpr-accent" data-slot="accentLine">{accentLine}</span>}
        </h1>
        {tagline && <p className="hpr-tag" data-slot="tagline">{tagline}</p>}
        {price && <p className="hpr-price" data-slot="price">{price}</p>}

        <div className="hpr-row">
          {primary && (
            <a className="hpr-btn hpr-buy" href={primaryHref} data-slot="primary">
              {primary}
            </a>
          )}
          {secondary && (
            <a className="hpr-btn hpr-more" href={secondaryHref}>
              {secondary} <span aria-hidden>›</span>
            </a>
          )}
        </div>

        <div className="hpr-stage">
          <img className="hpr-shot" src={image} alt={imageAlt} />
        </div>
      </div>
    </section>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0