Table
Betav1.0.0정렬 · 선택 · sticky · popin · drag&drop · noData 까지 한 세트로 제공하는 강화 표 컴포넌트. 엔터프라이즈 데이터 테이블 패턴.
| Invoice | Status | Method | Amount | |
|---|---|---|---|---|
| INV001 | Paid | Credit Card | $250.00 | |
| INV002 | Pending | PayPal | $150.00 | |
| INV003 | Unpaid | Bank Transfer | $350.00 | |
| INV004 | Paid | Credit Card | $450.00 | |
| INV005 | Pending | PayPal | $50.00 |
Tab → row → ↑↓ 이동 → Enter / Space 로 선택
import { useTableSelection } from "@/components/ui/use-table-selection";
import { useTableKeyboard } from "@/components/ui/use-table-keyboard";
const sel = useTableSelection<string>({ mode: "multi" });
const kb = useTableKeyboard({
rowCount: rows.length,
onSelect: (i) => sel.toggle(rows[i].id),
});
<TableBody {...kb.containerProps}>
{rows.map((row, i) => (
<TableRow
key={row.id}
selectable
selected={sel.isSelected(row.id)}
onSelect={() => sel.toggle(row.id, { rangeIds: ids })}
{...kb.getRowProps(i)}
>
...
</TableRow>
))}
</TableBody>Installation
pnpm dlx shadcn@latest add https://groudit.com/r/table.jsonVariants
Basic
가장 단순한 형태 — 헤더 + 행 + Caption. import 직후 이 코드만 써도 동작.
| Invoice | Status | Method | Amount |
|---|---|---|---|
| INV001 | Paid | Credit Card | $250.00 |
| INV002 | Pending | PayPal | $150.00 |
| INV003 | Unpaid | Bank Transfer | $350.00 |
| INV004 | Paid | Credit Card | $450.00 |
| INV005 | Pending | PayPal | $50.00 |
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
<Table>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>INV001</TableCell>
<TableCell>$250.00</TableCell>
</TableRow>
</TableBody>
</Table>Fixed (sticky) header
stickyHeader 를 켜면 헤더가 스크롤에도 고정.
| ID | Name | Type | Value |
|---|---|---|---|
| r0 | Row 1 | Type A | 13.40 |
| r1 | Row 2 | Type B | 26.80 |
| r2 | Row 3 | Type A | 40.20 |
| r3 | Row 4 | Type B | 53.60 |
| r4 | Row 5 | Type A | 67.00 |
| r5 | Row 6 | Type B | 80.40 |
| r6 | Row 7 | Type A | 93.80 |
| r7 | Row 8 | Type B | 107.20 |
| r8 | Row 9 | Type A | 120.60 |
| r9 | Row 10 | Type B | 134.00 |
| r10 | Row 11 | Type A | 147.40 |
| r11 | Row 12 | Type B | 160.80 |
| r12 | Row 13 | Type A | 174.20 |
| r13 | Row 14 | Type B | 187.60 |
| r14 | Row 15 | Type A | 201.00 |
| r15 | Row 16 | Type B | 214.40 |
| r16 | Row 17 | Type A | 227.80 |
| r17 | Row 18 | Type B | 241.20 |
| r18 | Row 19 | Type A | 254.60 |
| r19 | Row 20 | Type B | 268.00 |
| r20 | Row 21 | Type A | 281.40 |
| r21 | Row 22 | Type B | 294.80 |
| r22 | Row 23 | Type A | 308.20 |
| r23 | Row 24 | Type B | 321.60 |
| r24 | Row 25 | Type A | 335.00 |
| r25 | Row 26 | Type B | 348.40 |
| r26 | Row 27 | Type A | 361.80 |
| r27 | Row 28 | Type B | 375.20 |
| r28 | Row 29 | Type A | 388.60 |
| r29 | Row 30 | Type B | 402.00 |
<TableContainer stickyHeader className="max-h-[320px]">
<TableHeader>...</TableHeader>
<TableBody>{/* 30개 행 */}</TableBody>
</TableContainer>Sortable columns
useTableSort + TableSortableHead. 헤더 클릭으로 none → asc → desc → none 토글. aria-sort 자동.
| Method | |||
|---|---|---|---|
| INV001 | Paid | Credit Card | $250.00 |
| INV002 | Pending | PayPal | $150.00 |
| INV003 | Unpaid | Bank Transfer | $350.00 |
| INV004 | Paid | Credit Card | $450.00 |
| INV005 | Pending | PayPal | $50.00 |
import { useTableSort } from "@/components/ui/use-table-sort";
const sort = useTableSort<"invoice" | "amount">();
const sorted = sort.sortData(rows, (a, b, key) => {
if (key === "amount") return parseFloat(a.amount) - parseFloat(b.amount);
return String(a[key]).localeCompare(String(b[key]));
});
<TableSortableHead
sortDirection={sort.getSortDirection("invoice")}
onSort={() => sort.setSort("invoice")}
>
Invoice
</TableSortableHead>Popin mode (responsive)
좁은 화면(sm 미만)에서 컬럼이 행 아래로 접힘 — 각 셀 앞에 컬럼 라벨 자동 표시.popinMode + popinLabel.
| Invoice | Status | Method | Amount |
|---|---|---|---|
| INV001 | Paid | Credit Card | $250.00 |
| INV002 | Pending | PayPal | $150.00 |
| INV003 | Unpaid | Bank Transfer | $350.00 |
<TableContainer popinMode>
<TableHeader>
<TableRow>
<TableHead popinLabel="Invoice">Invoice</TableHead>
<TableHead popinLabel="Amount">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell popinLabel="Invoice">INV001</TableCell>
<TableCell popinLabel="Amount">$250.00</TableCell>
</TableRow>
</TableBody>
</TableContainer>Stacked columns on mobile
화면이 좁아질수록 컬럼이 사라지고 그 데이터가 첫 컬럼(Name) 아래로 합쳐집니다. 프리뷰의 뷰포트 토글로 폭을 줄여 확인하세요 — 문서 프리뷰는 컨테이너 폭 기준 (@container), 실제 앱에선 sm:/md: 로 바꿔 쓰면 됩니다.
Users
계정의 모든 사용자 — 이름, 직함, 이메일, 역할.
| Name | Title | Role | Edit | |
|---|---|---|---|---|
Lindsay Walton Front-end Developer lindsay.walton@example.com | Front-end Developer | lindsay.walton@example.com | Member | |
Courtney Henry Designer courtney.henry@example.com | Designer | courtney.henry@example.com | Admin | |
Tom Cook Director of Product tom.cook@example.com | Director of Product | tom.cook@example.com | Member | |
Whitney Francis Copywriter whitney.francis@example.com | Copywriter | whitney.francis@example.com | Admin | |
Leonard Krasner Senior Designer leonard.krasner@example.com | Senior Designer | leonard.krasner@example.com | Owner | |
Floyd Miles Principal Designer floyd.miles@example.com | Principal Designer | floyd.miles@example.com | Member |
// 실제 앱: viewport 기준 (sm:/md:/lg:)
// 문서 프리뷰: 컨테이너 기준 (@container + @max-md:/@max-lg:)
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="max-md:hidden">Title</TableHead>
<TableHead className="max-lg:hidden">Email</TableHead>
<TableHead>Role</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((u) => (
<TableRow key={u.email}>
<TableCell>
<div className="font-medium">{u.name}</div>
{/* 숨긴 컬럼을 Name 아래로 합쳐 노출 */}
<div className="text-muted-foreground md:hidden">{u.title}</div>
<div className="text-muted-foreground lg:hidden">{u.email}</div>
</TableCell>
<TableCell className="max-md:hidden">{u.title}</TableCell>
<TableCell className="max-lg:hidden">{u.email}</TableCell>
<TableCell>{u.role}</TableCell>
</TableRow>
))}
</TableBody>
</Table>Drag and drop reorder
useTableDnd— HTML5 D&D 기반. 외부 라이브러리 X.
| Invoice | Status | Method | Amount | |
|---|---|---|---|---|
| ⋮⋮ | INV001 | Paid | Credit Card | $250.00 |
| ⋮⋮ | INV002 | Pending | PayPal | $150.00 |
| ⋮⋮ | INV003 | Unpaid | Bank Transfer | $350.00 |
| ⋮⋮ | INV004 | Paid | Credit Card | $450.00 |
| ⋮⋮ | INV005 | Pending | PayPal | $50.00 |
행의 ⋮⋮ 핸들을 드래그해서 순서 변경
import { useTableDnd } from "@/components/ui/use-table-dnd";
const dnd = useTableDnd<string>({
items: rows.map((r) => r.id),
onReorder: (newOrder) => {
const map = new Map(rows.map((r) => [r.id, r]));
setRows(newOrder.map((id) => map.get(id)!));
},
});
<TableRow {...dnd.getRowProps(row.id)}>
<TableCell className="cursor-grab">⋮⋮</TableCell>
...
</TableRow>Editable cells (ALV 스타일)
엔터프라이즈 데이터 그리드 편집 패턴 — 셀 클릭 시 input/select/switch 로 전환되어 그 자리에서 편집. Enter / blur 로 저장, Esc 로 취소. EditableCell 컴포넌트로 text · number · select · switch · badge 타입 지원.
| ID | Name (click to edit) | Category (click) | Price (click) | In stock |
|---|---|---|---|---|
| p1 | ||||
| p2 | ||||
| p3 | ||||
| p4 |
셀 클릭 → 편집 모드. Enter / 외부 클릭 시 저장. Esc 로 취소.
import { EditableCell } from "@/components/ui/editable-cell";
const [products, setProducts] = useState(initialData);
const update = (id, key, value) => {
setProducts(prev => prev.map(p =>
p.id === id ? { ...p, [key]: value } : p,
));
};
<TableRow>
<TableCell>
<EditableCell
type="text"
value={p.name}
onChange={v => update(p.id, "name", v)}
/>
</TableCell>
<TableCell>
<EditableCell
type="select"
value={p.category}
onChange={v => update(p.id, "category", v)}
options={CATEGORY_OPTIONS}
/>
</TableCell>
<TableCell>
<EditableCell
type="number"
value={p.price}
onChange={v => update(p.id, "price", Number(v))}
/>
</TableCell>
<TableCell>
<EditableCell
type="switch"
value={p.inStock}
onChange={v => update(p.id, "inStock", v)}
/>
</TableCell>
</TableRow>Empty state (noData)
TableEmptyState — icon · title · description · action 슬롯.
| Invoice | Status | Method | Amount |
|---|---|---|---|
📭 No invoices yet Once you create your first invoice, it will appear here. | |||
<TableBody>
{rows.length === 0 ? (
<TableEmptyState
colSpan={4}
icon="📭"
title="No invoices yet"
description="Once you create your first invoice, it will appear here."
action={<Button size="sm">Create invoice</Button>}
/>
) : (
rows.map(row => <TableRow key={row.id}>...</TableRow>)
)}
</TableBody>Composition
TableContainer
└── Table
├── TableCaption
├── TableHeader
│ └── TableRow
│ ├── TableHead
│ └── TableSortableHead
├── TableBody
│ ├── TableRow
│ │ └── TableCell
│ └── TableEmptyState (noData)
└── TableFooterDependencies
설치 시 자동으로 함께 들어오는 의존성. 모든 버전은 정확히 고정되어 자동 업데이트를 방지합니다.
npm packages
| Package | Version |
|---|---|
| clsx | 2.1.1 |
| tailwind-merge | 3.6.0 |
Accessibility
이 컴포넌트는 WAI-ARIA table 패턴을 따릅니다.
Keyboard interactions
| Key | Description |
|---|---|
Tab | 다음 인터랙티브 셀로 이동. |
Notes
- •TableHeader/TableHead 로 시맨틱 헤더 명시.
- •TableCaption 으로 표의 목적을 스크린리더에 안내 권장.