← каталог
Nav Blend
Липкая шапка, которая выворачивается по тому, что под ней едет: mix-blend-mode: difference вместо наблюдателя и классов «на тёмном / на светлом». Белое инвертирует фон, чёрное ведёт себя как прозрачное — отсюда кнопка, сквозь буквы которой виден исходный кадр.
cssscrollhovermorphbasic0reduced-motion ✓
↕ демо длиннее кадра — прокрутите его
Редактируемые поля
Секция «nav». Весь контент приходит пропсами — ниже то, что конструктор даст поправить, не трогая код.
| Проп | Что это | Тип |
|---|---|---|
| brand | Бренд | text |
| links | Пункты меню | list · до 6 |
| cta | Кнопка | text |
| ctaHref | Ссылка кнопки | link |
Все пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| brand? | string | "studio⁄" | Название бренда слева. |
| links? | string[] | ["Работы", "Услуги", "Студия", "Контакты… | Пункты меню (формат «["Работы","Услуги"]»). |
| cta? | string | "Обсудить" | Подпись кнопки справа. |
| ctaHref? | string | "#" | |
| active? | number | 0 | Индекс подчёркнутого пункта (формат «0»). |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/nav-blend.jsonЧто нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
export interface NavBlendProps {
/** Название бренда слева. */
brand?: string;
/** Пункты меню (формат «["Работы","Услуги"]»). */
links?: string[];
/** Подпись кнопки справа. */
cta?: string;
ctaHref?: string;
/** Индекс подчёркнутого пункта (формат «0»). */
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>{`
.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);
}
.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; }
}
`}</style>
<nav className="nvb" aria-label="Основная" style={{ textAlign: "start" }}>
<div className="nvb-in">
<a className="nvb-brand" href="#">
{brand}
</a>
<ul className="nvb-list">
{links.map((l, i) => (
<li key={l}>
<a href="#" aria-current={i === active ? "page" : undefined}>
{l}
</a>
</li>
))}
</ul>
<a className="nvb-cta" href={ctaHref}>
{cta}
</a>
</div>
</nav>
</>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: адаптация идеи пена «Sticky nav with mix-blend-mode» (jalinb, https://codepen.io/jalinb/pen/ExOgOBZ, MIT); код Motivav1.0.0