Создание анимированного компонента вкладок с Shadcn/ui для React

Сегодня разбираем материал freecodecamp.org о теме «Как создать анимированный компонент вкладок с помощью Shadcn/ui». Практический разбор с шагами и примерами, который можно быстро применить в своей работе.


Ваши вкладки могут стать более интерактивными благодаря анимациям, которые добавляют движения и эффект раскрывающихся карточек при наведении, а также динамическому индикатору, плавно перемещающемуся между кнопками.

Простой переключатель вкладок лишь скрывает и показывает контент. Более сложные решения обеспечивают четкое отображение активного состояния, гладкие переходы и добавляют движение, что делает интерфейс более живым.

В этом руководстве вы создадите компонент вкладок, который будет полностью анимирован с использованием Shadcn/ui, Framer Motion и компонентов из реестра Shadcn Space.

По завершении у вас будет переиспользуемый компонент <Tabs/> с:

  • Пружинно-анимированным индикатором активной вкладки в виде таблетки
  • Эффектом сложенных карточек, раскрывающихся при наведении
  • Плавной анимацией появления при смене активной вкладки
  • Полностью тема-зависимой стилизацией с использованием CSS-переменных Shadcn/ui

Видеоруководство: если вы предпочитаете визуальное сопровождение, посмотрите полный урок на YouTube.

Предварительные требования

Прежде чем начать, убедитесь, что вы обладаете рабочими знаниями в следующих областях:

  • Основы React и TypeScript
  • Утилитарные классы Tailwind CSS
  • Основы Shadcn/ui (установка компонентов и темизация)

Вам также понадобится проект на Next.js или Vite со следующим уже настроенным:

  • Shadcn/ui установлен и инициализирован
  • Framer Motion (также называемый motion/react) установлен

По этой теме полезно отдельно посмотреть Создание динамических форм в React и Next.js, чтобы расширить контекст и сравнить подходы.

Что вы создадите

Вот обзор архитектуры компонента, которую вы создадите в этом руководстве:

AnimatedTabMotion (точка входа страницы/демо)
└── Tabs (панель вкладок + оркестратор контента) ├── Кнопки вкладок (с пружинно-анимированной активной таблеткой) └── FadeInStack (сложенные, анимированные панели контента)

Ключевые поведения:

Пружинная анимация таблетки позволяет индикатору активной вкладки, выполненному в форме таблетки, перемещаться от одной кнопки к другой с применением физических кривых и нестандартных CSS-переходов. Вместо резкого движения таблетка плавно скользит между вкладками, добавляя эффект отскока в конце, что создает ощущение легкости.

Эффект сложенных карточек — неактивные панели вкладок отображаются позади активной, уменьшенные и слегка затемнённые, создавая иллюзию слоистой глубины.

Раскрытие при наведении — когда пользователь наводит курсор на область контента, сложенные карточки разворачиваются вертикально.

Анимация отскока при появлении — верхняя (активная) карточка анимируется вниз и возвращается на место при выборе новой вкладки.

Установка компонента через Shadcn Space CLI

Shadcn Space — это реестр готовых компонентов, совместимых с Shadcn/ui. Он значительно упрощает процесс интеграции, позволяя подгружать необходимые компоненты в ваш проект через Shadcn CLI.

Ознакомьтесь с их руководством по началу работы, чтобы узнать, как использовать Shadcn CLI со сторонними реестрами.

Выполните одну из следующих команд в зависимости от вашего пакетного менеджера:

pnpm

pnpm dlx shadcn@latest add @shadcn-space/tabs-01

npm

npx shadcn@latest add @shadcn-space/tabs-01

Yarn

yarn dlx shadcn@latest add @shadcn-space/tabs-01

Bun

bunx --bun shadcn@latest add @shadcn-space/tabs-01

Это создает файл компонента в вашем проекте, который уже подключен к существующим токенам темы Shadcn/ui. Вы можете кастомизировать или расширять его по мере необходимости — это и является целью данного руководства.

Понимание структуры компонента

Перед тем как приступать к написанию кода, давайте рассмотрим полный компонент и разобьем его на логические части. Вот полная реализация:

