Tabs
Pill, segment or underline tabs with a spring layoutId indicator.
Preview
Pill
High-level summary.
Recent events.
Preferences.
Segment
Underline
TSXcomponents/previews/motion/tabs.preview.tsx
"use client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/motion/tabs";
export function TabsPreview() {
return (
<div className="flex w-full max-w-md flex-col gap-8">
<Section title="Pill">
<Tabs defaultValue="overview" variant="pill">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="text-sm text-muted-foreground">High-level summary.</TabsContent>
<TabsContent value="activity" className="text-sm text-muted-foreground">Recent events.</TabsContent>
<TabsContent value="settings" className="text-sm text-muted-foreground">Preferences.</TabsContent>
</Tabs>
</Section>
<Section title="Segment">
<Tabs defaultValue="day" variant="segment">
<TabsList>
<TabsTrigger value="day">Day</TabsTrigger>
<TabsTrigger value="week">Week</TabsTrigger>
<TabsTrigger value="month">Month</TabsTrigger>
</TabsList>
</Tabs>
</Section>
<Section title="Underline">
<Tabs defaultValue="all" variant="underline">
<TabsList>
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="open">Open</TabsTrigger>
<TabsTrigger value="closed">Closed</TabsTrigger>
</TabsList>
</Tabs>
</Section>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</span>
{children}
</div>
);
}
TSXcomponents/motion/tabs.tsx
"use client";
// beui.dev/components/motion/tabs
import { motion, MotionConfig, useReducedMotion, type Transition } from "motion/react";
import {
createContext,
useCallback,
useContext,
useId,
useMemo,
useState,
type ReactNode,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Variant = "pill" | "underline" | "segment";
type Ctx = {
value: string;
setValue: (v: string) => void;
layoutId: string;
variant: Variant;
};
const TabsCtx = createContext<Ctx | null>(null);
function useTabs() {
const ctx = useContext(TabsCtx);
if (!ctx) throw new Error("Tabs.* must be used inside <Tabs>");
return ctx;
}
// Weighty spring for the active-tab indicator: a touch of overshoot so it
// settles with life instead of snapping.
const transition: Transition = {
type: "spring",
stiffness: 170,
damping: 24,
mass: 1.2,
};
export function Tabs({
defaultValue,
value,
onValueChange,
variant = "pill",
children,
className,
}: {
defaultValue?: string;
value?: string;
onValueChange?: (v: string) => void;
variant?: Variant;
children: ReactNode;
className?: string;
}) {
const [internal, setInternal] = useState(defaultValue ?? "");
const layoutId = useId();
const reduce = useReducedMotion();
const controlled = value !== undefined;
const current = controlled ? value : internal;
const setValue = useCallback(
(v: string) => {
if (!controlled) setInternal(v);
onValueChange?.(v);
},
[controlled, onValueChange],
);
const contextValue = useMemo(
() => ({ value: current, setValue, layoutId, variant }),
[current, layoutId, setValue, variant],
);
return (
<MotionConfig transition={reduce ? { duration: 0 } : transition}>
<TabsCtx.Provider value={contextValue}>
{/* layoutRoot: the indicator's layoutId measures in page coordinates, so
inside fixed/scrolled containers it would replay scroll offsets as
movement. The pill only ever travels within the list, so scoping
projection to the Tabs wrapper is always correct. */}
<motion.div layoutRoot className={className}>
{children}
</motion.div>
</TabsCtx.Provider>
</MotionConfig>
);
}
const listClasses: Record<Variant, string> = {
pill: "inline-flex items-center gap-1 rounded-full bg-card p-1",
underline: "inline-flex items-center gap-1 border-b border-border",
segment: "inline-flex items-center gap-0 rounded-lg bg-card p-0.5",
};
export function TabsList({ children, className }: { children: ReactNode; className?: string }) {
const { variant } = useTabs();
return (
<div role="tablist" className={cn(listClasses[variant], className)}>
{children}
</div>
);
}
export function TabsTrigger({
value,
children,
className,
indicatorClassName,
}: {
value: string;
children: ReactNode;
className?: string;
indicatorClassName?: string;
}) {
const { value: current, setValue, layoutId, variant } = useTabs();
const active = current === value;
if (variant === "underline") {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={() => setValue(value)}
className={cn(
"relative isolate px-3 pb-2.5 pt-1 -mb-px text-sm font-medium transition-colors min-h-[44px] inline-flex items-center",
active ? "text-foreground" : "text-muted-foreground hover:text-foreground",
className,
)}
>
{children}
{active ? (
<motion.span
layoutId={layoutId}
className={cn(
"absolute -bottom-px left-0 right-0 h-px bg-primary",
indicatorClassName,
)}
/>
) : null}
</button>
);
}
const radius = variant === "pill" ? "rounded-full" : "rounded-md";
return (
<div className="relative">
{active ? (
<motion.span
layoutId={layoutId}
style={{ borderRadius: variant === "pill" ? 9999 : 8 }}
className={cn(
"absolute inset-0 bg-primary",
radius,
indicatorClassName,
)}
/>
) : null}
<button
type="button"
role="tab"
aria-selected={active}
onClick={() => setValue(value)}
className={cn(
"relative z-10 inline-flex items-center justify-center whitespace-nowrap bg-transparent px-3.5 py-1.5 text-sm font-medium outline-none",
"transition-colors",
active
? "text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
radius,
className,
)}
>
{children}
</button>
</div>
);
}
export function TabsContent({ value, children, className }: { value: string; children: ReactNode; className?: string }) {
const { value: current } = useTabs();
const reduce = useReducedMotion();
const active = current === value;
// Inactive panels stay mounted but hidden, so their content (e.g. source
// code) is present in the server-rendered HTML for crawlers and assistive
// tech, instead of being dropped from the DOM.
if (!active) {
return (
<div hidden className={className}>
{children}
</div>
);
}
return (
<motion.div
key={value}
initial={{ opacity: 0, y: reduce ? 0 : 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18, ease: EASE_OUT }}
className={cn("mt-4", className)}
>
{children}
</motion.div>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/tabs
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/tabs.tsx
"use client";
// beui.dev/components/motion/tabs
import { motion, MotionConfig, useReducedMotion, type Transition } from "motion/react";
import {
createContext,
useCallback,
useContext,
useId,
useMemo,
useState,
type ReactNode,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Variant = "pill" | "underline" | "segment";
type Ctx = {
value: string;
setValue: (v: string) => void;
layoutId: string;
variant: Variant;
};
const TabsCtx = createContext<Ctx | null>(null);
function useTabs() {
const ctx = useContext(TabsCtx);
if (!ctx) throw new Error("Tabs.* must be used inside <Tabs>");
return ctx;
}
// Weighty spring for the active-tab indicator: a touch of overshoot so it
// settles with life instead of snapping.
const transition: Transition = {
type: "spring",
stiffness: 170,
damping: 24,
mass: 1.2,
};
export function Tabs({
defaultValue,
value,
onValueChange,
variant = "pill",
children,
className,
}: {
defaultValue?: string;
value?: string;
onValueChange?: (v: string) => void;
variant?: Variant;
children: ReactNode;
className?: string;
}) {
const [internal, setInternal] = useState(defaultValue ?? "");
const layoutId = useId();
const reduce = useReducedMotion();
const controlled = value !== undefined;
const current = controlled ? value : internal;
const setValue = useCallback(
(v: string) => {
if (!controlled) setInternal(v);
onValueChange?.(v);
},
[controlled, onValueChange],
);
const contextValue = useMemo(
() => ({ value: current, setValue, layoutId, variant }),
[current, layoutId, setValue, variant],
);
return (
<MotionConfig transition={reduce ? { duration: 0 } : transition}>
<TabsCtx.Provider value={contextValue}>
{/* layoutRoot: the indicator's layoutId measures in page coordinates, so
inside fixed/scrolled containers it would replay scroll offsets as
movement. The pill only ever travels within the list, so scoping
projection to the Tabs wrapper is always correct. */}
<motion.div layoutRoot className={className}>
{children}
</motion.div>
</TabsCtx.Provider>
</MotionConfig>
);
}
const listClasses: Record<Variant, string> = {
pill: "inline-flex items-center gap-1 rounded-full bg-card p-1",
underline: "inline-flex items-center gap-1 border-b border-border",
segment: "inline-flex items-center gap-0 rounded-lg bg-card p-0.5",
};
export function TabsList({ children, className }: { children: ReactNode; className?: string }) {
const { variant } = useTabs();
return (
<div role="tablist" className={cn(listClasses[variant], className)}>
{children}
</div>
);
}
export function TabsTrigger({
value,
children,
className,
indicatorClassName,
}: {
value: string;
children: ReactNode;
className?: string;
indicatorClassName?: string;
}) {
const { value: current, setValue, layoutId, variant } = useTabs();
const active = current === value;
if (variant === "underline") {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={() => setValue(value)}
className={cn(
"relative isolate px-3 pb-2.5 pt-1 -mb-px text-sm font-medium transition-colors min-h-[44px] inline-flex items-center",
active ? "text-foreground" : "text-muted-foreground hover:text-foreground",
className,
)}
>
{children}
{active ? (
<motion.span
layoutId={layoutId}
className={cn(
"absolute -bottom-px left-0 right-0 h-px bg-primary",
indicatorClassName,
)}
/>
) : null}
</button>
);
}
const radius = variant === "pill" ? "rounded-full" : "rounded-md";
return (
<div className="relative">
{active ? (
<motion.span
layoutId={layoutId}
style={{ borderRadius: variant === "pill" ? 9999 : 8 }}
className={cn(
"absolute inset-0 bg-primary",
radius,
indicatorClassName,
)}
/>
) : null}
<button
type="button"
role="tab"
aria-selected={active}
onClick={() => setValue(value)}
className={cn(
"relative z-10 inline-flex items-center justify-center whitespace-nowrap bg-transparent px-3.5 py-1.5 text-sm font-medium outline-none",
"transition-colors",
active
? "text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
radius,
className,
)}
>
{children}
</button>
</div>
);
}
export function TabsContent({ value, children, className }: { value: string; children: ReactNode; className?: string }) {
const { value: current } = useTabs();
const reduce = useReducedMotion();
const active = current === value;
// Inactive panels stay mounted but hidden, so their content (e.g. source
// code) is present in the server-rendered HTML for crawlers and assistive
// tech, instead of being dropped from the DOM.
if (!active) {
return (
<div hidden className={className}>
{children}
</div>
);
}
return (
<motion.div
key={value}
initial={{ opacity: 0, y: reduce ? 0 : 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18, ease: EASE_OUT }}
className={cn("mt-4", className)}
>
{children}
</motion.div>
);
}
API Reference
Tabs
defaultValue?string—value?string—onValueChange?((v: string) => void)—variant?"pill" | "underline" | "segment"pillclassName?string—TabsList
className?string—TabsTrigger
valuestring—className?string—indicatorClassName?string—TabsContent
valuestring—className?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated