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

Gooey Nav

Жидкий индикатор навигации: капля перетекает к пункту, хвост догоняет с задержкой, SVG-фильтр склеивает их в «ртутную» форму. Ноль зависимостей.

csshoverclickmorphintermediate~2 КБreduced-motion ✓
🎛 Настройки — крути и копируй

Пропсы

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

ПропТипПо умолчаниюЧто это
items?string[]ITEMSПодписи пунктов.
initial?number0Индекс активного пункта на старте (формат «0»).
color?string"#8b5cf6"Цвет капли (формат «#8b5cf6»).
goo?number10Вязкость: больше — сильнее «слипание» и длиннее хвост (формат «10»).

Установка

npx shadcn@latest add https://motiva.pages.dev/r/gooey-nav.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, useId, useRef, useState } from "react";

export interface GooeyNavProps {
  /** Подписи пунктов. */
  items?: string[];
  /** Индекс активного пункта на старте (формат «0»). */
  initial?: number;
  /** Цвет капли (формат «#8b5cf6»). */
  color?: string;
  /** Вязкость: больше — сильнее «слипание» и длиннее хвост (формат «10»). */
  goo?: number;
}

const ITEMS = ["Работы", "Студия", "Журнал", "Контакты"];

/**
 * Gooey Nav — жидкий индикатор навигации: капля перетекает к активному пункту,
 * хвост догоняет с задержкой, SVG-фильтр (blur + contrast) склеивает их в одну «ртутную» форму.
 * Только CSS-переходы + один замер геометрии на смену — ни одного rAF-лупа.
 */
export function GooeyNav({ items = ITEMS, initial = 0, color = "#8b5cf6", goo = 10 }: GooeyNavProps) {
  const filterId = `goo-${useId().replace(/[:]/g, "")}`;
  const navRef = useRef<HTMLDivElement>(null);
  const [active, setActive] = useState(initial);
  const [preview, setPreview] = useState<number | null>(null);
  const [box, setBox] = useState({ x: 0, w: 0 });

  const target = preview ?? active;

  // геометрия капли = геометрия пункта; меряем на смену пункта и на resize
  useEffect(() => {
    const measure = () => {
      const nav = navRef.current;
      const el = nav?.querySelectorAll<HTMLElement>("[data-goo-item]")[target];
      if (el) setBox({ x: el.offsetLeft, w: el.offsetWidth });
    };
    measure();
    window.addEventListener("resize", measure);
    return () => window.removeEventListener("resize", measure);
  }, [target, items]);

  const blob = (delayMs: number, duration: number): React.CSSProperties => ({
    position: "absolute",
    top: 6,
    left: 0,
    height: 38,
    width: box.w,
    borderRadius: 999,
    background: color,
    transform: `translateX(${box.x}px)`,
    transition: `transform ${duration}ms cubic-bezier(0.22, 1, 0.36, 1) ${delayMs}ms, width ${duration}ms cubic-bezier(0.22, 1, 0.36, 1) ${delayMs}ms`,
    willChange: "transform, width",
  });

  return (
    <div
      ref={navRef}
      style={{
        position: "relative",
        display: "inline-flex",
        padding: 6,
        borderRadius: 999,
        border: "1px solid oklch(0.32 0.02 270)",
        background: "oklch(0.19 0.02 270 / 0.8)",
        backdropFilter: "blur(12px)",
      }}
    >
      <svg width="0" height="0" style={{ position: "absolute" }} aria-hidden>
        <defs>
          <filter id={filterId}>
            {/* размыть → задрать альфа-контраст: соседние пятна «слипаются» в одну каплю */}
            <feGaussianBlur in="SourceGraphic" stdDeviation={goo} result="blur" />
            <feColorMatrix
              in="blur"
              mode="matrix"
              values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 24 -11"
            />
          </filter>
        </defs>
      </svg>

      {/* слой капель под фильтром — текст рисуется отдельно, чтобы не размывался */}
      <div style={{ position: "absolute", inset: 0, filter: `url(#${filterId})`, pointerEvents: "none" }}>
        <span style={blob(60, 620)} />
        <span style={blob(0, 420)} />
      </div>

      {items.map((label, i) => (
        <button
          key={label}
          data-goo-item
          onClick={() => setActive(i)}
          onPointerEnter={() => setPreview(i)}
          onPointerLeave={() => setPreview(null)}
          onFocus={() => setPreview(i)}
          onBlur={() => setPreview(null)}
          aria-current={i === active ? "page" : undefined}
          style={{
            position: "relative",
            padding: "0 20px",
            height: 38,
            border: 0,
            background: "none",
            borderRadius: 999,
            cursor: "pointer",
            font: "500 0.9rem/1 system-ui, sans-serif",
            color: i === target ? "#fff" : "oklch(0.72 0.02 265)",
            transition: "color 220ms ease",
            whiteSpace: "nowrap",
          }}
        >
          {label}
        </button>
      ))}
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); техника gooey — SVG feGaussianBlur + feColorMatrix (Lucas Bebber, 2014)v1.0.0