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

Content is here and will change.

Props & Styling

Props

PropTypeDefaultDescription
tabs{ title: string; content: ReactNode }[]3 built-in demo tabsTab 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

A short summary lives here.

Long tab labels

The header row scrolls horizontally (overflow-x-auto) instead of wrapping.

General Settings

Notification Preferences

Billing & Invoices

General settings content.

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