"use client";
import { useState } from "react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
type Tab = { title: string; value: string; content?: React.ReactNode;
};
type TabsProps = { tabs: Tab[]; containerClassName?: string; activeTabClassName?: string; tabClassName?: string; contentClassName?: string;
};
const tabs = [ { title: "Product", value: "product", content: ( <div className="w-full overflow-hidden relative rounded-2xl p-10 text-xl md:text-4xl font-bold text-foreground bg-muted h-[300px] border border-border"> <p>Product Tab</p> </div> ), }, { title: "Services", value: "services", content: ( <div className="w-full overflow-hidden relative rounded-2xl p-10 text-xl md:text-4xl font-bold text-foreground bg-muted h-[300px] border border-border"> <p>Services tab</p> </div> ), }, { title: "Playground", value: "playground", content: ( <div className="w-full overflow-hidden relative rounded-2xl p-10 text-xl md:text-4xl font-bold text-foreground bg-muted h-[300px] border border-border"> <p>Playground tab</p> </div> ), }, { title: "Content", value: "content", content: ( <div className="w-full overflow-hidden relative rounded-2xl p-10 text-xl md:text-4xl font-bold text-foreground bg-muted h-[300px] border border-border"> <p>Content tab</p> </div> ), }, { title: "Random", value: "random", content: ( <div className="w-full overflow-hidden relative rounded-2xl p-10 text-xl md:text-4xl font-bold text-foreground bg-muted h-[300px] border border-border"> <p>Random tab</p> </div> ), },
];
const Tabs = ({ tabs, containerClassName, activeTabClassName, tabClassName, contentClassName,
}: TabsProps) => { const [activeIdx, setActiveIdx] = useState(0); const [hovering, setHovering] = useState(false);
const handleSelect = (idx: number) => { setActiveIdx(idx); };
const reorderedTabs = [ tabs[activeIdx], ...tabs.filter((_, i) => i !== activeIdx), ];
return ( <> <div className={cn( "flex flex-row items-center justify-start [perspective:1000px] relative overflow-auto sm:overflow-visible no-visible-scrollbar max-w-full w-full", containerClassName, )} > {tabs.map((tab, idx) => { const isActive = idx === activeIdx; return ( <button key={tab.value} onClick={() => handleSelect(idx)} onMouseEnter={() => setHovering(true)} onMouseLeave={() => setHovering(false)} className={cn("relative px-4 py-2 rounded-full", tabClassName)} style={{ transformStyle: "preserve-3d" }} > {isActive && ( <motion.div layoutId="clickedbutton" transition={{ type: "spring", bounce: 0.3, duration: 0.6 }} className={cn( "absolute inset-0 bg-primary rounded-full", activeTabClassName, )} /> )} <span className={cn( "relative block text-sm", isActive ? "text-background" : "text-foreground", )} > {tab.title} </span> </button> ); })} </div> <FadeInStack tabs={reorderedTabs} hovering={hovering} className={cn("mt-10", contentClassName)} /> </> );
};
type FadeInStackProps = { className?: string; tabs: Tab[]; hovering?: boolean;
};
const FadeInStack = ({ className, tabs, hovering }: FadeInStackProps) => { return ( <div className="relative w-full h-[300px]"> {tabs.map((tab, idx) => ( <motion.div key={tab.value} layoutId={tab.value} style={{ scale: 1 - idx * 0.1, top: hovering ? idx * -15 : 0, zIndex: -idx, opacity: idx < 3 ? 1 - idx * 0.1 : 0, }} animate={{ y: idx === 0 ? [0, 40, 0] : 0, }} className={cn("w-full h-full absolute top-0 left-0", className)} > {tab.content} </motion.div> ))} </div> );
};
export default function AnimatedTabMotion() { return ( <> <div className="[perspective:1000px] relative flex flex-col max-w-5xl mx-auto w-full items-start justify-start mb-13"> <Tabs tabs={tabs} /> </div> </> );
}

Теперь давайте разберём это по частям.

Шаг 1: Определение типов данных вкладок

type Tab = { title: string; value: string; content?: React.ReactNode;
};

