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

Установка

npx shadcn@latest add https://motiva.pages.dev/r/nav-sticky.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

Код

"use client";

import { useEffect, useRef, useState } from "react";

export interface NavStickyProps {
  /** Название бренда слева. */
  brand?: string;
  /** Пункты меню (формат «["Работы","Услуги"]»). */
  links?: string[];
  /** Подпись кнопки справа. */
  cta?: string;
  ctaHref?: string;
  /** Индекс активного пункта (формат «0»). */
  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>{`
        .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; }
      `}</style>

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

      <nav className="nvs" data-stuck={stuck ? "1" : "0"} style={{ textAlign: "start" }}>
        <div className="nvs-in">
          <span className="nvs-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)}>
                {l}
              </button>
            ))}
          </div>

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