wui
组件

表格 Table

保留原生语义与无障碍结构的响应式表格组件,支持紧凑密度、斑马纹、冻结表头与左右固定列。

第三方依赖 · class-variance-authority

基础用法

最基础的表格用法。使用语义化的 Table、TableHeader、TableBody、TableRow、TableHead 和 TableCell 组织结构化数据:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/table
安装基础依赖与工具函数
pnpm add class-variance-authority clsx tailwind-merge
复制组件源码到 components/ui/table.tsx
components/ui/table.tsx
"use client"

import * as React from "react"
import { cva } from "class-variance-authority"

import { cn } from "@/lib/utils"

const tableVariants = cva("w-full caption-bottom text-sm", {
  variants: {
    density: {
      default: "",
      compact:
        "[&_[data-slot=table-cell]]:px-3 [&_[data-slot=table-cell]]:py-2 [&_[data-slot=table-head]]:h-9 [&_[data-slot=table-head]]:px-3",
    },
    striped: {
      true: "[&_[data-slot=table-body]_[data-slot=table-row]:nth-child(even)]:bg-muted/35",
      false: "",
    },
  },
  defaultVariants: {
    density: "default",
    striped: false,
  },
})

export interface TableProps extends React.ComponentProps<"table"> {
  /** Extra classes applied to the horizontal overflow container. */
  containerClassName?: string
  /** Controls cell padding and row height. @default "default" */
  density?: "default" | "compact"
  /** Adds a subtle background to alternating body rows. @default false */
  striped?: boolean
  /** Keeps the header visible while the table container scrolls. @default false */
  stickyHeader?: boolean
}

/** A responsive native table with composable semantic sections. */
function Table({
  className,
  containerClassName,
  density = "default",
  striped = false,
  stickyHeader = false,
  ...props
}: TableProps) {
  return (
    <div
      data-slot="table-container"
      className={cn(
        "relative isolate w-full overflow-auto overscroll-x-contain",
        containerClassName
      )}
    >
      <table
        data-slot="table"
        data-density={density}
        className={cn(
          tableVariants({ density, striped }),
          stickyHeader &&
            "[&_[data-slot=table-header]]:bg-background [&_[data-slot=table-header]]:sticky [&_[data-slot=table-header]]:top-0 [&_[data-slot=table-header]]:z-20",
          className
        )}
        {...props}
      />
    </div>
  )
}

function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
  return (
    <thead
      data-slot="table-header"
      className={cn("[&_tr]:border-b", className)}
      {...props}
    />
  )
}

function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
  return (
    <tbody
      data-slot="table-body"
      className={cn("[&_tr:last-child]:border-0", className)}
      {...props}
    />
  )
}

function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
  return (
    <tfoot
      data-slot="table-footer"
      className={cn(
        "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
        className
      )}
      {...props}
    />
  )
}

function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
  return (
    <tr
      data-slot="table-row"
      className={cn(
        "group/row hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-[background-color,opacity] duration-200 ease-out data-[dragging=true]:opacity-45",
        className
      )}
      {...props}
    />
  )
}

export interface TableHeadProps extends React.ComponentProps<"th"> {
  /** Pins the column to an edge of the horizontal scroll container. */
  pinned?: "left" | "right"
  /** Distance from the pinned edge, useful when pinning multiple columns. @default 0 */
  pinOffset?: number | string
  /** Current column width in pixels when resizing is enabled. */
  resizeWidth?: number
  /** Called while the resize handle is dragged or adjusted with arrow keys. */
  onResize?: (width: number) => void
  /** Accessible name for the resize handle. */
  resizeLabel?: string
  /** Smallest allowed width in pixels. @default 80 */
  minResizeWidth?: number
  /** Largest allowed width in pixels. @default 800 */
  maxResizeWidth?: number
}

