← каталог
Sliding Tabs
Табы со скользящей пилюлей-подложкой, которая плавно переезжает к активному пункту.
gsapclickrevealbasic~23 КБreduced-motion ✓
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| tabs? | string[] | ["Главная", "Каталог", "Docs", "Прайс"] |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/tab-underline.jsonзависимости: gsap@^3.15.0
Что нужно в проекте до установки
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, useRef, useState } from "react";
import gsap from "gsap";
/** Табы со скользящей пилюлей под активным пунктом (GSAP). Уважает data-shot (без анимации). */
export function TabUnderline({ tabs = ["Главная", "Каталог", "Docs", "Прайс"] }: { tabs?: string[] }) {
const [active, setActive] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const pillRef = useRef<HTMLDivElement>(null);
const moveTo = (i: number, animate: boolean) => {
const list = listRef.current;
const pill = pillRef.current;
if (!list || !pill) return;
const btn = list.querySelectorAll("button")[i] as HTMLElement | undefined;
if (!btn) return;
const to = { x: btn.offsetLeft, width: btn.offsetWidth };
if (animate) gsap.to(pill, { ...to, duration: 0.4, ease: "power3.out" });
else gsap.set(pill, to);
};
useEffect(() => {
moveTo(active, false); // начальная позиция пилюли без анимации
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const select = (i: number) => {
setActive(i);
moveTo(i, !document.documentElement.hasAttribute("data-shot"));
};
return (
<div
ref={listRef}
style={{
position: "relative",
display: "inline-flex",
gap: 4,
padding: 4,
borderRadius: 999,
border: "1px solid oklch(0.3 0.02 270)",
background: "oklch(0.16 0.02 270)",
}}
>
<div
ref={pillRef}
style={{
position: "absolute",
top: 4,
bottom: 4,
left: 0,
borderRadius: 999,
background: "linear-gradient(100deg, oklch(0.62 0.19 280), oklch(0.72 0.15 220))",
zIndex: 0,
}}
/>
{tabs.map((t, i) => (
<button
key={t}
onClick={() => select(i)}
style={{
position: "relative",
zIndex: 1,
padding: "0.5rem 1.1rem",
border: "none",
background: "none",
cursor: "pointer",
fontWeight: 500,
fontSize: "0.95rem",
color: active === i ? "#fff" : "oklch(0.62 0.02 265)",
transition: "color 0.25s",
}}
>
{t}
</button>
))}
</div>
);
}
import gsap from "gsap";
/**
* Tab Underline — vanilla JS/GSAP (без React). Скользящая пилюля под активным табом.
* Разметка: контейнер (position:relative) + внутри элемент пилюли и кнопки.
*
* <div id="tabs" style="position:relative;display:inline-flex;gap:4px;padding:4px">
* <div data-pill style="position:absolute;top:4px;bottom:4px;left:0;border-radius:999px;background:#7c5cff"></div>
* <button>Главная</button><button>Каталог</button><button>Docs</button>
* </div>
* <script type="module">
* import { initTabUnderline } from './tab-underline.vanilla.js';
* initTabUnderline(document.querySelector('#tabs'), {
* onChange: (i) => console.log('активный таб', i),
* });
* </script>
*
* @param {HTMLElement} root — контейнер с [data-pill] и кнопками
* @param {{ initial?: number, onChange?: (i: number) => void }} [options]
* @returns {() => void} destroy
*/
export function initTabUnderline(root, { initial = 0, onChange } = {}) {
const pill = root.querySelector("[data-pill]");
const buttons = [...root.querySelectorAll("button")];
if (!pill || !buttons.length) return () => {};
const moveTo = (i, animate) => {
const btn = buttons[i];
const to = { x: btn.offsetLeft, width: btn.offsetWidth };
if (animate && !document.documentElement.hasAttribute("data-shot")) {
gsap.to(pill, { ...to, duration: 0.4, ease: "power3.out" });
} else {
gsap.set(pill, to);
}
buttons.forEach((b, bi) => b.setAttribute("aria-selected", String(bi === i)));
};
const handlers = buttons.map((btn, i) => {
const h = () => {
moveTo(i, true);
onChange?.(i);
};
btn.addEventListener("click", h);
return h;
});
moveTo(initial, false);
return () => buttons.forEach((btn, i) => btn.removeEventListener("click", handlers[i]));
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0