Тип Tab определяет форму каждого элемента вкладки:

  • title — метка, отображаемая в кнопке вкладки
  • value — уникальный ключ, используемый для идентификации каждой вкладки (и как layoutId для Framer Motion)
  • content — необязательный React.ReactNode, то есть вы можете передать любой JSX в качестве тела панели

Тип TabsProps делает компонент Tabs высококомпонуемым. Каждый визуальный слой имеет переопределяемый className, поэтому вы можете независимо изменять стиль активной таблетки, отдельных кнопок вкладок и области контента, не затрагивая основную логику.

Шаг 2: Создание массива данных вкладок

const tabs = [ { title: "Product", value: "product", content: ( <div ...>Product Tab</div> ), }, // ... ещё вкладки
];

Содержимое каждой вкладки — это JSX-элемент, стилизованный с помощью семантических токенов Shadcn/ui: bg-muted, text-foreground и border-border. Это сделано намеренно: такие токены автоматически адаптируются к светлой и тёмной теме без какой-либо дополнительной настройки.

Вы можете заменить эти заглушки <div> на любое реальное содержимое: графики, формы, таблицы, медиафайлы — всё, что требует ваш сценарий использования.

Шаг 3: Создание компонента Tabs (панель вкладок + состояние)

const [activeIdx, setActiveIdx] = useState(0);
const [hovering, setHovering] = useState(false);

Весь компонент управляется двумя состояниями:

  • activeIdx отслеживает, какая вкладка выбрана в данный момент (по индексу в массиве)
  • hovering отслеживает, находится ли курсор пользователя над какой-либо кнопкой вкладки; это значение передаётся в FadeInStack для запуска эффекта веерного раскрытия

Переупорядочивание вкладок для эффекта стека

const reorderedTabs = [ tabs[activeIdx], ...tabs.filter((_, i) => i !== activeIdx),
];

Это один из наиболее остроумных архитектурных приёмов. Вместо того чтобы показывать содержимое только активной вкладки, вы всегда рендерите все панели — но помещаете активную первой в массиве. Именно это создаёт визуальный эффект сложенных карточек:

  • Индекс 0 = активная панель, отображается поверх с полным масштабом и непрозрачностью
  • Индекс 1, 2 = следующие панели, расположены позади с уменьшенным масштабом и непрозрачностью
  • Индекс 3 и далее = скрыты (opacity 0)

Рендеринг кнопок вкладок с анимированной «таблеткой» на пружине

{tabs.map((tab, idx) => { const isActive = idx === activeIdx; return ( <button key={tab.value} onClick={() => handleSelect(idx)} onMouseEnter={() => setHovering(true)} onMouseLeave={() => setHovering(false)} className={cn("relative px-4 py-2 rounded-full", tabClassName)} style={{ transformStyle: "preserve-3d" }} > {isActive && ( <motion.div layoutId="clickedbutton" transition={{ type: "spring", bounce: 0.3, duration: 0.6 }} className={cn( "absolute inset-0 bg-primary rounded-full", activeTabClassName, )} /> )} <span className={cn( "relative block text-sm", isActive ? "text-background" : "text-foreground", )} > {tab.title} </span> </button> );
})}

Вся магия здесь — в layoutId="clickedbutton" на motion.div. Когда в один момент времени смонтирован только один элемент с заданным layoutId, Framer Motion отслеживает его позицию в DOM. Когда он размонтируется с одной кнопки и монтируется на другую, Framer Motion автоматически анимирует переход между двумя позициями в DOM. Это создаёт эффект скользящей «таблетки» без каких-либо ручных вычислений.

Конфигурация перехода использует пружину с bounce: 0.3 и duration: 0.6, что придаёт движению естественный, слегка упругий характер вместо механического линейного скольжения.

transformStyle: "preserve-3d" на кнопке включает трёхмерные CSS-трансформации, которые в сочетании с [perspective:1000px] на контейнере создают тонкий эффект глубины.

Шаг 4: Создание компонента FadeInStack