function TableHead({
  className,
  pinned,
  pinOffset = 0,
  resizeWidth,
  onResize,
  resizeLabel = "调整列宽",
  minResizeWidth = 80,
  maxResizeWidth = 800,
  style,
  children,
  ...props
}: TableHeadProps) {
  const drag = React.useRef<{ pointerId: number; x: number; width: number } | null>(null)
  const clampWidth = (width: number) =>
    Math.round(Math.min(maxResizeWidth, Math.max(minResizeWidth, width)))

  return (
    <th
      data-slot="table-head"
      data-pinned={pinned}
      className={cn(
        "text-muted-foreground h-10 whitespace-nowrap px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0",
        pinned &&
          "bg-background group-hover/row:bg-muted/50 group-data-[state=selected]/row:bg-muted z-30",
        pinned === "left" && "border-r",
        pinned === "right" && "border-l",
        onResize && "relative pr-5",
        className
      )}
      style={{
        ...style,
        ...(pinned ? { position: "sticky" } : {}),
        ...(pinned === "left"
          ? { left: pinOffset, insetInlineStart: pinOffset }
          : {}),
        ...(pinned === "right"
          ? { right: pinOffset, insetInlineEnd: pinOffset }
          : {}),
      }}
      {...props}
    >
      {children}
      {onResize && (
        <span
          role="separator"
          tabIndex={0}
          aria-orientation="vertical"
          aria-label={resizeLabel}
          aria-valuemin={minResizeWidth}
          aria-valuemax={maxResizeWidth}
          aria-valuenow={resizeWidth}
          className="group/resize absolute inset-y-0 right-0 z-10 flex w-3 cursor-col-resize touch-none items-center justify-center outline-none before:h-4 before:w-px before:bg-border hover:before:bg-primary focus-visible:before:bg-primary active:before:bg-primary"
          onClick={(event) => event.stopPropagation()}
          onPointerDown={(event) => {
            if (event.pointerType === "mouse" && event.button !== 0) return
            event.preventDefault()
            event.stopPropagation()
            drag.current = {
              pointerId: event.pointerId,
              x: event.clientX,
              width: resizeWidth ?? event.currentTarget.parentElement?.getBoundingClientRect().width ?? minResizeWidth,
            }
            event.currentTarget.setPointerCapture(event.pointerId)
          }}
          onPointerMove={(event) => {
            if (!drag.current || drag.current.pointerId !== event.pointerId) return
            onResize(clampWidth(drag.current.width + event.clientX - drag.current.x))
          }}
          onPointerUp={(event) => {
            if (drag.current?.pointerId !== event.pointerId) return
            drag.current = null
            event.currentTarget.releasePointerCapture(event.pointerId)
          }}
          onPointerCancel={() => { drag.current = null }}
          onLostPointerCapture={() => { drag.current = null }}
          onKeyDown={(event) => {
            if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return
            event.preventDefault()
            event.stopPropagation()
            const currentWidth = resizeWidth ?? event.currentTarget.parentElement?.getBoundingClientRect().width ?? minResizeWidth
            onResize(clampWidth(currentWidth + (event.key === "ArrowRight" ? 1 : -1) * (event.shiftKey ? 10 : 1)))
          }}
        />
      )}
    </th>
  )
}

export interface TableCellProps extends React.ComponentProps<"td"> {
  /** Pins the column to an edge of the horizontal scroll container. */
  pinned?: "left" | "right"
  /** Distance from the pinned edge, useful when pinning multiple columns. @default 0 */
  pinOffset?: number | string
}

function TableCell({
  className,
  pinned,
  pinOffset = 0,
  style,
  ...props
}: TableCellProps) {
  return (
    <td
      data-slot="table-cell"
      data-pinned={pinned}
      className={cn(
        "whitespace-nowrap p-4 align-middle [&:has([role=checkbox])]:pr-0",
        pinned &&
          "bg-background group-hover/row:bg-muted/50 group-data-[state=selected]/row:bg-muted z-10",
        pinned === "left" && "border-r",
        pinned === "right" && "border-l",
        className
      )}
      style={{
        ...style,
        ...(pinned ? { position: "sticky" } : {}),
        ...(pinned === "left"
          ? { left: pinOffset, insetInlineStart: pinOffset }
          : {}),
        ...(pinned === "right"
          ? { right: pinOffset, insetInlineEnd: pinOffset }
          : {}),
      }}
      {...props}
    />
  )
}

export interface TableSortButtonProps extends React.ComponentProps<"button"> {
  /** Current sort direction for the column; `false` renders the idle state. @default false */
  direction?: "asc" | "desc" | false
}

