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

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?number0Индекс подчёркнутого пункта (формат «0»).

Установка

npx shadcn@latest add https://motiva.pages.dev/r/nav-blend.json
Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

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