Build websites 10x faster with HextaUI Blocks — Learn more
UI/UI

Tabs

A set of layered sections of content with smooth animations and modern styling.

<div className="flex flex-col items-center gap-6 w-full">
  <Tabs
    items={[
      { id: "home", label: "Home", icon: <Home /> },
      { id: "settings", label: "Settings", icon: <Settings /> },
      { id: "profile", label: "Profile", icon: <User /> },
    ]}
    defaultValue="home"
  />
</div>

Installation

Install following dependencies:

npm install motion class-variance-authority
pnpm add motion class-variance-authority
yarn add motion class-variance-authority
bun add motion class-variance-authority

Copy and paste the following code into your project.

components/ui/tabs.tsx
"use client";

import * as React from "react";
import { motion } from "motion/react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

const tabsVariants = cva(
  "relative inline-flex items-center justify-center rounded-lg transition-all duration-300 w-full",
  {
    variants: {
      variant: {
        default:
          "bg-[hsl(var(--hu-background))] border border-[hsl(var(--hu-border))]",
        ghost: "bg-transparent",
        underline:
          "bg-transparent border-b border-[hsl(var(--hu-border))] rounded-none",
      },
      size: {
        sm: "h-9 p-1",
        default: "h-10 p-1.5",
        lg: "h-12 p-2",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  },
);

const tabTriggerVariants = cva(
  "relative inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-all duration-300 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--hu-ring))] focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 flex-1",
  {
    variants: {
      variant: {
        default:
          "text-[hsl(var(--hu-muted-foreground))] hover:text-[hsl(var(--hu-foreground))] data-[state=active]:text-[hsl(var(--hu-primary-foreground))]",
        ghost:
          "text-[hsl(var(--hu-muted-foreground))] hover:text-[hsl(var(--hu-foreground))] hover:bg-[hsl(var(--hu-accent))] data-[state=active]:text-[hsl(var(--hu-primary-foreground))] data-[state=active]:bg-transparent",
        underline:
          "text-[hsl(var(--hu-muted-foreground))] hover:text-[hsl(var(--hu-foreground))] data-[state=active]:text-[hsl(var(--hu-accent-foreground))] rounded-none",
      },
      size: {
        sm: "px-2.5 py-1 text-xs",
        default: "px-3 py-1.5 text-sm",
        lg: "px-4 py-2 text-base",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  },
);

export interface TabItem {
  id: string;
  label: string;
  icon?: React.ReactNode;
}

export interface TabsProps
  extends React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof tabsVariants> {
  items: TabItem[];
  defaultValue?: string;
  value?: string;
  onValueChange?: (value: string) => void;
  indicatorColor?: string;
}

const Tabs = React.forwardRef<HTMLDivElement, TabsProps>(
  (
    {
      className,
      variant,
      size,
      items,
      defaultValue,
      value,
      onValueChange,
      indicatorColor = "hsl(var(--hu-accent))",
      ...props
    },
    ref,
  ) => {
    const [activeValue, setActiveValue] = React.useState(
      value || defaultValue || items[0]?.id,
    );
    const [activeTabBounds, setActiveTabBounds] = React.useState({
      left: 0,
      width: 0,
    });

    const tabRefs = React.useRef<(HTMLButtonElement | null)[]>([]);

    React.useEffect(() => {
      if (value !== undefined) {
        setActiveValue(value);
      }
    }, [value]);

    React.useEffect(() => {
      const activeIndex = items.findIndex(
        (item: TabItem) => item.id === activeValue,
      );
      const activeTab = tabRefs.current[activeIndex];

      if (activeTab) {
        const tabRect = activeTab.getBoundingClientRect();
        const containerRect = activeTab.parentElement?.getBoundingClientRect();

        if (containerRect) {
          setActiveTabBounds({
            left: tabRect.left - containerRect.left,
            width: tabRect.width,
          });
        }
      }
    }, [activeValue, items]);

    const handleTabClick = (tabId: string) => {
      setActiveValue(tabId);
      onValueChange?.(tabId);
    };

    return (
      <div
        ref={ref}
        className={cn(tabsVariants({ variant, size }), className)}
        {...props}
      >
        {" "}
        {/* Animated indicator */}
        <motion.div
          className={cn(
            "absolute z-10",
            variant === "underline"
              ? "bottom-0 h-0.5 rounded-none"
              : "top-1 bottom-1 rounded-md",
          )}
          style={{
            backgroundColor:
              variant === "underline"
                ? "hsl(var(--hu-foreground))"
                : indicatorColor,
          }}
          initial={false}
          animate={{
            left: activeTabBounds.left,
            width: activeTabBounds.width,
          }}
          transition={{
            type: "spring",
            stiffness: 400,
            damping: 30,
          }}
        />
        {/* Tab triggers */}
        {items.map((item: TabItem, index: number) => {
          const isActive = activeValue === item.id;

          return (
            <button
              key={item.id}
              ref={(el) => {
                tabRefs.current[index] = el;
              }}
              className={cn(
                tabTriggerVariants({ variant, size }),
                "relative z-20 text-[hsl(var(--hu-muted-foreground))] data-[state=active]:text-[hsl(var(--hu-accent-foreground))]",
              )}
              data-state={isActive ? "active" : "inactive"}
              onClick={() => handleTabClick(item.id)}
              type="button"
            >
              {item.icon && (
                <span className="mr-2 [&_svg]:size-4">{item.icon}</span>
              )}
              {item.label}
            </button>
          );
        })}
      </div>
    );
  },
);

Tabs.displayName = "Tabs";

// Content component for tab panels
export interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {
  value: string;
  activeValue?: string;
}

const TabsContent = React.forwardRef<HTMLDivElement, TabsContentProps>(
  ({ className, value, activeValue, children, ...props }, ref) => {
    const isActive = value === activeValue;

    if (!isActive) return null;

    const {
      onDrag,
      onDragStart,
      onDragEnd,
      onAnimationStart,
      onAnimationEnd,
      onTransitionEnd,
      ...divProps
    } = props;

    return (
      <motion.div
        ref={ref}
        className={cn(
          "ring-offset-[hsl(var(--hu-background))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--hu-ring))] focus-visible:ring-offset-2",
          className,
        )}
        initial={{ opacity: 0, y: 4 }}
        animate={{ opacity: 1, y: 0 }}
        exit={{ opacity: 0, y: 4 }}
        transition={{ duration: 0.2, ease: "easeOut" }}
        {...divProps}
      >
        {children}
      </motion.div>
    );
  },
);

TabsContent.displayName = "TabsContent";

export { Tabs, TabsContent, tabsVariants };
npx shadcn@latest add "https://ui.shadcn.com/docs/components/tabs"
pnpm dlx shadcn@latest add "https://ui.shadcn.com/docs/components/tabs"
yarn dlx shadcn@latest add "https://ui.shadcn.com/docs/components/tabs"
bun x shadcn@latest add "https://ui.shadcn.com/docs/components/tabs"

Usage

import { Tabs, TabsContent } from "@/components/ui/tabs";
import { TabsWithContent } from "@/components/ui/Tabs";
export default function MyComponent() {
  return <TabsWithContent />;
}

Examples

Default

<Tabs
  items={[
    { id: "account", label: "Account" },
    { id: "password", label: "Password" },
    { id: "settings", label: "Settings" },
  ]}
  defaultValue="account"
/>

Variants

Default

Ghost

Underline

<Tabs variant="default" items={tabs} defaultValue="home" />
<Tabs variant="ghost" items={tabs} defaultValue="home" />
<Tabs variant="underline" items={tabs} defaultValue="home" />

With Icons

import { Home, Search, Bell, Heart } from "lucide-react";

<Tabs
  items={[
    { id: "home", label: "Home", icon: <Home /> },
    { id: "search", label: "Search", icon: <Search /> },
    { id: "notifications", label: "Notifications", icon: <Bell /> },
    { id: "favorites", label: "Favorites", icon: <Heart /> },
  ]}
  defaultValue="home"
/>

Sizes

Small

Default

Large

<Tabs size="sm" items={tabs} defaultValue="tab1" />
<Tabs size="default" items={tabs} defaultValue="tab1" />
<Tabs size="lg" items={tabs} defaultValue="tab1" />
```  </Tab>
</Tabs>

### Underline Variant

<Tabs items={["Preview", "Code"]}>
<Tab value="Preview">
<PreviewContainer>
  <div className="w-full max-w-md mx-auto">
    <TabsUnderlineExample />
  </div>
</PreviewContainer>
</Tab>
<Tab value="Code">
```tsx
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { Home, Settings, User, Bell } from "lucide-react";

function TabsUnderlineExample() {
  const [activeTab, setActiveTab] = useState("home");

  return (
    <div className="w-full">
      <Tabs
        variant="underline"
        items={[
          { id: "home", label: "Home", icon: <Home /> },
          { id: "notifications", label: "Notifications", icon: <Bell /> },
          { id: "profile", label: "Profile", icon: <User /> },
          { id: "settings", label: "Settings", icon: <Settings /> },
        ]}
        value={activeTab}
        onValueChange={setActiveTab}
      />

      <TabsContent value="home" activeValue={activeTab}>
        <div className="p-4 bg-accent/5 rounded-md mt-4">
          <h3 className="font-semibold">Home Content</h3>
          <p className="text-sm text-muted-foreground mt-2">
            This is the home tab content with underline variant.
          </p>
        </div>
      </TabsContent>

      {/* Other TabsContent components... */}
    </div>
  );
}

With Content

Overview

Welcome to your dashboard overview. Here you can see a summary of your most important metrics and recent activity.

function TabsWithContent() {
  const [activeTab, setActiveTab] = useState("overview");

  return (
    <div className="w-full max-w-md">
      <Tabs
        items={[
          { id: "overview", label: "Overview", icon: <Home /> },
          { id: "analytics", label: "Analytics", icon: <Search /> },
          { id: "settings", label: "Settings", icon: <Settings /> },
        ]}
        value={activeTab}
        onValueChange={setActiveTab}
      />

      <div className="mt-4 p-4 border rounded-[var(--radius)]">
        <TabsContent value="overview" activeValue={activeTab}>
          <div className="space-y-2">
            <h3 className="text-lg font-semibold">Overview</h3>
            <p className="text-sm text-muted-foreground">
              Welcome to your dashboard overview...
            </p>
          </div>
        </TabsContent>

        <TabsContent value="analytics" activeValue={activeTab}>
          <div className="space-y-2">
            <h3 className="text-lg font-semibold">Analytics</h3>
            <p className="text-sm text-muted-foreground">
              Detailed analytics and insights...
            </p>
          </div>
        </TabsContent>

        <TabsContent value="settings" activeValue={activeTab}>
          <div className="space-y-2">
            <h3 className="text-lg font-semibold">Settings</h3>
            <p className="text-sm text-muted-foreground">
              Configure your preferences...
            </p>
          </div>
        </TabsContent>
      </div>
    </div>
  );
}

Props

Tabs

PropTypeDefault
className?
string
undefined
indicatorColor?
string
"hsl(var(--hu-accent))"
size?
"sm" | "default" | "lg"
"default"
variant?
"default" | "ghost" | "underline"
"default"
onValueChange?
(value: string) => void
undefined
value?
string
undefined
defaultValue?
string
items[0]?.id
items?
TabItem[]
undefined

TabsContent

PropTypeDefault
className?
string
undefined
activeValue?
string
undefined
value?
string
undefined

TabItem

PropTypeDefault
icon?
React.ReactNode
undefined
label?
string
undefined
id?
string
undefined
Edit on GitHub

Last updated on