Tabs
The tab component prioritizes content switching at a users click. I opted for the animation to be emphasized in the tab headers and a simpler animation in the body. The user can feel the responsiveness of the click and see the results aka the content easily. There is a more fun approach of creating a jelly like selected tab indicator using a spring animation but it didn’t feel associated with the user’s input to interact with the element. I omitted any images for this version this time around to focus on the core of what tabs achieve in a UI. I think going forward, there’s possibility to add an image but I would have to explore making the image an optional prompt in the case that the user only has text content to offer within the tabs. Side note, this component is where I learned about utilizing layoutId, layout, key, mode, and Animate Presence from motion.dev. I think the biggest thing that I wasn’t understanding at first was the pill ui that I have when selecting the tab and having it move to the next selected item. It was a nice combination of utilizing state and an animation library to make a fleshed-out component.
Draft Tab
Low-Fi Tab
Hi-Fi Tab
Props & Styling
Props
| Prop | Type | Default | Description |
|---|---|---|---|
tabs | { title: string; content: ReactNode }[] | 3 built-in demo tabs | Tab list. Each content renders in the body when its tab is active. |
Styling
The active-tab indicator is a shared-layout pill (layoutId="pill", layout, 0.3s easeInOut) — Motion animates it between tab headers rather than fading a new one in, which is what makes it read as one element moving instead of two elements crossfading. The body content below cross-fades on opacity (0.2s easeOut) inside AnimatePresence mode="wait", keyed on the active tab index, so the outgoing content fully exits before the incoming content mounts — never both visible at once.
The tab header row is overflow-x-auto rather than wrapping, so a long tab set scrolls horizontally instead of growing to multiple lines.
Variants
Two tabs
The pill and content transitions work the same regardless of tab count.
Overview
Details
Long tab labels
The header row scrolls horizontally (overflow-x-auto) instead of wrapping.
General Settings
Notification Preferences
Billing & Invoices
Code
import {AnimatePresence, motion} from 'motion/react';
import { useState } from "react";
const MotionDiv = motion.create('div');
export interface TabItem {
title: string
content: React.ReactNode
}
interface TabsProps {
tabs?: TabItem[]
}
const defaultTabs: TabItem[] = [
{
title: "Draft Tab",
content: "Content is here and will change."
},
{
title: "Low-Fi Tab",
content: "Content here is low-fidelity and may change."
},
{
title: "Hi-Fi Tab",
content: "Content here is high-fidelity and close to final."
}
]
const Tabs = ({ tabs = defaultTabs }: TabsProps) => {
const [activeTab, setActiveTab] = useState(0);
return (
<div className = "flex flex-col gap-1">
<div className = "relative flex flex-row gap-4 py-2 overflow-x-auto border-b border-gray-200">
{tabs.map((tab, index) => (
<div key={index}
onClick={() => setActiveTab(index)}
className = "relative z-20 flex flex-col flex-shrink-0 cursor-pointer"
>
{activeTab === index && (
<MotionDiv
layoutId = "pill"
layout
transition = {{ ease: 'easeInOut', duration: 0.3 }}
className = "absolute inset-0 z-10 w-full h-auto bg-blue-200 rounded-md"
/>
)}
<h3 className = {activeTab === index ? "text-blue-800 font-medium relative z-20 px-2 whitespace-nowrap" : "text-gray-400 relative z-20 px-2 whitespace-nowrap hover:font-medium"}>
{tab.title}
</h3>
</div>
))}
</div>
<AnimatePresence
mode='wait'
>
{tabs[activeTab] && (
<MotionDiv
key = {activeTab}
initial = {{opacity: 0}}
animate = {{opacity: 1}}
exit = {{opacity: 0}}
transition = {{duration: 0.2, ease: "easeOut"}}
>
<div>{tabs[activeTab].content}</div>
</MotionDiv>
)}
</AnimatePresence>
</div>
)
}
export default Tabs