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 в корне — с алиасами в форме"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

Код

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.5»); 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>{`
        /* 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)); }
      `}</style>

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

      <div className="hpr-in">
        {eyebrow && <p className="hpr-eyebrow">{eyebrow}</p>}
        <h1 className="hpr-h">
          <span>{title}</span>
          {accentLine && <span className="hpr-accent">{accentLine}</span>}
        </h1>
        {tagline && <p className="hpr-tag">{tagline}</p>}
        {price && <p className="hpr-price">{price}</p>}

        <div className="hpr-row">
          {primary && (
            <a className="hpr-btn hpr-buy" href={primaryHref}>
              {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