Tabs
Tabbed navigation used to organize related content and facilitate toggling between sections without losing context.
Main implementation of the Tabs component, managing layouts, variants, sizes, and accessibility.
"use client";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import * as React from "react";
import { cn } from "@/lib/utils";
import { designSizes } from "@/lib/design-system";
type TabsVariant = "default" | "bordered" | "ghost" | "underline" | "pills" | "contained";
type TabsSize = "xs" | "sm" | "md" | "lg" | "xl";
type TabsColor =
| "default"
| "primary"
| "secondary"
| "success"
| "warning"
| "danger"
| "custom";
interface TabsProps extends React.ComponentProps<typeof TabsPrimitive.Root> {
onTabChange?: (value: string) => void;
}
interface TabsListProps extends React.ComponentProps<typeof TabsPrimitive.List> {
background?: boolean;
isScrollable?: boolean;
/** Accessible label for the tab list. Useful when there are multiple tab lists on the same page. */
label?: string;
}
interface TabsTriggerProps
extends React.ComponentProps<typeof TabsPrimitive.Trigger> {
startContent?: React.ReactNode;
endContent?: React.ReactNode;
badgeContent?: string;
badgePosition?: "start" | "end";
variant?: TabsVariant;
size?: TabsSize;
color?: TabsColor;
customColor?: string;
isDisabled?: boolean;
isLoading?: boolean;
}
interface TabsContentProps
extends React.ComponentProps<typeof TabsPrimitive.Content> {}
const colorClasses: Record<
Exclude<TabsColor, "custom">,
Record<TabsVariant, string>
> = {
default: {
default: "data-[state=active]:bg-default data-[state=active]:text-default-foreground",
ghost: "data-[state=active]:bg-default/20 data-[state=active]:text-default-foreground",
bordered: "data-[state=active]:border-default data-[state=active]:text-default-foreground",
underline: "data-[state=active]:border-default data-[state=active]:text-default-foreground",
pills: "data-[state=active]:bg-default data-[state=active]:text-default-foreground",
contained: "data-[state=active]:bg-default data-[state=active]:text-default-foreground",
},
primary: {
default: "data-[state=active]:bg-primary data-[state=active]:text-primary-foreground",
ghost: "data-[state=active]:bg-primary/20 data-[state=active]:text-primary",
bordered: "data-[state=active]:border-primary data-[state=active]:text-primary",
underline: "data-[state=active]:border-primary data-[state=active]:text-primary",
pills: "data-[state=active]:bg-primary data-[state=active]:text-primary-foreground",
contained: "data-[state=active]:bg-primary data-[state=active]:text-primary-foreground",
},
secondary: {
default: "data-[state=active]:bg-secondary data-[state=active]:text-secondary-foreground",
ghost: "data-[state=active]:bg-secondary/20 data-[state=active]:text-secondary",
bordered: "data-[state=active]:border-secondary data-[state=active]:text-secondary",
underline: "data-[state=active]:border-secondary data-[state=active]:text-secondary",
pills: "data-[state=active]:bg-secondary data-[state=active]:text-secondary-foreground",
contained: "data-[state=active]:bg-secondary data-[state=active]:text-secondary-foreground",
},
success: {
default: "data-[state=active]:bg-success data-[state=active]:text-success-foreground",
ghost: "data-[state=active]:bg-success/20 data-[state=active]:text-success",
bordered: "data-[state=active]:border-success data-[state=active]:text-success",
underline: "data-[state=active]:border-success data-[state=active]:text-success",
pills: "data-[state=active]:bg-success data-[state=active]:text-success-foreground",
contained: "data-[state=active]:bg-success data-[state=active]:text-success-foreground",
},
warning: {
default: "data-[state=active]:bg-warning data-[state=active]:text-warning-foreground",
ghost: "data-[state=active]:bg-warning/20 data-[state=active]:text-warning",
bordered: "data-[state=active]:border-warning data-[state=active]:text-warning",
underline: "data-[state=active]:border-warning data-[state=active]:text-warning",
pills: "data-[state=active]:bg-warning data-[state=active]:text-warning-foreground",
contained: "data-[state=active]:bg-warning data-[state=active]:text-warning-foreground",
},
danger: {
default: "data-[state=active]:bg-danger data-[state=active]:text-danger-foreground",
ghost: "data-[state=active]:bg-danger/20 data-[state=active]:text-danger",
bordered: "data-[state=active]:border-danger data-[state=active]:text-danger",
underline: "data-[state=active]:border-danger data-[state=active]:text-danger",
pills: "data-[state=active]:bg-danger data-[state=active]:text-danger-foreground",
contained: "data-[state=active]:bg-danger data-[state=active]:text-danger-foreground",
},
};
const variantClasses: Record<TabsVariant, string> = {
default:
"bg-background text-foreground hover:bg-muted/50 rounded-3xl shadow-md border border-border transition-all duration-200",
ghost:
"bg-transparent text-muted-foreground hover:bg-muted rounded-3xl border border-transparent shadow-none transition-all duration-200",
bordered:
"bg-transparent text-foreground border-2 border-border hover:bg-muted rounded-3xl shadow-sm transition-all duration-200",
underline:
"bg-transparent text-muted-foreground hover:text-foreground rounded-none transition-all duration-200 border-b-2 border-transparent",
pills:
"bg-transparent text-muted-foreground hover:text-foreground rounded-full transition-all duration-200 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground shadow-xs",
contained:
"bg-muted/40 text-muted-foreground hover:text-foreground rounded-xl transition-all duration-200 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
};
const stateClasses = {
disabled: "opacity-50 cursor-not-allowed pointer-events-none select-none",
loading: "cursor-wait data-[state=active]:opacity-80",
};
const Spinner = React.memo(() => {
return (
// aria-hidden: aria-busy on the trigger already signals loading to screen readers
<span
className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent"
aria-hidden="true"
/>
);
});
Spinner.displayName = "Spinner";
const Tabs = React.memo(({ className, onTabChange, ...props }: TabsProps) => {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-4", className)}
onValueChange={onTabChange}
{...props}
/>
);
});
Tabs.displayName = "Tabs";
const TabsList = React.memo(({ className, background = true, label, ...props }: TabsListProps) => {
return (
<div className="overflow-x-auto scrollbar-none flex items-center relative">
<TabsPrimitive.List
data-slot="tabs-list"
// aria-label forwarded so multi-tablist pages can distinguish between them
aria-label={label}
className={cn(
"inline-flex items-center gap-2 rounded-2xl p-1 scroll-snap-x-x",
background && "bg-muted",
className
)}
{...props}
/>
</div>
);
});
TabsList.displayName = "TabsList";
const TabsTrigger = React.memo(({
className,
startContent,
endContent,
badgeContent,
badgePosition = "end",
variant = "default",
size = "md",
color = "default",
customColor,
isDisabled = false,
isLoading = false,
...props
}: TabsTriggerProps) => {
const disabled = isDisabled || isLoading;
const isCustom = color === "custom" && !!customColor;
const isHex = isCustom && customColor.startsWith("#");
return (
<TabsPrimitive.Trigger
style={
isHex
? ({
"--tabs-active-bg": customColor,
"--tabs-active-border": customColor,
"--tabs-active-text": "#ffffff",
} as React.CSSProperties)
: undefined
}
className={cn(
// outline-none + focus-visible:ring-* ensures keyboard users see a
// focus ring without showing one on mouse/touch interactions.
"inline-flex items-center justify-center gap-1.5 font-medium transition-all duration-200 ease-in-out cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
designSizes[size],
variantClasses[variant],
color !== "custom" && colorClasses[color][variant],
isHex &&
"data-[state=active]:bg-(--tabs-active-bg) data-[state=active]:border-(--tabs-active-border) data-[state=active]:text-(--tabs-active-text)",
disabled && stateClasses.disabled,
isLoading && stateClasses.loading,
className
)}
// Only set native disabled when isDisabled (not isLoading) so the tab
// stays focusable/discoverable while loading. aria-disabled covers both.
disabled={isDisabled}
aria-disabled={disabled || undefined}
aria-busy={isLoading || undefined}
{...props}
>
{isLoading ? (
<div className="flex items-center gap-2">
<Spinner />
{props.children}
</div>
) : (
<>
{badgePosition === "start" && badgeContent && (
// aria-hidden: badge count is decorative within the tab label context
<span
aria-hidden="true"
className={cn(
"mr-2 inline-flex items-center justify-center px-2 py-0.5 text-xs font-semibold rounded-full shadow bg-primary text-white"
)}
>
{badgeContent}
</span>
)}
{/* aria-hidden: icons are decorative; the tab's text label is the accessible name */}
{startContent && <span className="mr-1" aria-hidden="true">{startContent}</span>}
{props.children}
{endContent && <span className="ml-1" aria-hidden="true">{endContent}</span>}
{badgePosition === "end" && badgeContent && (
<span
aria-hidden="true"
className={cn(
"ml-2 inline-flex items-center justify-center px-2 py-0.5 text-xs font-semibold rounded-full shadow bg-primary text-white"
)}
>
{badgeContent}
</span>
)}
</>
)}
</TabsPrimitive.Trigger>
);
});
TabsTrigger.displayName = "TabsTrigger";
const TabsContent = React.memo(({ className, children, ...props }: TabsContentProps) => {
// Respect user's OS-level "reduce motion" preference (WCAG 2.3.3)
const shouldReduceMotion = useReducedMotion();
return (
// Radix TabsContent automatically gets role="tabpanel" and aria-labelledby
<TabsPrimitive.Content {...props} data-slot="tabs-content">
<AnimatePresence mode="wait">
<motion.div
key={props.value}
initial={shouldReduceMotion ? false : { opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
exit={shouldReduceMotion ? {} : { opacity: 0, x: -10 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.25 }}
className={cn(
"flex-1 outline-none transition-all duration-300 ease-in-out motion-reduce:transition-none motion-reduce:transform-none",
className
)}
>
{children}
</motion.div>
</AnimatePresence>
</TabsPrimitive.Content>
);
});
TabsContent.displayName = "TabsContent";
export { Tabs, TabsList, TabsTrigger, TabsContent };
Variants
Defines the visual appearance of the tab triggers through the variant prop, allowing the style to adapt to the interface context. When not specified, the default variant is used.
default | ghost | bordered | underlineIcons and Indicators
Use startContent and endContent to add optional visual elements that help reinforce the meaning of the tabs and make navigation faster and more intuitive.
React.ReactNodeendContent: React.ReactNodeBadges
Visual indicators used to highlight notifications, states, or quantities associated with the tabs, with positioning control via badgePosition.
stringbadgePosition: start | endBackground
Defines the background display in the tab group. By default, the background is shown, but can be disabled via the background prop.
booleanSizes
Allows adjusting the visual scale of tabs through the size prop. The default size is md, with options that adapt to different interface densities and contexts.
xs | sm | md | lg | xlColors
Defines the active tab color scheme through the color prop. You can use predefined semantic colors (like primary, success, or danger) or apply custom styles with customColor (HEX). This ensures visual consistency without limiting design flexibility.
default | primary | secondary | success | warning | danger | customcustomColor: string (HEX)Disabled State
Indicates tabs that are unavailable for interaction, applying an appropriate visual and behavioral state via the isDisabled prop.
booleanLoading State
Represents tabs performing asynchronous processing. When using the isLoading prop, interaction is temporarily blocked and a loading spinner is displayed.
booleanProps — Tabs
Available properties for the root Tabs component.
| Prop | Type | Default | Description |
|---|---|---|---|
| defaultValue | string | — | Defines the initial active tab when used in an uncontrolled way. |
| value | string | — | Manually controls the active tab. |
| onTabChange | (value: string) => void | — | Callback fired whenever the active tab changes. |
Props — TabsList
Configures the container wrapping the tab triggers.
| Prop | Type | Options | Default | Description |
|---|---|---|---|---|
| background | boolean | true | false | true | Controls the background display wrapping the tabs. |
Props — TabsTrigger
Properties responsible for tab appearance, behaviors, and states.
| Prop | Type | Options | Default | Description |
|---|---|---|---|---|
| variant | string | default | ghost | bordered | underline | default | Defines the tab's visual style. |
| size | string | xs | sm | md | lg | xl | md | Controls the visual size and density of the tab. |
| color | string | primary | secondary | success | danger | custom | primary | Defines the active state color scheme. |
| customColor | string | HEX | — | Custom color used when color="custom". |
| startContent | ReactNode | — | — | Element displayed before the text (icons, avatars). |
| endContent | ReactNode | — | — | Element displayed after the text. |
| badgeContent | string | — | — | Displays an informational badge next to the tab. |
| badgePosition | string | start | end | end | Defines the position of the badge relative to the text. |
| isDisabled | boolean | true | false | false | Disables tab interaction. |
| isLoading | boolean | true | false | false | Displays a loading state and blocks interaction. |