Table

Betav1.0.0

정렬 · 선택 · sticky · popin · drag&drop · noData 까지 한 세트로 제공하는 강화 표 컴포넌트. 엔터프라이즈 데이터 테이블 패턴.

InvoiceStatusMethodAmount
INV001PaidCredit Card$250.00
INV002PendingPayPal$150.00
INV003UnpaidBank Transfer$350.00
INV004PaidCredit Card$450.00
INV005PendingPayPal$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.json

Variants

Basic

가장 단순한 형태 — 헤더 + 행 + Caption. import 직후 이 코드만 써도 동작.

A list of your recent invoices.
InvoiceStatusMethodAmount
INV001PaidCredit Card$250.00
INV002PendingPayPal$150.00
INV003UnpaidBank Transfer$350.00
INV004PaidCredit Card$450.00
INV005PendingPayPal$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 를 켜면 헤더가 스크롤에도 고정.

IDNameTypeValue
r0Row 1Type A13.40
r1Row 2Type B26.80
r2Row 3Type A40.20
r3Row 4Type B53.60
r4Row 5Type A67.00
r5Row 6Type B80.40
r6Row 7Type A93.80
r7Row 8Type B107.20
r8Row 9Type A120.60
r9Row 10Type B134.00
r10Row 11Type A147.40
r11Row 12Type B160.80
r12Row 13Type A174.20
r13Row 14Type B187.60
r14Row 15Type A201.00
r15Row 16Type B214.40
r16Row 17Type A227.80
r17Row 18Type B241.20
r18Row 19Type A254.60
r19Row 20Type B268.00
r20Row 21Type A281.40
r21Row 22Type B294.80
r22Row 23Type A308.20
r23Row 24Type B321.60
r24Row 25Type A335.00
r25Row 26Type B348.40
r26Row 27Type A361.80
r27Row 28Type B375.20
r28Row 29Type A388.60
r29Row 30Type B402.00
<TableContainer stickyHeader className="max-h-[320px]">
  <TableHeader>...</TableHeader>
  <TableBody>{/* 30개 행 */}</TableBody>
</TableContainer>

Sortable columns

useTableSort + TableSortableHead. 헤더 클릭으로 none → asc → desc → none 토글. aria-sort 자동.

Method
INV001PaidCredit Card$250.00
INV002PendingPayPal$150.00
INV003UnpaidBank Transfer$350.00
INV004PaidCredit Card$450.00
INV005PendingPayPal$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.

InvoiceStatusMethodAmount
INV001PaidCredit Card$250.00
INV002PendingPayPal$150.00
INV003UnpaidBank 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

계정의 모든 사용자 — 이름, 직함, 이메일, 역할.

NameTitleEmailRoleEdit
Lindsay Walton
Front-end Developer
lindsay.walton@example.com
Front-end Developerlindsay.walton@example.comMember
Courtney Henry
Designer
courtney.henry@example.com
Designercourtney.henry@example.comAdmin
Tom Cook
Director of Product
tom.cook@example.com
Director of Producttom.cook@example.comMember
Whitney Francis
Copywriter
whitney.francis@example.com
Copywriterwhitney.francis@example.comAdmin
Leonard Krasner
Senior Designer
leonard.krasner@example.com
Senior Designerleonard.krasner@example.comOwner
Floyd Miles
Principal Designer
floyd.miles@example.com
Principal Designerfloyd.miles@example.comMember
// 실제 앱: 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.

InvoiceStatusMethodAmount
⋮⋮INV001PaidCredit Card$250.00
⋮⋮INV002PendingPayPal$150.00
⋮⋮INV003UnpaidBank Transfer$350.00
⋮⋮INV004PaidCredit Card$450.00
⋮⋮INV005PendingPayPal$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 타입 지원.

IDName (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 슬롯.

InvoiceStatusMethodAmount
📭

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)
    └── TableFooter

Dependencies

설치 시 자동으로 함께 들어오는 의존성. 모든 버전은 정확히 고정되어 자동 업데이트를 방지합니다.

npm packages

PackageVersion
clsx2.1.1
tailwind-merge3.6.0

Groudit components

이 컴포넌트가 의존하는 다른 Groudit 컴포넌트 — 함께 설치됩니다.

Accessibility

이 컴포넌트는 WAI-ARIA table 패턴을 따릅니다.

Keyboard interactions

KeyDescription
Tab
다음 인터랙티브 셀로 이동.

WCAG 2.1 AA

이 컴포넌트는 다음 Success Criteria 충족을 목표로 합니다.

Notes

  • TableHeader/TableHead 로 시맨틱 헤더 명시.
  • TableCaption 으로 표의 목적을 스크린리더에 안내 권장.