Heading Inline Image
Снимки вшиты прямо в строку заголовка и раскрываются по прокрутке — слова честно раздвигаются, освобождая им место. Ноль скриптов: ширина чипа анимируется по animation-timeline: view(). Редкий случай, где layout-анимация — сам приём, а не издержка: пересчёт заперт в одном заголовке и идёт только на кадрах прокрутки.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| parts? | (string | { img: string })[] | PARTS | Части заголовка вперемешку со снимками: строка — текст, `{ img: "https://motiva.pages.dev/img/…" }` — чип-картинка в этом месте строки. |
| open? | number | 2.6 | Ширина раскрытого чипа в em. |
| fontSize? | string | "clamp(1.9rem, 6.5cqw, 3.8rem)" | Кегль: любое CSS-значение font-size. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/heading-inline-image.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.
Экспорт
Код
.hii {
margin: 0;
font-weight: 800;
line-height: 1.08;
letter-spacing: var(--ds-tracking-tight, -0.03em);
color: var(--ds-fg, oklch(0.95 0.01 265));
}
/* чип-картинка живёт В СТРОКЕ заголовка: рост её ширины честно раздвигает слова.
Это редкий случай, где layout-анимация — сам приём, а не издержка: пересчёт
идёт только на кадрах прокрутки и заперт в одном заголовке.
База (нет поддержки, reduce, data-shot) — чип раскрыт */
.hii-chip {
display: inline-block;
vertical-align: -0.12em;
width: var(--hii-open, 2.6em);
height: 1em;
border-radius: 0.5em;
margin-inline: 0.08em;
object-fit: cover;
max-width: none;
}
@supports (animation-timeline: view()) {
@media not (prefers-reduced-motion: reduce) {
.hii-chip {
animation: hii-grow linear both;
animation-timeline: view();
/* чип раскрывается, пока заголовок идёт нижней третью кадра к середине */
animation-range: cover 10% cover 45%;
}
@keyframes hii-grow {
from { width: 0.55em; }
to { width: var(--hii-open, 2.6em); }
}
}
}
html[data-shot] .hii-chip { animation: none; }
import { Fragment } from "react";
import type { CSSProperties } from "react";
const css = `.hii {
margin: 0;
font-weight: 800;
line-height: 1.08;
letter-spacing: var(--ds-tracking-tight, -0.03em);
color: var(--ds-fg, oklch(0.95 0.01 265));
}
/* чип-картинка живёт В СТРОКЕ заголовка: рост её ширины честно раздвигает слова.
Это редкий случай, где layout-анимация — сам приём, а не издержка: пересчёт
идёт только на кадрах прокрутки и заперт в одном заголовке.
База (нет поддержки, reduce, data-shot) — чип раскрыт */
.hii-chip {
display: inline-block;
vertical-align: -0.12em;
width: var(--hii-open, 2.6em);
height: 1em;
border-radius: 0.5em;
margin-inline: 0.08em;
object-fit: cover;
max-width: none;
}
@supports (animation-timeline: view()) {
@media not (prefers-reduced-motion: reduce) {
.hii-chip {
animation: hii-grow linear both;
animation-timeline: view();
/* чип раскрывается, пока заголовок идёт нижней третью кадра к середине */
animation-range: cover 10% cover 45%;
}
@keyframes hii-grow {
from { width: 0.55em; }
to { width: var(--hii-open, 2.6em); }
}
}
}
html[data-shot] .hii-chip { animation: none; }
`;
export interface HeadingInlineImageProps {
/**
* Части заголовка вперемешку со снимками: строка — текст,
* `{ img: "https://motiva.pages.dev/img/…" }` — чип-картинка в этом месте строки.
*/
parts?: (string | { img: string })[];
/** Ширина раскрытого чипа в em. */
open?: number;
/** Кегль: любое CSS-значение font-size. */
fontSize?: string;
}
const PARTS: (string | { img: string })[] = [
"Города",
{ img: "https://motiva.pages.dev/img/1015-800x600.jpg" },
"начинаются с воды,",
{ img: "https://motiva.pages.dev/img/1043-900x675.jpg" },
"а держатся людьми",
];
/**
* Снимки вшиты прямо в строку заголовка и РАСКРЫВАЮТСЯ по прокрутке — слова
* честно раздвигаются, освобождая им место. Ноль скриптов.
*
* Чип — inline-block высотой в строку; его ширина анимируется по
* `animation-timeline: view()` от щели 0.55em до полной. Это редкий случай,
* где layout-анимация — САМ приём, а не издержка: пересчёт раскладки заперт
* в одном заголовке и происходит только на кадрах прокрутки.
*
* База (без поддержки прокруточных анимаций, под `prefers-reduced-motion`
* и `data-shot`) — чипы раскрыты: заголовок сразу в конечной форме.
*/
export function HeadingInlineImage({
parts = PARTS,
open = 2.6,
fontSize = "clamp(1.9rem, 6.5cqw, 3.8rem)",
}: HeadingInlineImageProps = {}) {
return (
<h2 className="hii" style={{ fontSize, "--hii-open": `${open}em` } as CSSProperties}>
<style href="motiva-heading-inline-image" precedence="medium">{css}</style>
{parts.map((p, i) =>
typeof p === "string" ? (
<Fragment key={i}> {p} </Fragment>
) : (
<img key={i} className="hii-chip" src={p.img} alt="" loading="lazy" />
),
)}
</h2>
);
}