Table
Interactive UI Explorer
아래에서 각 컴포넌트의 다양한 Variants와 States를 문서화된 형태로 테스트해볼 수 있습니다.
Live Preview
| 역할 | 상태 | ||
|---|---|---|---|
| 김하늘 | 디자이너 | 활성 | 12 |
| 이도현 | 프론트엔드 | 일시정지 | 7 |
| 박서연 | 백엔드 | 활성 | 23 |
| 최민준 | PM | 보관 | 4 |
Table
컬럼 설정 기반의 데이터 테이블입니다. 정렬·커스텀 셀 렌더·로딩 스켈레톤·빈 상태를 지원합니다.
정렬 가능한 헤더(이름·작업 수)를 클릭하면 오름차순 → 내림차순 → 해제로 순환합니다.
정렬 + 커스텀 셀
sortable 컬럼은 클릭으로 정렬되고, render로 아바타·배지 같은 임의 셀을 그릴 수 있습니다.
Source Code
| 역할 | 상태 | ||
|---|---|---|---|
김하김하늘 | 디자이너 | 활성 | 12 |
이도이도현 | 프론트엔드 | 일시정지 | 7 |
박서박서연 | 백엔드 | 활성 | 23 |
최민최민준 | PM | 보관 | 4 |
정유정유진 | QA | 활성 | 15 |
Live Preview
로딩 / 빈 상태
loading이면 스켈레톤 행을, 데이터가 없으면 emptyText를 표시합니다.
Source Code
| 역할 | 상태 | ||
|---|---|---|---|
김하김하늘 | 디자이너 | 활성 | 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>
);
}