const FadeInStack = ({ className, tabs, hovering }: FadeInStackProps) => { return ( <div className="relative w-full h-[300px]"> {tabs.map((tab, idx) => ( <motion.div key={tab.value} layoutId={tab.value} style={{ scale: 1 - idx * 0.1, top: hovering ? idx * -15 : 0, zIndex: -idx, opacity: idx < 3 ? 1 - idx * 0.1 : 0, }} animate={{ y: idx === 0 ? [0, 40, 0] : 0, }} className={cn("w-full h-full absolute top-0 left-0", className)} > {tab.content} </motion.div> ))} </div> );
};

Разберём визуальную логику для каждого motion.div.

scale: 1 — idx * 0.1

Каждая карточка позади активной уменьшается на 10% на каждый слой. Таким образом:

  • Активная карточка (idx 0): scale: 1.0
  • Вторая карточка (idx 1): scale: 0.9
  • Третья карточка (idx 2): scale: 0.8

Это создаёт чёткое разделение по глубине между слоями стека.

top: hovering ? idx * -15 : 0

Когда hovering равно true, каждая карточка смещается вверх на idx 15px. Активная карточка не двигается (idx 15 = 0), а карточки позади неё раскрываются веером на -15px, -30px и так далее. Это создаёт приятный эффект «раскладывания колоды» при наведении.

zIndex: -idx

Отрицательный z-index выстраивает карточки по порядку: активная карточка находится сверху (z-index 0), а последующие уходят всё дальше назад.

opacity: idx < 3 ? 1 — idx * 0.1 : 0

Карточки с индексом 3 и выше скрыты полностью. Первые три карточки постепенно теряют непрозрачность: 1.0, 0.9, 0.8.

animate={{ y: idx === 0 ? [0, 40, 0] : 0 }}

Только активная карточка (idx 0) получает эту покадровую анимацию (keyframe animation). Когда выбирается вкладка и массив reorderedTabs перестраивается, новая активная карточка появляется с небольшим провалом вниз (y: 40) и возвращается в исходное положение. Это быстрое тактильное подтверждение того, что вкладка сменилась.

layoutId={tab.value}

Каждая карточка также имеет layoutId, совпадающий с её значением value. Когда reorderedTabs пересчитывается и позиции в массиве меняются, Framer Motion может отслеживать идентичность каждой карточки и плавно анимировать её перемещение между позициями, предотвращая резкие скачки.

Шаг 5: Создание компонента страницы

export default function AnimatedTabMotion() { return ( <div className="[perspective:1000px] relative flex flex-col max-w-5xl mx-auto w-full items-start justify-start mb-13"> <Tabs tabs={tabs} /> </div> );
}

Внешняя обёртка применяет [perspective:1000px] — произвольное свойство Tailwind, задающее значение CSS perspective. Именно оно придаёт трёхмерную глубину transformStyle: "preserve-3d" на кнопках вкладок.

max-w-5xl и mx-auto центрируют компонент на широких экранах, а items-start выравнивает панель вкладок по левому краю, что соответствует большинству реальных паттернов интерфейса.

Шаг 6: Кастомизация компонента

Поскольку Tabs принимает переопределения классов для каждого визуального слоя, вы можете полностью изменить стиль компонента в соответствии с вашей дизайн-системой. Я сам часто начинаю именно с этих параметров, когда встраиваю компонент в новый проект. Вот пример с более тёмной активной «таблеткой» и более компактным макетом:

<Tabs tabs={tabs} containerClassName="gap-1" tabClassName="text-xs px-3 py-1.5" activeTabClassName="bg-zinc-900 dark:bg-white" contentClassName="mt-6"
/>

Вы также можете заменить заглушки панелей содержимого на реальный контент — например, карточку с настоящим описанием, график или форму. Компонент не накладывает никаких ограничений на то, что передаётся в поле content каждой вкладки.

Типичные ошибки при реализации

Работая с этим паттерном, я встречал несколько ошибок, которые легко допустить при первой реализации.

Дублирующийся layoutId. Если вы используете несколько экземпляров компонента Tabs на одной странице, layoutId="clickedbutton" будет конфликтовать между ними. Решение — параметризовать layoutId через пропс, например layoutId={tabGroupId + "-pill"}.

Фиксированная высота контейнера. FadeInStack использует h-[300px] как базовое значение. Если ваш контент имеет переменную высоту, карточки будут обрезаться или перекрываться. Задайте высоту явно через contentClassName или адаптируйте компонент под динамическое измерение высоты через ResizeObserver.

