Table

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview
역할상태
김하늘디자이너활성12
이도현프론트엔드일시정지7
박서연백엔드활성23
최민준PM보관4

Table

컬럼 설정 기반의 데이터 테이블입니다. 정렬·커스텀 셀 렌더·로딩 스켈레톤·빈 상태를 지원합니다.
정렬 가능한 헤더(이름·작업 수)를 클릭하면 오름차순 → 내림차순 → 해제로 순환합니다.

정렬 + 커스텀 셀

sortable 컬럼은 클릭으로 정렬되고, render로 아바타·배지 같은 임의 셀을 그릴 수 있습니다.

Source Code
const columns: TableColumn<Member>[] = [
  { key: 'name', header: '이름', sortable: true,
    render: (r) => <Avatar name={r.name} /> },
  { key: 'status', header: '상태',
    render: (r) => <Badge>{r.status}</Badge> },
  { key: 'tasks', header: '작업 수', align: 'right', sortable: true },
];

<Table columns={columns} data={members} rowKey={(r) => r.id} />
역할상태
김하김하늘
디자이너활성12
이도이도현
프론트엔드일시정지7
박서박서연
백엔드활성23
최민최민준
PM보관4
정유정유진
QA활성15
Live Preview

로딩 / 빈 상태

loading이면 스켈레톤 행을, 데이터가 없으면 emptyText를 표시합니다.

Source Code
<Table loading data={members} ... />        // 스켈레톤
<Table data={[]} emptyText="멤버가 없습니다." ... />  // 빈 상태
역할상태
김하김하늘
디자이너활성12
이도이도현
프론트엔드일시정지7
박서박서연
백엔드활성23
최민최민준
PM보관4
정유정유진
QA활성15
역할상태
표시할 멤버가 없습니다.
Live Preview
Implementation

제작 코드

이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.

Tabletypescript
'use client';

/**
 * Table 컴포넌트
 *
 * 컬럼 설정 기반의 데이터 테이블. 정렬·커스텀 렌더·로딩 스켈레톤·빈 상태를 지원합니다.
 * - 제네릭 <T>로 행 타입을 받고, columns로 표시/정렬 규칙을 선언합니다.
 * - 정렬 가능한 헤더는 클릭마다 오름차순 → 내림차순 → 해제로 순환합니다.
 * - stickyHeader로 스크롤 시 헤더를 고정할 수 있습니다(컨테이너 높이 제한 시).
 */

import { useMemo, useState, type ReactNode } from 'react';
import { cn } from '@/lib/cn';

export type TableAlign = 'left' | 'center' | 'right';

export interface TableColumn<T> {
  /** 컬럼 식별자 (정렬 키 + React key). 기본 렌더 시 row[key]를 읽음 */
  key: string;
  /** 헤더 라벨 */
  header: ReactNode;
  /** 셀 렌더 커스터마이즈 (없으면 row[key] 텍스트) */
  render?: (row: T, index: number) => ReactNode;
  align?: TableAlign;
  /** 정렬 가능 여부 */
  sortable?: boolean;
  /** 정렬 기준 값 추출 (없으면 row[key]) */
  sortAccessor?: (row: T) => string | number;
  /** 컬럼 너비 (CSS 값) */
  width?: string;
  headerClassName?: string;
  cellClassName?: string;
}

type SortDir = 'asc' | 'desc';

export interface TableProps<T> {
  columns: TableColumn<T>[];
  data: T[];
  /** 행 고유 키 */
  rowKey: (row: T, index: number) => string | number;
  size?: 'sm' | 'md';
  stickyHeader?: boolean;
  /** 로딩 중이면 스켈레톤 행 표시 */
  loading?: boolean;
  loadingRows?: number;
  /** 데이터가 없을 때 표시할 내용 */
  emptyText?: ReactNode;
  onRowClick?: (row: T, index: number) => void;
  className?: string;
}

const alignClass: Record<TableAlign, string> = {
  left: 'text-left',
  center: 'text-center',
  right: 'text-right',
};

function SortIcon({ active, dir }: { active: boolean; dir?: SortDir }) {
  return (
    <span className={cn('inline-flex flex-col -space-y-1', active ? 'text-slate-700' : 'text-slate-300')}>
      <svg className={cn('size-2.5', active && dir === 'asc' && 'text-violet-600')} viewBox="0 0 24 24" fill="currentColor">
        <path d="M12 8l5 6H7z" />
      </svg>
      <svg className={cn('size-2.5', active && dir === 'desc' && 'text-violet-600')} viewBox="0 0 24 24" fill="currentColor">
        <path d="M12 16l-5-6h10z" />
      </svg>
    </span>
  );
}

