Motiva
Мои коллекции
← каталог

Nav Blend

Липкая шапка, которая выворачивается по тому, что под ней едет: mix-blend-mode: difference вместо наблюдателя и классов «на тёмном / на светлом». Белое инвертирует фон, чёрное ведёт себя как прозрачное — отсюда кнопка, сквозь буквы которой виден исходный кадр. Приёму нужна спокойная полоса под шапкой: над плотной сеткой карточек подписи смешиваются с текстом под ними и читаются плохо.

cssscrollhovermorphbasicreduced-motion ✓
↗
↕ демо длиннее кадра — прокрутите его

Редактируемые поля

Секция «nav». Весь контент приходит пропсами — ниже то, что конструктор даст поправить, не трогая код.

ПропЧто этоТип
brandБрендtext
linksПункты менюlist · до 6
ctaКнопкаtext
ctaHrefСсылка кнопкиlink

Все пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
brand?string"studio⁄"Название бренда слева.
links?string[]["Работы", "Услуги", "Студия", "Контакты…Пункты меню.
cta?string"Обсудить"Подпись кнопки справа.
ctaHref?string"#"
active?number0Индекс подчёркнутого пункта.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/nav-blend.json
Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.json.

    Приехавший рядом .tsx можно не трогать — вам нужен .vanilla.js.

Экспорт

↓ Скачать .zip

Код

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>
    </>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0