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

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

Что нужно в проекте до установки
  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";
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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0