Hero Video
Hero с фоновым видео — четвёртый вариант вида hero: несёт свой фон (сокета нет намеренно). Экономия в три яруса: постер отдельной картинкой под видео, ролик грузится и играет только когда секция видна, под reduced-motion видео не запрашивается вовсе.
Редактируемые поля
Секция «hero». Весь контент приходит пропсами — ниже то, что конструктор даст поправить, не трогая код.
| Проп | Что это | Тип |
|---|---|---|
| badge | Плашка | text |
| title | Заголовок | text |
| sub | Подзаголовок | text |
| primary | Главная кнопка | text |
| secondary | Вторая кнопка | text |
| note | Мелкая строка | text |
Все пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| badge? | string | "экспедиции выходного дня" | |
| title? | string | "Уехать туда, где не ловит" | |
| sub? | string | "Собираем маршруты на два дня без толп и… | |
| primary? | string | "Выбрать маршрут" | |
| primaryHref? | string | "#" | |
| secondary? | string | "Как это устроено" | |
| secondaryHref? | string | "#" | |
| note? | string | "ближайший выезд — в субботу · осталось … | Мелкая строка под кнопками. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/hero-video.jsonЧто нужно в проекте до установки
Файл
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 { useEffect, useRef, useState } from "react";
const css = `.hrv { position: relative; isolation: isolate; width: 100%;
min-height: min(680px, 92svh);
display: grid; align-content: center; justify-items: center;
padding-block: var(--ds-section-y, var(--ds-8, 80px));
overflow: clip;
color: oklch(0.97 0.005 265);
font-family: var(--ds-font, system-ui, sans-serif);
text-align: center; }
/* видео — СВОЙ фон секции (сокета нет намеренно: два фона спорили бы за слой).
Постер лежит отдельной картинкой ПОД видео: пока ролик не готов — и там,
где он не грузится вовсе (reduced-motion, экономия) — кадр уже стоит */
.hrv-media { position: absolute; inset: 0; z-index: -1; }
.hrv-media img,
.hrv-media video { position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; }
.hrv-media::after { content: ""; position: absolute; inset: 0; pointer-events: none;
background:
linear-gradient(oklch(0.08 0.02 265 / calc(var(--hrv-scrim, 0.55) * 0.7)),
oklch(0.08 0.02 265 / var(--hrv-scrim, 0.55))); }
.hrv-in { max-width: min(46rem, 92%); padding-inline: var(--ds-gutter, clamp(18px, 5vw, 54px)); }
.hrv-badge { display: inline-block; margin: 0 0 var(--ds-4, 16px); padding: 6px 14px;
border-radius: 999px; border: 1px solid oklch(0.98 0.01 265 / 0.28);
background: oklch(0.1 0.02 265 / 0.35); backdrop-filter: blur(6px);
font-family: var(--ds-font-mono, ui-monospace, monospace);
font-size: var(--ds-text-xs, 0.75rem); letter-spacing: 0.14em; text-transform: uppercase; }
.hrv-h { margin: 0; font-size: clamp(2.2rem, 6vw, 4rem); font-weight: 800;
line-height: 1.04; letter-spacing: var(--ds-tracking-tight, -0.03em);
text-wrap: balance;
text-shadow: 0 2px 24px oklch(0.05 0.02 265 / 0.45); }
.hrv-sub { margin: var(--ds-4, 16px) auto 0; max-width: 52ch;
font-size: var(--ds-text-md, 1.05rem); line-height: var(--ds-leading-body, 1.55);
color: oklch(0.93 0.01 265 / 0.92); }
.hrv-row { display: flex; gap: var(--ds-3, 12px); justify-content: center; flex-wrap: wrap;
margin-top: var(--ds-6, 32px); }
.hrv-btn { padding: 13px 26px; border-radius: 999px; cursor: pointer;
font: 600 var(--ds-text-sm, 0.95rem) / 1 var(--ds-font, system-ui, sans-serif);
text-decoration: none; border: 1px solid transparent;
transition: filter 0.2s ease, background-color 0.2s ease; }
.hrv-btn:hover { filter: brightness(1.1); }
.hrv-btn:focus-visible { outline: 2px solid oklch(0.98 0.01 265 / 0.6); outline-offset: 2px; }
.hrv-primary { background: var(--ds-accent, oklch(0.72 0.16 300));
color: var(--ds-accent-fg, oklch(0.14 0.02 275)); }
.hrv-secondary { background: oklch(0.1 0.02 265 / 0.35); color: inherit;
border-color: oklch(0.98 0.01 265 / 0.3); backdrop-filter: blur(6px); }
.hrv-note { margin: var(--ds-4, 18px) 0 0;
font-family: var(--ds-font-mono, ui-monospace, monospace);
font-size: var(--ds-text-xs, 0.75rem); color: oklch(0.9 0.01 265 / 0.75); }
`;
export interface HeroVideoProps {
badge?: string;
title?: string;
sub?: string;
primary?: string;
primaryHref?: string;
secondary?: string;
secondaryHref?: string;
/** Мелкая строка под кнопками. */
note?: string;
/** Фоновый ролик; путь вида `https://motiva.pages.dev/video/<имя>.mp4`. */
src?: string;
/** Кадр-заставка; стоит до готовности ролика и вместо него под reduced-motion. */
poster?: string;
/** Плотность затемнения над видео; 0 — выключить. */
scrim?: number;
}
/**
* HERO С ФОНОВЫМ ВИДЕО — четвёртый вариант вида hero: там, где centered/split
* берут сокетный шейдер, этот несёт СВОЙ фон, поэтому сокета нет намеренно —
* два фона спорили бы за слой.
*
* Экономия честная, в три яруса: постер — отдельная картинка ПОД видео (кадр
* стоит мгновенно); сам ролик с `preload="none"` начинает грузиться и играть
* только когда секция ВИДНА (IntersectionObserver — hero ниже первого экрана
* не тратит ни байта); под reduced-motion и data-shot видео не запрашивается
* ВОВСЕ — остаётся постер. Ролик muted+loop+playsInline, без звука и управления:
* это фон, а не контент — для контента есть video-facade и video-chapters.
*/
export function HeroVideo({
badge = "экспедиции выходного дня",
title = "Уехать туда, где не ловит",
sub = "Собираем маршруты на два дня без толп и связи: заброска, ночёвка, обратный поезд. Вы берёте рюкзак — остальное уже собрано.",
primary = "Выбрать маршрут",
primaryHref = "#",
secondary = "Как это устроено",
secondaryHref = "#",
note = "ближайший выезд — в субботу · осталось 4 места",
src = "https://motiva.pages.dev/video/smoke-1280x720.mp4",
poster = "https://motiva.pages.dev/video/smoke-1280x720-poster.jpg",
scrim = 0.55,
}: HeroVideoProps) {
const host = useRef<HTMLElement>(null);
const [play, setPlay] = useState(false);
useEffect(() => {
const el = host.current;
if (!el) return;
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (frozen) return; // видео не запрашивается вовсе — стоит постер
const io = new IntersectionObserver(
([e]) => {
if (e?.isIntersecting) {
setPlay(true);
io.disconnect();
}
},
{ rootMargin: "120px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<section
ref={host}
className="hrv"
style={{ ["--hrv-scrim" as string]: scrim }}
>
<style href="motiva-hero-video" precedence="medium">{css}</style>
<div className="hrv-media" aria-hidden="true">
<img src={poster} alt="" loading="eager" decoding="async" />
{play && <video src={src} poster={poster} muted loop playsInline autoPlay preload="none" />}
</div>
<div className="hrv-in">
{/* data-slot: адрес слота для inline-правки в конструкторе (/build) */}
{badge && <p className="hrv-badge" data-slot="badge">{badge}</p>}
{title && <h1 className="hrv-h" data-slot="title">{title}</h1>}
{sub && <p className="hrv-sub" data-slot="sub">{sub}</p>}
{(primary || secondary) && (
<div className="hrv-row">
{primary && (
<a className="hrv-btn hrv-primary" href={primaryHref} data-slot="primary">{primary}</a>
)}
{secondary && (
<a className="hrv-btn hrv-secondary" href={secondaryHref} data-slot="secondary">{secondary}</a>
)}
</div>
)}
{note && <p className="hrv-note" data-slot="note">{note}</p>}
</div>
</section>
);
}