/** Header button whose arrow fades in when active and rotates between ascending and descending. */
function TableSortButton({
  className,
  direction = false,
  children,
  ...props
}: TableSortButtonProps) {
  return (
    <button
      type="button"
      data-slot="table-sort-button"
      data-direction={direction || undefined}
      className={cn(
        "group/sort hover:text-foreground focus-visible:ring-ring/40 -mx-1.5 inline-flex h-7 items-center gap-1 rounded-sm px-1.5 font-medium outline-none transition-colors duration-150 focus-visible:ring-2 data-[direction]:text-foreground",
        className
      )}
      {...props}
    >
      {children}
      <svg
        aria-hidden
        viewBox="0 0 16 16"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.6}
        strokeLinecap="round"
        strokeLinejoin="round"
        className={cn(
          "size-3.5 shrink-0 transition-[rotate,opacity] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
          direction === false && "opacity-35 group-hover/sort:opacity-70",
          direction === "desc" && "rotate-180"
        )}
      >
        <path d="M8 13V3M4 7l4-4 4 4" />
      </svg>
    </button>
  )
}

function TableCaption({
  className,
  ...props
}: React.ComponentProps<"caption">) {
  return (
    <caption
      data-slot="table-caption"
      className={cn("text-muted-foreground mt-4 text-sm", className)}
      {...props}
    />
  )
}

export {
  Table,
  TableHeader,
  TableBody,
  TableFooter,
  TableHead,
  TableRow,
  TableCell,
  TableCaption,
  TableSortButton,
  tableVariants,
}

属性 Props

Table

Table 最外层封装了支持横向滚动的容器元素,并接受原生 <table> 的全部 HTML 属性:

属性类型默认值说明
density"default" | "compact""default"控制表格单元格内边距与行高的视觉密度。
stripedbooleanfalse是否为偶数数据行增加斑马纹浅灰背景,提升多行连续阅读体验。
stickyHeaderbooleanfalse在容器发生纵向滚动时,表头是否固定在顶部吸顶可见。
containerClassNamestring—应用于外层溢出滚动容器(div[data-slot=table-container])的额外 CSS 类名。
classNamestring—应用于底层 <table> 元素的额外 CSS 类名。

TableHead

表头单元格,接受原生 <th> 的全部 HTML 属性:

属性类型默认值说明
pinned"left" | "right"—将列冻结在横向滚动容器的左侧或右侧边缘。
pinOffsetnumber | string0距离固定边缘的偏移量(像素或 CSS 长度),连续固定多列时用于计算层叠位置。
resizeWidth / onResizenumber / (width: number) => void—传入当前列宽和更新函数后显示拖拽手柄;支持鼠标、触控笔、触摸拖动与左右方向键。列宽由使用方写入 colgroup。
minResizeWidth / maxResizeWidthnumber / number80 / 800限制拖动和键盘调整后的列宽。
resizeLabelstring"调整列宽"调宽手柄的无障碍名称,建议包含列名。
classNamestring—应用于表头单元格的额外 CSS 类名。

调整列宽

表格使用固定布局和 colgroup 时,可将列宽交给状态管理,并在 TableHead 上启用调宽手柄:

const [nameWidth, setNameWidth] = React.useState(180)

<Table className="table-fixed" style={{ minWidth: nameWidth + 240 }}>
  <colgroup>
    <col style={{ width: nameWidth }} />
    <col style={{ width: 240 }} />
  </colgroup>
  <TableHeader>
    <TableRow>
      <TableHead resizeWidth={nameWidth} onResize={setNameWidth} resizeLabel="调整名称列宽">名称</TableHead>
      <TableHead>说明</TableHead>
    </TableRow>
  </TableHeader>
  {/* TableBody */}
</Table>

需要跨页面保留宽度时,使用方可将状态写入 localStorage,并按表格或页面分别设置存储键。

TableCell

数据单元格,接受原生 <td> 的全部 HTML 属性:

属性类型默认值说明
pinned"left" | "right"—将单元格固定在横向滚动容器的左侧或右侧边缘,需与同列的 TableHead 保持一致。
pinOffsetnumber | string0距离固定边缘的偏移量,需与同列的 TableHead 保持一致。
classNamestring—应用于数据单元格的额外 CSS 类名。

TableSortButton