export function Table<T>({
  columns,
  data,
  rowKey,
  size = 'md',
  stickyHeader = false,
  loading = false,
  loadingRows = 4,
  emptyText = '데이터가 없습니다.',
  onRowClick,
  className,
}: TableProps<T>) {
  const [sort, setSort] = useState<{ key: string; dir: SortDir } | null>(null);

  const sortedData = useMemo(() => {
    if (!sort) return data;
    const col = columns.find((c) => c.key === sort.key);
    if (!col) return data;
    const accessor =
      col.sortAccessor ??
      ((row: T) => {
        const v = (row as Record<string, unknown>)[col.key];
        return typeof v === 'number' ? v : String(v ?? '');
      });
    const sorted = [...data].sort((a, b) => {
      const av = accessor(a);
      const bv = accessor(b);
      if (typeof av === 'number' && typeof bv === 'number') return av - bv;
      return String(av).localeCompare(String(bv), 'ko');
    });
    return sort.dir === 'desc' ? sorted.reverse() : sorted;
  }, [data, sort, columns]);

  const toggleSort = (key: string) => {
    setSort((prev) => {
      if (!prev || prev.key !== key) return { key, dir: 'asc' };
      if (prev.dir === 'asc') return { key, dir: 'desc' };
      return null;
    });
  };

  const cellPad = size === 'sm' ? 'px-3 py-2' : 'px-4 py-3';

  return (
    <div className={cn('overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm', className)}>
      <div className="overflow-x-auto">
        <table className="w-full border-collapse text-sm">
          <thead>
            <tr className={cn('border-b border-slate-200 bg-slate-50/80', stickyHeader && 'sticky top-0 z-10')}>
              {columns.map((col) => {
                const isSorted = sort?.key === col.key;
                const dir = isSorted ? sort?.dir : undefined;
                return (
                  <th
                    key={col.key}
                    scope="col"
                    style={col.width ? { width: col.width } : undefined}
                    aria-sort={isSorted ? (dir === 'asc' ? 'ascending' : 'descending') : undefined}
                    className={cn(
                      'whitespace-nowrap text-xs font-semibold uppercase tracking-wide text-slate-500',
                      cellPad,
                      alignClass[col.align ?? 'left'],
                      col.headerClassName,
                    )}
                  >
                    {col.sortable ? (
                      <button
                        type="button"
                        onClick={() => toggleSort(col.key)}
                        className={cn(
                          'group inline-flex items-center gap-1 transition-colors hover:text-slate-800',
                          col.align === 'right' && 'flex-row-reverse',
                          isSorted && 'text-slate-800',
                        )}
                      >
                        {col.header}
                        <SortIcon active={!!isSorted} dir={dir} />
                      </button>
                    ) : (
                      col.header
                    )}
                  </th>
                );
              })}
            </tr>
          </thead>
          <tbody>
            {loading ? (
              Array.from({ length: loadingRows }).map((_, r) => (
                <tr key={`skeleton-${r}`} className="border-b border-slate-100 last:border-0">
                  {columns.map((col) => (
                    <td key={col.key} className={cellPad}>
                      <div className="h-3 w-2/3 animate-pulse rounded bg-slate-100" />
                    </td>
                  ))}
                </tr>
              ))
            ) : sortedData.length === 0 ? (
              <tr>
                <td colSpan={columns.length} className="px-4 py-12 text-center text-sm text-slate-400">
                  {emptyText}
                </td>
              </tr>
            ) : (
              sortedData.map((row, index) => (
                <tr
                  key={rowKey(row, index)}
                  onClick={onRowClick ? () => onRowClick(row, index) : undefined}
                  className={cn(
                    'border-b border-slate-100 transition-colors last:border-0',
                    onRowClick && 'cursor-pointer hover:bg-slate-50',
                  )}
                >
                  {columns.map((col) => (
                    <td
                      key={col.key}
                      className={cn('text-slate-700', cellPad, alignClass[col.align ?? 'left'], col.cellClassName)}
                    >
                      {col.render ? col.render(row, index) : String((row as Record<string, unknown>)[col.key] ?? '')}
                    </td>
                  ))}
                </tr>
              ))
            )}
          </tbody>
        </table>
      </div>
    </div>
  );
}