← каталог
Gooey Nav
Жидкий индикатор навигации: капля перетекает к пункту, хвост догоняет с задержкой, SVG-фильтр склеивает их в «ртутную» форму. Ноль зависимостей.
csshoverclickmorphintermediate~2 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| items? | string[] | ITEMS | Подписи пунктов. |
| initial? | number | 0 | Индекс активного пункта на старте (формат «0»). |
| color? | string | "#8b5cf6" | Цвет капли (формат «#8b5cf6»). |
| goo? | number | 10 | Вязкость: больше — сильнее «слипание» и длиннее хвост (формат «10»). |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/gooey-nav.jsonЧто нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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>
);
}
/**
* Gooey Nav — vanilla JS, ноль зависимостей. Жидкий индикатор навигации: капля перетекает
* к активному пункту, хвост догоняет с задержкой, SVG-фильтр (blur + альфа-контраст)
* склеивает их в одну «ртутную» форму. Только CSS-переходы, ни одного rAF-лупа.
*
* <nav id="nav">
* <button aria-current="page">Работы</button>
* <button>Студия</button>
* <button>Журнал</button>
* </nav>
* <script type="module">
* import { initGooeyNav } from './gooey-nav.vanilla.js';
* const destroy = initGooeyNav(document.querySelector('#nav'), { color: '#8b5cf6' });
* </script>
*
* @param {HTMLElement} nav — контейнер; пункты — его кнопки/дети
* @param {{ itemSelector?: string, color?: string, goo?: number, height?: number }} [options]
* @returns {() => void} destroy
*/
export function initGooeyNav(
nav,
{ itemSelector = "button", color = "#8b5cf6", goo = 10, height = 38 } = {},
) {
const items = [...nav.querySelectorAll(itemSelector)];
if (items.length === 0) return () => {};
const uid = `goo-${Math.abs(Date.now() % 100000)}-${items.length}`;
nav.style.position = "relative";
nav.style.display = "inline-flex";
nav.style.padding = "6px";
nav.style.borderRadius = "999px";
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", "0");
svg.setAttribute("height", "0");
svg.setAttribute("aria-hidden", "true");
svg.style.position = "absolute";
svg.innerHTML = `<defs><filter id="${uid}">
<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>`;
nav.appendChild(svg);
const layer = document.createElement("div");
layer.style.cssText = `position:absolute;inset:0;filter:url(#${uid});pointer-events:none;`;
const mkBlob = (delay, duration) => {
const b = document.createElement("span");
b.style.cssText = `position:absolute;top:6px;left:0;height:${height}px;width:0;border-radius:999px;
background:${color};will-change:transform,width;
transition:transform ${duration}ms cubic-bezier(0.22,1,0.36,1) ${delay}ms,
width ${duration}ms cubic-bezier(0.22,1,0.36,1) ${delay}ms;`;
layer.appendChild(b);
return b;
};
const tail = mkBlob(60, 620);
const head = mkBlob(0, 420);
nav.insertBefore(layer, nav.firstChild);
let active = Math.max(0, items.findIndex((i) => i.getAttribute("aria-current")));
let target = active;
const paint = () => {
const el = items[target];
if (!el) return;
for (const b of [tail, head]) {
b.style.width = `${el.offsetWidth}px`;
b.style.transform = `translateX(${el.offsetLeft}px)`;
}
items.forEach((it, i) => {
it.style.color = i === target ? "#fff" : "oklch(0.72 0.02 265)";
});
};
// все подписки держим списком — destroy обязан вернуть чужой DOM в исходное состояние
const bound = [];
const on = (el, type, fn) => {
el.addEventListener(type, fn);
bound.push([el, type, fn]);
};
items.forEach((it, i) => {
it.style.cssText += `position:relative;padding:0 20px;height:${height}px;border:0;background:none;
border-radius:999px;cursor:pointer;font:500 0.9rem/1 system-ui,sans-serif;
transition:color 220ms ease;white-space:nowrap;`;
on(it, "click", () => {
active = i;
items.forEach((o, k) => (k === i ? o.setAttribute("aria-current", "page") : o.removeAttribute("aria-current")));
target = i;
paint();
});
const preview = () => {
target = i;
paint();
};
const restore = () => {
target = active;
paint();
};
on(it, "pointerenter", preview);
on(it, "focus", preview);
on(it, "pointerleave", restore);
on(it, "blur", restore);
});
on(window, "resize", () => paint());
paint();
return () => {
for (const [el, type, fn] of bound) el.removeEventListener(type, fn);
bound.length = 0;
layer.remove();
svg.remove();
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); техника gooey — SVG feGaussianBlur + feColorMatrix (Lucas Bebber, 2014)v1.0.0