表头排序按钮,接受原生 <button> 的全部 HTML 属性。激活时箭头淡入,升降序切换时箭头旋转过渡;请同时在所在 TableHead 上设置 aria-sort:

属性类型默认值说明
direction"asc" | "desc" | falsefalse当前列的排序方向;为 false 时显示弱化的空闲箭头,悬停时加深。
onClick(event: React.MouseEvent<HTMLButtonElement>) => void—点击时切换排序状态,排序逻辑由使用方维护。

结构子组件

TableHeader(<thead>)、TableBody(<tbody>)、TableFooter(<tfoot>)、TableRow(<tr>)和 TableCaption(<caption>)均透传对应原生 HTML 元素的全部属性与引用。

事件 Events

所有表格组件均完整透传原生 DOM 事件,常用事件如下:

属性类型默认值说明
onClick(event: React.MouseEvent<HTMLTableRowElement>) => void—在 TableRow 或 TableCell 上点击时触发,常用于行点击查看详情或触发行选择。
onPointerEnter(event: React.PointerEvent<HTMLTableRowElement>) => void—指针移入数据行时触发,可用于显示悬浮操作按钮或关联图表高亮。
onPointerLeave(event: React.PointerEvent<HTMLTableRowElement>) => void—指针离开数据行时触发。
onKeyDown(event: React.KeyboardEvent<HTMLTableRowElement>) => void—在可聚焦的数据行或单元格上按下键盘按键时触发,用于支持键盘快捷操作。

使用场景与设计规范

Table 适用于呈现列结构清晰、需要进行纵向扫描与横向对比的二维结构化数据。

  • 表格 vs 描述列表 Descriptions:若数据为同构的多条业务记录(如 50 笔订单明细、团队成员列表),应使用 Table;若为单个实体的多维度详情画像(如单个订单的详细信息),应使用 Descriptions。
  • 数据对齐原则:
    • 文本列:左对齐(默认)。
    • 数值与金额列:右对齐,并添加 tabular-nums 字体特性类名,保证数字与小数点垂直精确对齐,方便视觉扫视比对。
    • 状态与标签列:居中或左对齐,宽度相对固定。
    • 操作列:右对齐,建议固定在表格最右侧(pinned="right")。
  • 固定列(Sticky Pinning)规范:当表格列数较多且存在横向滚动时,建议将前 1~2 列关键标识(如复选框、名称、ID)固定在左侧,将操作列固定在右侧,确保用户在滚动浏览宽数据时不迷失当前上下文。

场景示例

紧凑密度与斑马纹

在运维监控、接口日志等高密度数据分析场景下,使用 density="compact" 降低内边距,配合 striped 斑马纹与 TableFooter 汇总行:

Loading…

行选择与批量操作

在数据管理列表中,通过 TableRow 的 data-state="selected" 属性呈现选中背景高亮;选中后工具栏切换为批量操作,移除的行淡出后其余行平滑上移:

Loading…

可展开行与排序

点击行或左侧箭头展开订单详情,详情区域以高度过渡展开;使用 TableSortButton 切换金额排序,行位置通过 motion 的 layout 平滑换位。动效在系统开启“减少动态效果”时自动关闭:

Loading…

复杂数据表格(固定列 / 树形 / 排序 / 筛选 / 拖拽)

针对企业级复杂中后台场景,Table 可以无缝承载级联多级表头、树形展开折叠、同级拖动排序、表头多维筛选与左右双向冻结列:

Loading…

无障碍与交互 Accessibility

  • 语义化结构:组件输出规范的 <table>、<thead>、<tbody>、<tfoot>、<tr>、<th> 和 <td> 原生元素,读屏器能够自动识别行列数与单元格坐标。
  • 表头关联:TableHead 自动渲染为 <th> 并具有 align-middle 与 font-medium 样式,在复杂多级表头下可通过 colSpan 与 rowSpan 维持无障碍关系树。
  • 键盘焦点与选中:当表格支持行选择时,使用带有明确 aria-label 的 Checkbox 组件;当整行支持点击时,建议通过 tabIndex={0} 与 onKeyDown 提供回车键响应支持。
  • 横向滚动容器:外层容器具有 overflow-auto,在触控与键盘 Tab 导航中可顺畅聚焦与滚动,避免内容在小屏幕下溢出截断。