Tabs
Pill, segment, or underline tabs with an indicator that slides to the selected tab.
$12
Billed every month
$120
Two months free
$40
Up to five seats
"use client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/motion/tabs";
const PLANS = [
{ value: "monthly", label: "Monthly", price: "$12", note: "Billed every month" },
{ value: "yearly", label: "Yearly", price: "$120", note: "Two months free" },
{ value: "team", label: "Team", price: "$40", note: "Up to five seats" },
];
export function TabsPreview() {
return (
<div className="flex w-full max-w-sm flex-col items-center gap-10">
<Tabs defaultValue="yearly" variant="pill" className="flex flex-col items-center gap-4">
<TabsList>
{PLANS.map((plan) => (
<TabsTrigger key={plan.value} value={plan.value}>
{plan.label}
</TabsTrigger>
))}
</TabsList>
{PLANS.map((plan) => (
<TabsContent key={plan.value} value={plan.value} className="text-center">
<p className="text-3xl font-semibold tabular-nums text-foreground">{plan.price}</p>
<p className="mt-1 text-sm text-muted-foreground">{plan.note}</p>
</TabsContent>
))}
</Tabs>
<Tabs defaultValue="inbox" variant="underline">
<TabsList>
<TabsTrigger value="inbox">Inbox</TabsTrigger>
<TabsTrigger value="drafts">Drafts</TabsTrigger>
<TabsTrigger value="sent">Sent</TabsTrigger>
</TabsList>
</Tabs>
</div>
);
}
"use client";
// easeui.dev/components/motion/tabs
import { MotionConfig, motion, useReducedMotion } from "motion/react";
import {
createContext,
type KeyboardEvent,
type ReactNode,
useCallback,
useContext,
useId,
useMemo,
useState,
} from "react";
import { cn } from "@/lib/utils";
type Variant = "pill" | "segment" | "underline";
interface TabsContextValue {
value: string;
select: (value: string) => void;
baseId: string;
variant: Variant;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabs(part: string) {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error(`${part} must be used inside <Tabs>`);
return ctx;
}
const EASE = [0.23, 1, 0.32, 1] as const;
// The indicator glides between tabs. A tween with no bounce, so a scrolling
// tab list never overshoots and flashes a scrollbar.
const INDICATOR_TRANSITION = { duration: 0.2, ease: EASE } as const;
const LIST_CLASS: Record<Variant, string> = {
pill: "inline-flex items-center gap-1 rounded-full bg-muted p-1 shadow-[0_0_0_1px_var(--border)]",
segment: "inline-flex items-center gap-0.5 rounded-lg bg-muted p-0.5 shadow-[0_0_0_1px_var(--border)]",
underline: "flex items-center gap-5 border-b border-border",
};
const TRIGGER_CLASS: Record<Variant, string> = {
pill: "min-h-9 rounded-full px-3.5",
segment: "min-h-8 rounded-md px-3",
underline: "-mb-px min-h-11 px-0.5",
};
const INDICATOR_CLASS: Record<Variant, string> = {
pill: "absolute inset-0 rounded-full bg-background shadow-[0_1px_2px_rgb(0_0_0/0.12)]",
segment: "absolute inset-0 rounded-md bg-background shadow-[0_1px_2px_rgb(0_0_0/0.12)]",
underline: "absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-foreground",
};
export interface TabsProps {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Visual style. Default "pill". */
variant?: Variant;
className?: string;
children: ReactNode;
}
export function Tabs({
value,
defaultValue,
onValueChange,
variant = "pill",
className,
children,
}: TabsProps) {
const [inner, setInner] = useState(defaultValue ?? "");
const baseId = useId();
const reduce = useReducedMotion();
const current = value ?? inner;
const select = useCallback(
(next: string) => {
if (value === undefined) setInner(next);
onValueChange?.(next);
},
[value, onValueChange],
);
const ctx = useMemo(
() => ({ value: current, select, baseId, variant }),
[current, select, baseId, variant],
);
return (
<TabsContext.Provider value={ctx}>
<MotionConfig transition={reduce ? { duration: 0 } : INDICATOR_TRANSITION}>
{/* layoutRoot keeps the indicator measuring inside this component, so a
fixed or scrolled parent never turns scroll offset into movement. */}
<motion.div layoutRoot className={className}>
{children}
</motion.div>
</MotionConfig>
</TabsContext.Provider>
);
}
const NAV_KEYS = new Set(["ArrowRight", "ArrowLeft", "Home", "End"]);
export function TabsList({ className, children }: { className?: string; children: ReactNode }) {
const { variant } = useTabs("TabsList");
// Arrow keys, Home, and End move between tabs and select them.
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (!NAV_KEYS.has(event.key)) return;
const tabs = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]:not(:disabled)'),
);
const index = tabs.indexOf(document.activeElement as HTMLButtonElement);
if (index < 0) return;
event.preventDefault();
const next =
event.key === "Home"
? 0
: event.key === "End"
? tabs.length - 1
: (index + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
tabs[next].focus();
tabs[next].click();
};
return (
<div role="tablist" onKeyDown={onKeyDown} className={cn(LIST_CLASS[variant], className)}>
{children}
</div>
);
}
export interface TabsTriggerProps {
value: string;
className?: string;
indicatorClassName?: string;
children: ReactNode;
}
export function TabsTrigger({ value, className, indicatorClassName, children }: TabsTriggerProps) {
const ctx = useTabs("TabsTrigger");
const active = ctx.value === value;
return (
<button
type="button"
role="tab"
id={`${ctx.baseId}-tab-${value}`}
aria-selected={active}
aria-controls={`${ctx.baseId}-panel-${value}`}
tabIndex={active || !ctx.value ? 0 : -1}
onClick={() => ctx.select(value)}
className={cn(
"relative inline-flex touch-manipulation items-center justify-center whitespace-nowrap text-sm font-medium outline-none transition-colors duration-150",
"focus-visible:ring-2 focus-visible:ring-foreground/40",
TRIGGER_CLASS[ctx.variant],
active ? "text-foreground" : "text-muted-foreground hover:text-foreground",
className,
)}
>
{active ? (
<motion.span
layoutId={`${ctx.baseId}-indicator`}
aria-hidden="true"
className={cn(INDICATOR_CLASS[ctx.variant], indicatorClassName)}
/>
) : null}
<span className="relative">{children}</span>
</button>
);
}
export interface TabsContentProps {
value: string;
className?: string;
children: ReactNode;
}
export function TabsContent({ value, className, children }: TabsContentProps) {
const ctx = useTabs("TabsContent");
const reduce = useReducedMotion();
const active = ctx.value === value;
// Inactive panels stay in the DOM, hidden, so their content is still in the
// server HTML. The active panel fades in.
return (
<motion.div
role="tabpanel"
id={`${ctx.baseId}-panel-${value}`}
aria-labelledby={`${ctx.baseId}-tab-${value}`}
hidden={!active}
initial={false}
animate={{ opacity: active ? 1 : 0 }}
transition={{ duration: reduce ? 0 : 0.15, ease: EASE }}
className={cn("mt-4", className)}
>
{children}
</motion.div>
);
}
Installation
$ bunx --bun shadcn add @easeui/tabs
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx motion tailwind-merge - 3
Add the source files
components/motion/tabs.tsx "use client"; // easeui.dev/components/motion/tabs import { MotionConfig, motion, useReducedMotion } from "motion/react"; import { createContext, type KeyboardEvent, type ReactNode, useCallback, useContext, useId, useMemo, useState, } from "react"; import { cn } from "@/lib/utils"; type Variant = "pill" | "segment" | "underline"; interface TabsContextValue { value: string; select: (value: string) => void; baseId: string; variant: Variant; } const TabsContext = createContext<TabsContextValue | null>(null); function useTabs(part: string) { const ctx = useContext(TabsContext); if (!ctx) throw new Error(`${part} must be used inside <Tabs>`); return ctx; } const EASE = [0.23, 1, 0.32, 1] as const; // The indicator glides between tabs. A tween with no bounce, so a scrolling // tab list never overshoots and flashes a scrollbar. const INDICATOR_TRANSITION = { duration: 0.2, ease: EASE } as const; const LIST_CLASS: Record<Variant, string> = { pill: "inline-flex items-center gap-1 rounded-full bg-muted p-1 shadow-[0_0_0_1px_var(--border)]", segment: "inline-flex items-center gap-0.5 rounded-lg bg-muted p-0.5 shadow-[0_0_0_1px_var(--border)]", underline: "flex items-center gap-5 border-b border-border", }; const TRIGGER_CLASS: Record<Variant, string> = { pill: "min-h-9 rounded-full px-3.5", segment: "min-h-8 rounded-md px-3", underline: "-mb-px min-h-11 px-0.5", }; const INDICATOR_CLASS: Record<Variant, string> = { pill: "absolute inset-0 rounded-full bg-background shadow-[0_1px_2px_rgb(0_0_0/0.12)]", segment: "absolute inset-0 rounded-md bg-background shadow-[0_1px_2px_rgb(0_0_0/0.12)]", underline: "absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-foreground", }; export interface TabsProps { value?: string; defaultValue?: string; onValueChange?: (value: string) => void; /** Visual style. Default "pill". */ variant?: Variant; className?: string; children: ReactNode; } export function Tabs({ value, defaultValue, onValueChange, variant = "pill", className, children, }: TabsProps) { const [inner, setInner] = useState(defaultValue ?? ""); const baseId = useId(); const reduce = useReducedMotion(); const current = value ?? inner; const select = useCallback( (next: string) => { if (value === undefined) setInner(next); onValueChange?.(next); }, [value, onValueChange], ); const ctx = useMemo( () => ({ value: current, select, baseId, variant }), [current, select, baseId, variant], ); return ( <TabsContext.Provider value={ctx}> <MotionConfig transition={reduce ? { duration: 0 } : INDICATOR_TRANSITION}> {/* layoutRoot keeps the indicator measuring inside this component, so a fixed or scrolled parent never turns scroll offset into movement. */} <motion.div layoutRoot className={className}> {children} </motion.div> </MotionConfig> </TabsContext.Provider> ); } const NAV_KEYS = new Set(["ArrowRight", "ArrowLeft", "Home", "End"]); export function TabsList({ className, children }: { className?: string; children: ReactNode }) { const { variant } = useTabs("TabsList"); // Arrow keys, Home, and End move between tabs and select them. const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { if (!NAV_KEYS.has(event.key)) return; const tabs = Array.from( event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]:not(:disabled)'), ); const index = tabs.indexOf(document.activeElement as HTMLButtonElement); if (index < 0) return; event.preventDefault(); const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : (index + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length; tabs[next].focus(); tabs[next].click(); }; return ( <div role="tablist" onKeyDown={onKeyDown} className={cn(LIST_CLASS[variant], className)}> {children} </div> ); } export interface TabsTriggerProps { value: string; className?: string; indicatorClassName?: string; children: ReactNode; } export function TabsTrigger({ value, className, indicatorClassName, children }: TabsTriggerProps) { const ctx = useTabs("TabsTrigger"); const active = ctx.value === value; return ( <button type="button" role="tab" id={`${ctx.baseId}-tab-${value}`} aria-selected={active} aria-controls={`${ctx.baseId}-panel-${value}`} tabIndex={active || !ctx.value ? 0 : -1} onClick={() => ctx.select(value)} className={cn( "relative inline-flex touch-manipulation items-center justify-center whitespace-nowrap text-sm font-medium outline-none transition-colors duration-150", "focus-visible:ring-2 focus-visible:ring-foreground/40", TRIGGER_CLASS[ctx.variant], active ? "text-foreground" : "text-muted-foreground hover:text-foreground", className, )} > {active ? ( <motion.span layoutId={`${ctx.baseId}-indicator`} aria-hidden="true" className={cn(INDICATOR_CLASS[ctx.variant], indicatorClassName)} /> ) : null} <span className="relative">{children}</span> </button> ); } export interface TabsContentProps { value: string; className?: string; children: ReactNode; } export function TabsContent({ value, className, children }: TabsContentProps) { const ctx = useTabs("TabsContent"); const reduce = useReducedMotion(); const active = ctx.value === value; // Inactive panels stay in the DOM, hidden, so their content is still in the // server HTML. The active panel fades in. return ( <motion.div role="tabpanel" id={`${ctx.baseId}-panel-${value}`} aria-labelledby={`${ctx.baseId}-tab-${value}`} hidden={!active} initial={false} animate={{ opacity: active ? 1 : 0 }} transition={{ duration: reduce ? 0 : 0.15, ease: EASE }} className={cn("mt-4", className)} > {children} </motion.div> ); }lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
API reference
| Component | Prop | Type | Default | Description |
|---|---|---|---|---|
| Tabs | value? | string | - | - |
| Tabs | defaultValue? | string | - | - |
| Tabs | onValueChange? | ((value: string) => void) | - | - |
| Tabs | variant? | "pill" | "segment" | "underline" | pill | Visual style. Default "pill". |
| Tabs | className? | string | - | - |
| TabsList | className? | string | - | - |
| TabsTrigger | value | string | - | - |
| TabsTrigger | className? | string | - | - |
| TabsTrigger | indicatorClassName? | string | - | - |
| TabsContent | value | string | - | - |
| TabsContent | className? | string | - | - |