Отсутствие "use client". Компонент использует useState и Framer Motion, которые работают только на клиенте. В Next.js App Router директива "use client" обязательна — без неё вы получите ошибку гидратации.

Слишком много вкладок. Эффект стека визуально работает при 3–6 вкладках. При большем количестве карточки за пределами индекса 2 всё равно скрыты, но рендерятся в DOM. Если вкладок много, рассмотрите ленивую загрузку контента.

Краткое резюме ключевых концепций

Вот сводка основных техник Framer Motion, использованных в этом компоненте:

ТехникаЧто делает
layoutId на motion.divАнимирует общий элемент между позициями в DOM (скользящая «таблетка»)
layoutId на motion.div для каждой вкладкиОтслеживает идентичность карточки при переупорядочивании, чтобы Framer Motion анимировал изменения позиций
animate={{ y: [0, 40, 0] }}Покадровая анимация для эффекта упругого появления при смене вкладки
style={{ scale, top, zIndex, opacity }}Встроенные реактивные стили, создающие эффект глубины сложенных карточек
transition={{ type: "spring" }}Применяет физически обоснованную пружинную кривую вместо CSS-функции плавности

В этом руководстве вы создали полностью анимированный, адаптированный к теме компонент вкладок с использованием Shadcn/ui и Framer Motion. По моему опыту, именно сочетание layoutId и переупорядочивания массива даёт наиболее выразительный результат при минимальном объёме кода. Мой совет — начните с базовой реализации и постепенно добавляйте кастомизацию через пропсы классов, не трогая внутреннюю логику. Вы узнали, как:

  • Использовать layoutId для создания скользящего индикатора-«таблетки» с пружинной анимацией
  • Рендерить все панели вкладок одновременно и переупорядочивать их для создания эффекта стека карточек
  • Управлять эффектами наведения и глубины с помощью встроенных реактивных свойств стиля
  • Применять покадровые анимации Framer Motion для тактильного эффекта упругого появления
  • Сохранять полную кастомизируемость компонента через переопределения имён классов

Этот паттерн — сочетание семантических дизайн-токенов Shadcn/ui с анимациями макета Framer Motion — хорошо масштабируется далеко за пределы вкладок. Ту же технику layoutId и переупорядочивания стека можно применить к каруселям, галереям изображений, всплывающим уведомлениям и многому другому.

Вы можете изучить полный компонент и другие анимированные блоки интерфейса на Shadcn Space, где CLI-команда позволяет легко добавлять готовые к продакшену компоненты прямо в ваш проект.

Ресурсы

  • Shadcn Space Tabs Component
  • Shadcn Space Getting Started Guide
  • Framer Motion Documentation
  • Shadcn/ui Documentation
  • Video Tutorial on YouTube

Ответы на эти вопросы могут быть для вас полезными

Можно ли использовать этот компонент без Framer Motion?

Нет, в текущей реализации Framer Motion обязателен: именно он обеспечивает layoutId-анимацию таблетки и покадровый эффект отскока. Без него придётся вручную вычислять позиции и управлять CSS-переходами, что значительно усложнит код.

Работает ли компонент с тёмной темой Shadcn/ui автоматически?

Да. Все цвета в компоненте используют семантические токены Shadcn/ui — bg-primary, bg-muted, text-foreground, border-border. Они автоматически переключаются при смене темы без дополнительных настроек.

Как добавить иконку рядом с заголовком вкладки?

Поле title в типе Tab принимает строку, но вы можете расширить тип до React.ReactNode и передавать JSX с иконкой. Достаточно изменить title: string на title: React.ReactNode в определении типа и обновить рендеринг {tab.title} в кнопке.

Что делать, если нужно управлять активной вкладкой извне компонента?

В текущей реализации состояние activeIdx хранится внутри Tabs. Чтобы сделать компонент управляемым (controlled), добавьте пропсы activeIdx и onTabChange и уберите внутренний useState — стандартный паттерн для React-компонентов с внешним управлением состоянием.

Оцените статью
0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
0
Оставьте комментарий! Напишите, что думаете по поводу статьи.x