wui
组件

标签栏 Tabs

用于在同一界面区域切换展示同层级内容分区的导航组件,内置共享布局弹性滑块动效与完整的键盘无障碍能力。

第三方依赖 · radix-ui第三方依赖 · class-variance-authority第三方依赖 · motion

基础用法

点击不同的标签项即可切换内容面板:活动指示器以弹簧动效滑向目标标签,内容面板会从切换方向的一侧淡入:

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
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 (根组件)

属性类型默认值说明
valuestring—受控模式下当前激活标签项的唯一标识值。
defaultValuestring—非受控模式下初始激活的标签项标识值。
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" 为底部彩色线条下划线风格。
classNamestring—应用于标签栏外层容器的额外 CSS 类名。
loopbooleantrue使用键盘方向键导航到达边界时是否循环回到第一个/最后一个标签。

TabsTrigger

属性类型默认值说明
valuestring—当前标签触发器的唯一标识值,需与对应 TabsContent 的 value 一致。
disabledbooleanfalse是否禁用该标签项(禁用后不可点击且跳过键盘焦点)。
classNamestring—应用于单个标签按钮的自定义类名。

TabsContent

属性类型默认值说明
valuestring—关联的内容面板唯一标识值,与选中的 TabsTrigger value 匹配时才会渲染呈现。
forceMountbooleanfalse是否在未激活时强制保留在 DOM 树中(常用于保留表单草稿状态或 SEO 抓取)。
classNamestring—应用于内容面板容器的额外类名。

事件 Events

属性类型默认值说明
onValueChange(value: string) => void—当用户点击、触摸或通过键盘改变当前激活的标签页时触发,参数为最新激活的 value 字符串。

使用场景与设计规范

Tabs 用于在同一视觉空间内对信息进行结构化分类,降低单屏认知负荷:

  • 组件选型对比:
    • Tabs vs 路由页面导航:若各个分类属于独立的功能模块或具备不同的 URL 路径,推荐采用顶栏导航或侧边栏菜单;若属于同一实体对象的不同维度属性(如“概览 / 审计日志 / 权限设置”),采用 Tabs 更高效。
    • Tabs vs 手风琴 Accordion:Tabs 每次仅展示一个互斥分类;Accordion 适合纵向排布并支持同时展开多个折叠项(如 FAQ、长文档目录)。
  • 样式选择指南(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):
    • 自动适配用户的系统无障碍设置。当开启“减弱动态效果”时,指示器与内容面板将关闭弹簧插值与方向位移动画,立即呈现切换结果。