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

Nav Sticky

Липкая шапка: подложка активного пункта переезжает между ссылками, уплотнение панели ловится маячком и IntersectionObserver — без слушателя scroll.

cssclickscrollmorphintermediate~2 КБreduced-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-sticky.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

Код

"use client";

const css = `.nvs-sentinel { height: 1px; }
.nvs { position: sticky; top: 0; z-index: 30; width: 100%;
  font-family: var(--ds-font, system-ui, sans-serif);
  transition: background var(--ds-base, 0.32s), border-color var(--ds-base, 0.32s); }
.nvs[data-stuck="1"] {
  background: color-mix(in oklab, var(--ds-bg, oklch(0.14 0.02 275)) 78%, transparent);
  backdrop-filter: blur(14px);
  border-bottom: 1px solid var(--ds-border, oklch(0.3 0.02 275)); }
.nvs-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)); }
.nvs-brand { font-weight: 800; letter-spacing: var(--ds-tracking-display, -0.045em);
  font-size: var(--ds-text-md, 1.05rem); color: var(--ds-fg, oklch(0.95 0.01 265)); }

.nvs-list { position: relative; display: flex; gap: var(--ds-1, 4px); padding: var(--ds-1, 4px);
  border-radius: var(--ds-radius-pill, 999px);
  border: 1px solid var(--ds-border, oklch(0.3 0.02 275));
  background: color-mix(in oklab, var(--ds-surface, oklch(0.18 0.02 275)) 70%, transparent); }
@media (max-width: 720px) { .nvs-list { display: none; } }
.nvs-list button { position: relative; border: 0; background: none; cursor: pointer;
  border-radius: var(--ds-radius-pill, 999px); padding: 7px 14px;
  font: 500 var(--ds-text-sm, 0.86rem) / 1 var(--ds-font, system-ui, sans-serif);
  color: var(--ds-muted, oklch(0.72 0.02 265));
  transition: color var(--ds-fast, 0.18s); }
.nvs-list button[data-on="1"] { color: var(--ds-accent-fg, oklch(0.14 0.02 275)); }
.nvs-pill { position: absolute; top: var(--ds-1, 4px); left: 0; height: calc(100% - 8px);
  border-radius: var(--ds-radius-pill, 999px);
  background: var(--ds-accent, oklch(0.72 0.16 300)); z-index: -1;
  transition: translate var(--ds-base, 0.32s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)),
              width var(--ds-base, 0.32s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }

.nvs-cta { border: 0; cursor: pointer; text-decoration: none; display: inline-block;
  border-radius: var(--ds-radius-pill, 999px); padding: 9px 18px;
  font: 650 var(--ds-text-sm, 0.86rem) / 1 var(--ds-font, system-ui, sans-serif);
  color: var(--ds-accent-fg, oklch(0.14 0.02 275));
  background: var(--ds-accent, oklch(0.72 0.16 300));
  transition: background var(--ds-fast, 0.18s), transform var(--ds-fast, 0.18s); }
.nvs-cta:hover { background: var(--ds-accent-hover, oklch(0.78 0.16 300)); transform: translateY(-1px); }
.nvs-cta:focus-visible, .nvs-list button:focus-visible {
  outline: 2px solid var(--ds-accent-ring, oklch(0.72 0.16 300 / 0.35)); outline-offset: 3px; }
`;
import { useEffect, useRef, useState } from "react";

export interface NavStickyProps {
  /** Название бренда слева. */
  brand?: string;
  /** Пункты меню. */
  links?: string[];
  /** Подпись кнопки справа. */
  cta?: string;
  ctaHref?: string;
  /** Индекс активного пункта. */
  active?: number;
}

/**
 * Липкая шапка: подложка активного пункта переезжает между ссылками, а сама панель
 * «уплотняется», когда страница прокручена.
 *
 * Два решения, ради которых секцию стоит читать:
 *
 * 1. Факт прокрутки ловится не слушателем `scroll`, а невидимым маячком над шапкой и
 *    IntersectionObserver. Обработчик срабатывает ДВА раза за всю сессию — на пересечении
 *    порога, — вместо десятков вызовов в секунду.
 * 2. Подложка ездит трансформом, а её ширина берётся из `offsetWidth` активной кнопки
 *    ровно в момент смены, а не в каждом кадре.
 */
export function NavSticky({
  brand = "studio⁄",
  links = ["Работы", "Услуги", "Студия", "Контакты"],
  cta = "Обсудить",
  ctaHref = "#",
  active = 0,
}: NavStickyProps) {
  const [cur, setCur] = useState(active);
  const [stuck, setStuck] = useState(false);
  const listRef = useRef<HTMLDivElement>(null);
  const sentinel = useRef<HTMLDivElement>(null);

  // положение подложки: пересчитываем при смене пункта и при изменении размеров панели
  useEffect(() => {
    const list = listRef.current;
    if (!list) return;
    const place = () => {
      const btn = list.querySelectorAll<HTMLElement>("button")[cur];
      const pill = list.querySelector<HTMLElement>(".nvs-pill");
      if (!btn || !pill) return;
      pill.style.width = `${btn.offsetWidth}px`;
      pill.style.translate = `${btn.offsetLeft - list.clientLeft}px 0`;
    };
    place();
    const ro = new ResizeObserver(place);
    ro.observe(list);
    return () => ro.disconnect();
  }, [cur, links]);

  useEffect(() => {
    const el = sentinel.current;
    if (!el) return;
    const io = new IntersectionObserver(([e]) => setStuck(!e!.isIntersecting), { threshold: 1 });
    io.observe(el);
    return () => io.disconnect();
  }, []);

  return (
    <>
      <style href="motiva-nav-sticky" precedence="medium">{css}</style>

      {/* маячок: пока он в кадре — страница не прокручена */}
      <div ref={sentinel} className="nvs-sentinel" aria-hidden />

      <nav className="nvs" data-stuck={stuck ? "1" : "0"} style={{ textAlign: "start" }}>
        <div className="nvs-in">
          {/* data-slot: адрес слота для inline-правки в конструкторе (/build) */}
          {brand && <span className="nvs-brand" data-slot="brand">{brand}</span>}

          <div className="nvs-list" ref={listRef}>
            <span className="nvs-pill" aria-hidden />
            {links.map((l, i) => (
              <button
                key={l}
                data-on={i === cur ? "1" : "0"}
                aria-current={i === cur}
                onClick={() => setCur(i)}
                data-slot="links"
                data-idx={i}
              >
                {l}
              </button>
            ))}
          </div>

          {cta && (
            <a className="nvs-cta" href={ctaHref} data-slot="cta">
              {cta}
            </a>
          )}
        </div>
      </nav>
    </>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0