表格 Table
保留原生语义与无障碍结构的响应式表格组件,支持紧凑密度、斑马纹、冻结表头与左右固定列。
基础用法
最基础的表格用法。使用语义化的 Table、TableHeader、TableBody、TableRow、TableHead 和 TableCell 组织结构化数据:
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/tablepnpm add class-variance-authority clsx tailwind-mergecomponents/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" | 控制表格单元格内边距与行高的视觉密度。 |
| striped | boolean | false | 是否为偶数数据行增加斑马纹浅灰背景,提升多行连续阅读体验。 |
| stickyHeader | boolean | false | 在容器发生纵向滚动时,表头是否固定在顶部吸顶可见。 |
| containerClassName | string | — | 应用于外层溢出滚动容器(div[data-slot=table-container])的额外 CSS 类名。 |
| className | string | — | 应用于底层 <table> 元素的额外 CSS 类名。 |
TableHead
表头单元格,接受原生 <th> 的全部 HTML 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| pinned | "left" | "right" | — | 将列冻结在横向滚动容器的左侧或右侧边缘。 |
| pinOffset | number | string | 0 | 距离固定边缘的偏移量(像素或 CSS 长度),连续固定多列时用于计算层叠位置。 |
| resizeWidth / onResize | number / (width: number) => void | — | 传入当前列宽和更新函数后显示拖拽手柄;支持鼠标、触控笔、触摸拖动与左右方向键。列宽由使用方写入 colgroup。 |
| minResizeWidth / maxResizeWidth | number / number | 80 / 800 | 限制拖动和键盘调整后的列宽。 |
| resizeLabel | string | "调整列宽" | 调宽手柄的无障碍名称,建议包含列名。 |
| className | string | — | 应用于表头单元格的额外 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 保持一致。 |
| pinOffset | number | string | 0 | 距离固定边缘的偏移量,需与同列的 TableHead 保持一致。 |
| className | string | — | 应用于数据单元格的额外 CSS 类名。 |
TableSortButton
表头排序按钮,接受原生 <button> 的全部 HTML 属性。激活时箭头淡入,升降序切换时箭头旋转过渡;请同时在所在 TableHead 上设置 aria-sort:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| direction | "asc" | "desc" | false | false | 当前列的排序方向;为 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 汇总行:
行选择与批量操作
在数据管理列表中,通过 TableRow 的 data-state="selected" 属性呈现选中背景高亮;选中后工具栏切换为批量操作,移除的行淡出后其余行平滑上移:
可展开行与排序
点击行或左侧箭头展开订单详情,详情区域以高度过渡展开;使用 TableSortButton 切换金额排序,行位置通过 motion 的 layout 平滑换位。动效在系统开启“减少动态效果”时自动关闭:
复杂数据表格(固定列 / 树形 / 排序 / 筛选 / 拖拽)
针对企业级复杂中后台场景,Table 可以无缝承载级联多级表头、树形展开折叠、同级拖动排序、表头多维筛选与左右双向冻结列:
无障碍与交互 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 导航中可顺畅聚焦与滚动,避免内容在小屏幕下溢出截断。