组件
标签栏 Tabs
用于在同一界面区域切换展示同层级内容分区的导航组件,内置共享布局弹性滑块动效与完整的键盘无障碍能力。
基础用法
点击不同的标签项即可切换内容面板:活动指示器以弹簧动效滑向目标标签,内容面板会从切换方向的一侧淡入:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/tabs安装基础依赖与动效库
pnpm add radix-ui motion class-variance-authority clsx tailwind-merge复制组件源码到
components/ui/tabs.tsx"use client"
import * as React from "react"
import { Tabs as TabsPrimitive } from "radix-ui"
import { motion, useReducedMotion } from "motion/react"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
type TabsStyle = "pill" | "underline"
type TabsOrientation = "horizontal" | "vertical"
const INDICATOR_SPRING = {
type: "spring",
stiffness: 520,
damping: 38,
mass: 0.7,
} as const
const TabsContext = React.createContext<{
value?: string
layoutId: string
/** -1 when the newly selected tab sits before the previous one, 1 after, 0 unknown. */
direction: number
orientation: TabsOrientation
}>({ layoutId: "wui-tabs", direction: 0, orientation: "horizontal" })
const TabsListContext = React.createContext<TabsStyle>("pill")
function getTriggerIndex(root: HTMLElement | null, value: string | undefined) {
if (!root || value === undefined) return -1
const triggers = Array.from(
root.querySelectorAll<HTMLElement>("[data-slot=tabs-trigger]")
).filter((trigger) => trigger.closest("[data-slot=tabs]") === root)
return triggers.findIndex((trigger) => trigger.dataset.value === value)
}
export interface TabsProps extends React.ComponentProps<typeof TabsPrimitive.Root> {}
function Tabs({
value,
defaultValue,
onValueChange,
orientation = "horizontal",
className,
ref,
children,
...props
}: TabsProps) {
const [internalValue, setInternalValue] = React.useState(defaultValue)
const selectedValue = value ?? internalValue
const layoutId = React.useId()
const rootRef = React.useRef<HTMLDivElement | null>(null)
const [previousValue, setPreviousValue] = React.useState(selectedValue)
const [direction, setDirection] = React.useState(0)
// Derive the travel direction while rendering (the DOM still holds the
// previous commit), so content can slide in from the side of the tab the
// user moved towards — including controlled changes made from outside.
if (previousValue !== selectedValue) {
const from = getTriggerIndex(rootRef.current, previousValue)
const to = getTriggerIndex(rootRef.current, selectedValue)
setPreviousValue(selectedValue)
setDirection(from === -1 || to === -1 ? 0 : Math.sign(to - from))
}
function handleValueChange(next: string) {
if (value === undefined) setInternalValue(next)
onValueChange?.(next)
}
const setRefs = React.useCallback(
(node: HTMLDivElement | null) => {
rootRef.current = node
if (typeof ref === "function") ref(node)
else if (ref) ref.current = node
},
[ref]
)
return (
<TabsContext.Provider
value={{ value: selectedValue, layoutId, direction, orientation }}
>
<TabsPrimitive.Root
ref={setRefs}
data-slot="tabs"
value={value}
defaultValue={defaultValue}
onValueChange={handleValueChange}
orientation={orientation}
className={cn(
"data-[orientation=vertical]:flex data-[orientation=vertical]:gap-6",
className
)}
{...props}
>
{children}
</TabsPrimitive.Root>
</TabsContext.Provider>
)
}
const tabsListVariants = cva(
"inline-flex w-fit items-center text-muted-foreground data-[orientation=vertical]:h-fit data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch",
{
variants: {
variant: {
pill: "rounded-lg bg-muted p-1",
underline:
"gap-1 border-b data-[orientation=vertical]:gap-0.5 data-[orientation=vertical]:border-b-0 data-[orientation=vertical]:border-l",
},
},
defaultVariants: { variant: "pill" },
}
)
export interface TabsListProps extends React.ComponentProps<typeof TabsPrimitive.List> {
/** Indicator treatment used by every trigger inside the list. @default "pill" */
variant?: TabsStyle
}
function TabsList({ className, variant = "pill", ...props }: TabsListProps) {
return (
<TabsListContext.Provider value={variant}>
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
</TabsListContext.Provider>
)
}
function TabsTrigger({
className,
children,
value,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
const tabs = React.useContext(TabsContext)
const variant = React.useContext(TabsListContext)
const reduceMotion = useReducedMotion()
const active = tabs.value === value
const vertical = tabs.orientation === "vertical"
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
data-value={value}
className={cn(
"relative isolate inline-flex min-w-20 items-center justify-center gap-2 whitespace-nowrap px-4 py-2 text-sm font-medium outline-none transition-colors duration-200 hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground data-[orientation=vertical]:justify-start [&_svg]:size-4 [&_svg]:shrink-0",
variant === "pill" && "rounded-md",
variant === "underline" &&
(vertical ? "rounded-r-md px-3 py-2" : "rounded-t-md px-3 py-2.5"),
className
)}
value={value}
{...props}
>
{active ? (
<motion.span
aria-hidden
data-slot="tabs-indicator"
layoutId={`${tabs.layoutId}-${variant}`}
className={cn(
"absolute -z-10",
variant === "pill" &&
"inset-0 rounded-md bg-background shadow-xs dark:bg-input/40",
variant === "underline" &&
(vertical
? "inset-y-1.5 -left-px w-0.5 rounded-full bg-primary"
: "inset-x-2 -bottom-px h-0.5 rounded-full bg-primary")
)}
transition={reduceMotion ? { duration: 0 } : INDICATOR_SPRING}
/>
) : null}
{children}
</TabsPrimitive.Trigger>
)
}
function TabsContent({
className,
children,
value,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
const tabs = React.useContext(TabsContext)
const reduceMotion = useReducedMotion()
const active = tabs.value === value
const distance = reduceMotion ? 0 : tabs.direction * 16
const offset =
tabs.orientation === "vertical"
? { x: 0, y: distance || (reduceMotion ? 0 : 6) }
: { x: distance, y: distance ? 0 : reduceMotion ? 0 : 6 }
return (
<TabsPrimitive.Content
data-slot="tabs-content"
value={value}
className={cn(
"mt-4 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/30 data-[orientation=vertical]:mt-0 data-[orientation=vertical]:min-w-0 data-[orientation=vertical]:flex-1",
className
)}
{...props}
>
<motion.div
initial={{ opacity: 0, ...offset }}
animate={active ? { opacity: 1, x: 0, y: 0 } : { opacity: 0, ...offset }}
transition={
reduceMotion ? { duration: 0 } : { duration: 0.26, ease: [0.22, 1, 0.36, 1] }
}
>
{children}
</motion.div>
</TabsPrimitive.Content>
)
}
export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants }
属性 Props
Tabs (根组件)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| value | string | — | 受控模式下当前激活标签项的唯一标识值。 |
| defaultValue | string | — | 非受控模式下初始激活的标签项标识值。 |
| onValueChange | (value: string) => void | — | 激活的标签项发生改变时的回调函数。 |
| orientation | "horizontal" | "vertical" | "horizontal" | 标签栏的排布方向。设为 "vertical" 时标签列表纵向排列在内容左侧,下划线指示器改为左侧竖线,内容面板改为纵向滑入。 |
| activationMode | "automatic" | "manual" | "automatic" | 键盘导航时的激活模式。"automatic" 表示键盘聚焦即激活;"manual" 表示需要按下 Enter 或 Space 确认激活。 |
| dir | "ltr" | "rtl" | — | 文字排版与键盘左右箭头的方向流向。 |
TabsList
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| variant | "pill" | "underline" | "pill" | 指示器视觉样式变体。"pill" 为胶囊滑块背景风格;"underline" 为底部彩色线条下划线风格。 |
| className | string | — | 应用于标签栏外层容器的额外 CSS 类名。 |
| loop | boolean | true | 使用键盘方向键导航到达边界时是否循环回到第一个/最后一个标签。 |
TabsTrigger
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| value | string | — | 当前标签触发器的唯一标识值,需与对应 TabsContent 的 value 一致。 |
| disabled | boolean | false | 是否禁用该标签项(禁用后不可点击且跳过键盘焦点)。 |
| className | string | — | 应用于单个标签按钮的自定义类名。 |
TabsContent
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| value | string | — | 关联的内容面板唯一标识值,与选中的 TabsTrigger value 匹配时才会渲染呈现。 |
| forceMount | boolean | false | 是否在未激活时强制保留在 DOM 树中(常用于保留表单草稿状态或 SEO 抓取)。 |
| className | string | — | 应用于内容面板容器的额外类名。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onValueChange | (value: string) => void | — | 当用户点击、触摸或通过键盘改变当前激活的标签页时触发,参数为最新激活的 value 字符串。 |
使用场景与设计规范
Tabs 用于在同一视觉空间内对信息进行结构化分类,降低单屏认知负荷:
- 组件选型对比:
- Tabs vs 路由页面导航:若各个分类属于独立的功能模块或具备不同的 URL 路径,推荐采用顶栏导航或侧边栏菜单;若属于同一实体对象的不同维度属性(如“概览 / 审计日志 / 权限设置”),采用
Tabs更高效。 - Tabs vs 手风琴 Accordion:
Tabs每次仅展示一个互斥分类;Accordion适合纵向排布并支持同时展开多个折叠项(如 FAQ、长文档目录)。
- Tabs vs 路由页面导航:若各个分类属于独立的功能模块或具备不同的 URL 路径,推荐采用顶栏导航或侧边栏菜单;若属于同一实体对象的不同维度属性(如“概览 / 审计日志 / 权限设置”),采用
- 样式选择指南(Pill vs Underline):
- Pill(胶囊式):视觉重量较轻,带有浅灰色外框与背景高亮块,适合局部工具栏、图表维度切换或卡片内部的次级筛选。
- Underline(下划线式):具有清晰的底部分界线,排版更具呼吸感,适合页面级设置、主体详情页面的顶部分区。
- 设计规范与避坑原则:
- 标签数量适中:建议保持在 2 ~ 6 个之间。标签过多易造成移动端折行或视觉混乱。
- 文案简短对称:标签文案应保持词性与字数相近(如“基本信息”、“成员管理”、“安全策略”),避免单个标签过长。
- 勿用于强步骤流程:若内容存在严格的前后依赖(第一步 -> 第二步 -> 第三步),请改用
Steps步骤条,不要使用允许自由跳转的Tabs。
场景示例
胶囊式标签与微动效
使用默认的 pill 样式,搭配图标与数据指标卡片,体验平滑的物理弹簧滑块过渡动效:
Loading…
下划线样式与设置页
设置 variant="underline" 构建企业级后台中常用的主设置分区页:
Loading…
受控模式与动态徽标
通过 value / onValueChange 受控,结合 Badge 展示各分类未读数;「全部已读」等外部操作会实时更新标签上的计数:
Loading…
纵向设置导航
设置 orientation="vertical" 构建左侧导航、右侧内容的设置页。指示器沿竖线滑动,内容面板按切换方向上下滑入:
Loading…
综合业务表单选项卡
在每个 TabsContent 中组合 Card、Input 与操作按钮,组织结构清晰的账户与安全配置中心:
Loading…
无障碍与交互 Accessibility
- WAI-ARIA Tabs 规范:
TabsList自动挂载role="tablist"与aria-orientation。TabsTrigger自动挂载role="tab"、aria-selected以及aria-controls(指向关联的 content 面板 ID)。TabsContent自动挂载role="tabpanel"与aria-labelledby。
- 键盘导航支持:
- → / ←:在水平标签项之间顺次切换焦点并激活。
- ↓ / ↑:在垂直标签项之间切换焦点。
- Home:快速跳转并激活第一个标签。
- End:快速跳转并激活最后一个标签。
- Tab:将焦点从当前激活的 Tab 触发器直接移入下方的内容面板。
- 动效减弱(Reduced Motion):
- 自动适配用户的系统无障碍设置。当开启“减弱动态效果”时,指示器与内容面板将关闭弹簧插值与方向位移动画,立即呈现切换结果。