Tabs
Underline or pill tabs with an indicator that glides between them.
$
pnpm dlx shadcn@latest add https://lodeui.com/r/tabs.jsonKey numbers for this project at a glance.
components/examples/tabs-demo.tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { Tabs } from "@/components/ui/tabs";
const items = [
{ value: "overview", label: "Overview" },
{ value: "activity", label: "Activity" },
{ value: "settings", label: "Settings" },
] as const;
const copy: Record<string, string> = {
overview: "Key numbers for this project at a glance.",
activity: "Everything that happened, newest first.",
settings: "Members, integrations and danger zone.",
};
export default function TabsDemo() {
const [tab, setTab] = React.useState<(typeof items)[number]["value"]>("overview");
return (
<div className="w-full max-w-md space-y-8">
<div>
<Tabs aria-label="Sections" items={items} value={tab} onValueChange={setTab} />
<AnimatePresence mode="wait">
<motion.p key={tab} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }} transition={{ duration: 0.15 }} className="pt-4 text-sm text-muted-foreground">
{copy[tab]}
</motion.p>
</AnimatePresence>
</div>
<Tabs aria-label="View" variant="pill" items={[{ value: "list", label: "List" }, { value: "board", label: "Board" }, { value: "calendar", label: "Calendar" }]} />
</div>
);
}When to use
- Switching between sections of the same page or panel.
When not to use
- Setting a value in a form: use a segmented control.
- Site navigation: use links.
Installation
$
pnpm dlx shadcn@latest add https://lodeui.com/r/tabs.json1Install the dependencies
npm install motion2Copy the source into your project
components/ui/tabs.tsx"use client"; import * as React from "react"; import { motion } from "motion/react"; import { cn } from "@/lib/utils"; export interface TabItem<T extends string> { value: T; label: React.ReactNode; disabled?: boolean; } export interface TabsProps<T extends string> { items: readonly TabItem<T>[]; value?: T; defaultValue?: T; onValueChange?: (value: T) => void; variant?: "underline" | "pill"; className?: string; "aria-label"?: string; } export function Tabs<T extends string>({ items, value, defaultValue, onValueChange, variant = "underline", className, ...aria }: TabsProps<T>) { const id = React.useId(); const [internal, setInternal] = React.useState<T>(defaultValue ?? items[0]?.value); const current = value ?? internal; const refs = React.useRef<(HTMLButtonElement | null)[]>([]); const select = (v: T) => { if (value === undefined) setInternal(v); onValueChange?.(v); }; const onKeyDown = (e: React.KeyboardEvent, i: number) => { const enabled = items.map((it, idx) => (it.disabled ? -1 : idx)).filter((x) => x >= 0); const pos = enabled.indexOf(i); let next: number | undefined; if (e.key === "ArrowRight") next = enabled[(pos + 1) % enabled.length]; if (e.key === "ArrowLeft") next = enabled[(pos - 1 + enabled.length) % enabled.length]; if (e.key === "Home") next = enabled[0]; if (e.key === "End") next = enabled[enabled.length - 1]; if (next === undefined) return; e.preventDefault(); select(items[next].value); refs.current[next]?.focus(); }; return ( <div role="tablist" aria-label={aria["aria-label"]} className={cn( "relative flex", variant === "underline" ? "gap-1 border-b border-border" : "gap-1 rounded-lg bg-muted p-1", className, )} > {items.map((item, i) => { const active = item.value === current; return ( <button key={item.value} ref={(el) => { refs.current[i] = el; }} role="tab" type="button" aria-selected={active} tabIndex={active ? 0 : -1} disabled={item.disabled} onClick={() => select(item.value)} onKeyDown={(e) => onKeyDown(e, i)} className={cn( "relative px-3 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40", variant === "underline" ? "h-10 rounded-t-md" : "h-8 rounded-md", active ? "text-foreground" : "text-muted-foreground hover:text-foreground", )} > {active && (variant === "underline" ? ( <motion.span layoutId={`${id}-tab`} className="absolute inset-x-1 -bottom-px h-0.5 rounded-full bg-foreground" transition={{ type: "spring", bounce: 0.15, duration: 0.4 }} /> ) : ( <motion.span layoutId={`${id}-tab`} className="absolute inset-0 rounded-md bg-background shadow-sm ring-1 ring-border" transition={{ type: "spring", bounce: 0.15, duration: 0.4 }} /> ))} <span className="relative">{item.label}</span> </button> ); })} </div> ); }
Usage
Import it from @/components/ui/tabs after installing.
example.tsx
import * as React from "react";
import { Tabs } from "@/components/ui/tabs";
export function ProjectTabs() {
const [tab, setTab] = React.useState("overview");
return (
<>
<Tabs aria-label="Project" value={tab} onValueChange={setTab} items={[{ value: "overview", label: "Overview" }, { value: "activity", label: "Activity" }]} />
{tab === "overview" ? <p>Overview</p> : <p>Activity</p>}
</>
);
}API reference
Tabs
A tablist with an animated indicator. You render the panel for the active value.
| Prop | Type | Default | Description |
|---|---|---|---|
| items | readonly { value: T; label: ReactNode; disabled?: boolean }[] | – | The tabs. |
| value / defaultValue / onValueChange | T | – | Controlled or uncontrolled. |
| variant | "underline" | "pill" | "underline" | Indicator style. |
Keyboard interactions
| Keys | Action |
|---|---|
| ←or→ | Move and select |
| HomeorEnd | First or last tab |
Accessibility
- role="tablist" with role="tab", aria-selected and roving tabindex.
- Disabled tabs are skipped.
Motion
- The indicator springs between tabs (bounce 0.15, 400ms).
Responsive behavior
- Wrap in overflow-x-auto for many tabs on phones.
Performance
- One animated element.
Notes for AI
- Give the panel role="tabpanel" if it's a distinct region.
This page as Markdown, for assistants and coding agents:
/components/tabs/markdownThe full library index for assistants is at /llms.txt.
Lode222 doc pagesllms.txt