Nav Blend
Липкая шапка, которая выворачивается по тому, что под ней едет: mix-blend-mode: difference вместо наблюдателя и классов «на тёмном / на светлом». Белое инвертирует фон, чёрное ведёт себя как прозрачное — отсюда кнопка, сквозь буквы которой виден исходный кадр. Приёму нужна спокойная полоса под шапкой: над плотной сеткой карточек подписи смешиваются с текстом под ними и читаются плохо.
Редактируемые поля
Секция «nav». Весь контент приходит пропсами — ниже то, что конструктор даст поправить, не трогая код.
| Проп | Что это | Тип |
|---|---|---|
| brand | Бренд | text |
| links | Пункты меню | list · до 6 |
| cta | Кнопка | text |
| ctaHref | Ссылка кнопки | link |
Все пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| brand? | string | "studio⁄" | Название бренда слева. |
| links? | string[] | ["Работы", "Услуги", "Студия", "Контакты… | Пункты меню. |
| cta? | string | "Обсудить" | Подпись кнопки справа. |
| ctaHref? | string | "#" | |
| active? | number | 0 | Индекс подчёркнутого пункта. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/nav-blend.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.
Экспорт
Код
const css = `.nvb {
position: sticky; top: 0; z-index: 20; width: 100%;
/* никакого фона: смешивается текст с тем, что под ним, а не полоса целиком */
mix-blend-mode: difference;
color: #fff;
font-family: var(--ds-font, system-ui, sans-serif);
}
/* вуаль: липнет вместе с шапкой, лежит ПОД ней (z ниже) и не занимает места
(отрицательный margin ровно на свою высоту). Градиент от фона темы гасит
пёстрый контент под навигацией — difference поверх вуали продолжает работать
и сам разруулит обе темы */
.nvb-veil {
position: sticky; top: 0; z-index: 19;
height: 108px; margin-bottom: -108px;
pointer-events: none;
/* плотность НАМЕРЕННО умеренная (0.62 у кромки): вуаль гасит пестроту, но
сквозь неё обязана читаться инверсия difference — это закон записи, и свой
verify меряет обе стороны. Плотнее ~0.7 в зоне текста инверсия гаснет */
background: linear-gradient(
to bottom,
color-mix(in oklab, var(--ds-bg, oklch(0.14 0.02 275)) 85%, transparent) 0%,
color-mix(in oklab, var(--ds-bg, oklch(0.14 0.02 275)) 50%, transparent) 30%,
color-mix(in oklab, var(--ds-bg, oklch(0.14 0.02 275)) 25%, transparent) 60%,
transparent 100%
);
}
.nvb-in {
display: flex; align-items: center; justify-content: space-between;
gap: var(--ds-4, 16px);
max-width: var(--ds-container, 1120px); margin-inline: auto;
padding: var(--ds-3, 12px) var(--ds-gutter, clamp(18px, 5vw, 54px));
}
.nvb-brand {
font-weight: 800; font-size: var(--ds-text-md, 1.05rem);
letter-spacing: var(--ds-tracking-display, -0.045em);
color: #fff; text-decoration: none;
}
.nvb-list { display: flex; gap: var(--ds-5, 22px); list-style: none; margin: 0; padding: 0; }
@media (max-width: 720px) { .nvb-list { display: none; } }
.nvb-list a {
position: relative; display: block; padding-block: 2px;
font: 500 var(--ds-text-sm, 0.86rem) / 1.2 var(--ds-font, system-ui, sans-serif);
color: #fff; text-decoration: none;
}
/* подчёркивание тоже белое: единственный цвет, который здесь виден всегда */
.nvb-list a::after {
content: ""; position: absolute; left: 0; right: 0; bottom: -3px; height: 1px;
background: #fff; scale: 0 1; transform-origin: left;
transition: scale var(--ds-fast, 0.18s) var(--ds-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
.nvb-list a:hover::after,
.nvb-list a:focus-visible::after,
.nvb-list a[aria-current="page"]::after { scale: 1 1; }
.nvb-list a[aria-current="page"] { font-weight: 700; }
/* белая заливка выворачивает таблетку, чёрные буквы оставляют исходный фон */
.nvb-cta {
flex: none; border-radius: var(--ds-radius-pill, 999px);
padding: 8px 16px; background: #fff; color: #000;
font: 600 var(--ds-text-sm, 0.86rem) / 1 var(--ds-font, system-ui, sans-serif);
text-decoration: none;
transition: padding var(--ds-fast, 0.18s) var(--ds-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
.nvb-cta:hover, .nvb-cta:focus-visible { padding-inline: 22px; }
.nvb a:focus-visible { outline: 2px solid #fff; outline-offset: 3px; border-radius: 3px; }
/* Запасной вид там, где смешивания нет (старый движок) или где оно вредит:
в режиме принудительных цветов ОС цвета назначает система, и инверсия ломает
ровно тот контраст, ради которого режим включают. */
@supports not (mix-blend-mode: difference) {
.nvb { mix-blend-mode: normal; background: var(--ds-bg, oklch(0.14 0.02 275)); color: var(--ds-fg, oklch(0.95 0.01 265)); }
.nvb-brand, .nvb-list a { color: inherit; }
.nvb-list a::after { background: currentColor; }
.nvb-cta { background: var(--ds-accent, oklch(0.72 0.16 300)); color: var(--ds-accent-fg, oklch(0.16 0.02 300)); }
}
@media (forced-colors: active) {
.nvb { mix-blend-mode: normal; background: Canvas; color: CanvasText; }
.nvb-brand, .nvb-list a { color: inherit; }
.nvb-cta { background: ButtonFace; color: ButtonText; border: 1px solid ButtonBorder; }
}
`;
export interface NavBlendProps {
/** Название бренда слева. */
brand?: string;
/** Пункты меню. */
links?: string[];
/** Подпись кнопки справа. */
cta?: string;
ctaHref?: string;
/** Индекс подчёркнутого пункта. */
active?: number;
}
/**
* Липкая шапка, которая выворачивается по тому, что под ней едет.
*
* Ни скрипта, ни наблюдателя, ни классов «шапка на тёмном / шапка на светлом»: цвет
* считает компоновщик из пикселей под шапкой — `mix-blend-mode: difference`.
*
* Работает это на двух крайних значениях, и обе стоит держать в голове, потому что из них
* следует вся вёрстка ниже:
*
* - **белый инвертирует.** `difference(фон, белый)` = точная инверсия фона: на белой полосе
* надпись чёрная, на фотографии — её негатив. Поэтому весь текст здесь строго `#fff`,
* а не «почти белый»: полупрозрачный белый даёт серый, а серый на сером фоне пропадает.
* Различать пункты приходится начертанием и кеглем, а не прозрачностью — это ограничение
* приёма, а не недосмотр.
* - **чёрный не делает ничего.** `difference(фон, чёрный)` = фон. То есть в этом режиме
* `#000` ведёт себя как прозрачный. Отсюда кнопка справа: белая заливка + чёрные буквы
* дают вывернутую таблетку, СКВОЗЬ буквы которой виден исходный фон. Читается на чём
* угодно, и ни одного цвета для этого знать не нужно.
*
* Про что стоит знать заранее, потому что кодом это не лечится:
*
* - на средне-сером фоне (около `oklch(0.5)`) инверсия даёт почти тот же серый, и шапка
* бледнеет. Ровная серая заливка — единственный фон, под который эту шапку класть нельзя;
* - на насыщенном цвете инверсия возвращает ДОПОЛНИТЕЛЬНЫЙ, а не «тот же цвет потемнее»:
* над фиолетовым шапка станет зелёной (видно в демо на полосе акцента). Это определение
* `difference`, а не дефект; в макете это учитывают, а не чинят.
*
* Что ломает смешивание (проверять в таком порядке, если «не работает»):
* 1. свой фон у шапки — тогда выворачивается вся полоса целиком, а не буквы по ней;
* 2. `backdrop-filter`, `opacity < 1`, `filter`, `transform` или `isolation: isolate`
* на ЛЮБОМ предке — каждый из них заводит группу компоновки, и смешивать становится
* не с чем: шапка сольётся с фоном этой группы, а не со страницей;
* 3. `position: sticky` + `z-index` на самой шапке — это НЕ помеха: изолирует предок,
* а не собственный контекст элемента.
*
* Секция намеренно не задаёт себе ни фона, ни вертикального ритма страницы: она ложится
* ПОВЕРХ первого экрана, а не встаёт перед ним.
*/
export function NavBlend({
brand = "studio⁄",
links = ["Работы", "Услуги", "Студия", "Контакты"],
cta = "Обсудить",
ctaHref = "#",
active = 0,
}: NavBlendProps) {
return (
<>
<style href="motiva-nav-blend" precedence="medium">{css}</style>
{/* вуаль — липкий СОСЕД шапки, а не обёртка и не её ребёнок: обёртка со sticky
завела бы собственный stacking context, и difference смешивался бы с вуалью
вместо страницы; ребёнок невозможен — смешивается элемент целиком.
Отрицательный нижний margin убирает её из потока, градиент от фона темы
отделяет шапку от пёстрого контента там, где одной инверсии букв мало */}
<div className="nvb-veil" aria-hidden="true" />
<nav className="nvb" aria-label="Основная" style={{ textAlign: "start" }}>
<div className="nvb-in">
{/* data-slot: адрес слота для inline-правки в конструкторе (/build) */}
{brand && (
<a className="nvb-brand" href="#" data-slot="brand">
{brand}
</a>
)}
<ul className="nvb-list">
{links.map((l, i) => (
<li key={l}>
<a href="#" aria-current={i === active ? "page" : undefined} data-slot="links" data-idx={i}>
{l}
</a>
</li>
))}
</ul>
{cta && (
<a className="nvb-cta" href={ctaHref} data-slot="cta">
{cta}
</a>
)}
</div>
</nav>
</>
);
}