Back

Animated Tabs

Tailwind UI's code switcher re-built with React Aria Components and animated with Framer Motion.

Preview content

This example is built on BuildUI's already excellent Animated Tabs recipe. It provides improved accessibility by using React Aria Components under the hood and demonstrates how to animate a tab handle that lives between a background and a label.

Best Practices

  • Prefix React Aria Components' imports to avoid confusion with custom components.

    import { Tab as AriaTab } from 'react-aria-components';
  • Easily style children inside the parent using data-slot.

    '[&>[data-slot=label]]:text-slate-600 [&>[data-slot=label]]:transition-colors group-hover:[&>[data-slot=label]]:text-slate-900'
  • Group styles by splitting them into multiple lines with clsx.

    className={clsx(
    // Tab text wrapper
    'relative z-20 flex items-center gap-x-2',
    // Tab icon
    '[&>[data-slot=icon]]:duration-[400ms] [&>[data-slot=icon]]:size-5 [&>[data-slot=icon]]:text-slate-600 [&>[data-slot=icon]]:transition-colors [&>[data-slot=icon]]:group-rac-selected:text-sky-500',
    // Tab text
    '[&>[data-slot=label]]:text-slate-600 [&>[data-slot=label]]:transition-colors group-hover:[&>[data-slot=label]]:text-slate-900 [&>[data-slot=label]]:group-rac-selected:text-slate-900',
    )}

Requirements

Code

import { CodeBracketIcon, EyeIcon } from '@heroicons/react/20/solid';
import { clsx } from 'clsx';
import { motion } from 'framer-motion';
import {
type TabPanelProps as AriaTabPanelProps,
type TabProps as AriaTabProps,
Tab as AriaTab,
TabList as AriaTabList,
TabPanel as AriaTabPanel,
Tabs as AriaTabs,
} from 'react-aria-components';
interface TabProps extends Omit<AriaTabProps, 'children' | 'className'> {
children: React.ReactNode;
}
function AnimatedTabs() {
return (
<AriaTabs className="flex flex-col items-stretch gap-y-4">
<AriaTabList className="flex gap-x-1 rounded-lg bg-slate-100 p-0.5">
<Tab id="preview">
<EyeIcon />
<span data-slot="label">Preview</span>
</Tab>
<Tab id="code">
<CodeBracketIcon />
<span data-slot="label">Code</span>
</Tab>
</AriaTabList>
<TabPanel id="preview">
<p>Preview content</p>
</TabPanel>
<TabPanel id="code">
<code>Code content</code>
</TabPanel>
</AriaTabs>
);
}
function Tab({ children, ...props }: TabProps) {
return (
<AriaTab
className="group relative flex cursor-pointer items-center rounded-md px-2 py-[0.4375rem] text-sm font-semibold focus-visible:outline-none rac-selected:cursor-default"
{...props}
>
{({ isSelected }) => (
<>
{isSelected && <TabHandle />}
<div
className={clsx(
'relative z-20 flex items-center gap-x-2',
'[&>[data-slot=icon]]:duration-[400ms] [&>[data-slot=icon]]:size-5 [&>[data-slot=icon]]:text-slate-600 [&>[data-slot=icon]]:transition-colors [&>[data-slot=icon]]:group-rac-selected:text-sky-500',
'[&>[data-slot=label]]:text-slate-600 [&>[data-slot=label]]:transition-colors group-hover:[&>[data-slot=label]]:text-slate-900 [&>[data-slot=label]]:group-rac-selected:text-slate-900',
)}
>
{children}
</div>
</>
)}
</AriaTab>
);
}
function TabPanel({
children,
...props
}: Omit<AriaTabPanelProps, 'className'>) {
return (
<AriaTabPanel
className="rounded-lg bg-white p-8 ring-1 ring-slate-900/10"
{...props}
>
{children}
</AriaTabPanel>
);
}
function TabHandle() {
return (
<motion.span
className="absolute inset-0 z-10 rounded-md bg-white shadow"
layoutId="bubble"
transition={{ type: 'spring', bounce: 0, duration: 0.4 }}
/>
);
}