Shadcn Loading State Table
Skeleton rows for the first load and a dimmed, still-interactive table for background refetches — so the table never collapses to a blank box or a lone spinner while data is on its way.
$ npx shadcn add https://www.shad-table.dev/r/loading-state-table.json| Customer | Status | Amount | Due |
|---|---|---|---|
How it works
1.Skeleton rows are built from the table's own columns
The loading branch maps over table.getAllLeafColumns() instead of hard-coding cells, so the placeholder always has the same number of columns as the real table — add or hide a column and the skeleton follows.
{isLoading? Array.from({ length: skeletonRows }, (_, rowIndex) => (<TableRow key={rowIndex}>{leafColumns.map((column) => (<TableCell key={column.id}><Skeleton className="h-4 w-3/4" /></TableCell>))}</TableRow>)): /* real rows */}
2.Widths vary so it reads as content
A fixed pool of widths is indexed by row and column, so neighbouring cells get different lengths. Uniform bars look like a grid; varied bars look like text about to appear. The right-aligned amount column pushes its bar to the right edge to match the real cell.
const SKELETON_WIDTHS = ['w-3/4', 'w-1/2', 'w-2/3', 'w-5/6', 'w-1/3']<Skeleton className={cn('h-4', SKELETON_WIDTHS[(rowIndex + colIndex) % SKELETON_WIDTHS.length])} />
3.First load vs. refetch are different states
isLoading means there is nothing to show yet, so the body is replaced with skeletons. isFetching means rows already exist, so they stay visible, get dimmed, and a thin progress line appears at the top. Swapping real rows for skeletons on every refetch makes the table flash and loses the user's place.
<TableBody className={cn(isFetching && 'opacity-60 transition-opacity')}>{isFetching && !isLoading && (<div className="absolute inset-x-0 top-0 z-10 h-0.5 animate-pulse bg-primary" />)}
4.aria-busy tells assistive tech the table is updating
The wrapper sets aria-busy while loading or fetching, so screen readers don't announce half-loaded content.
<div className="relative overflow-hidden rounded-md border" aria-busy={isLoading || isFetching}>
5.The demo ignores superseded responses
Each request gets an incrementing id, and a response is only applied if it still matches the latest id. Clicking Reload twice quickly can't let the slower, older response overwrite the newer one.
const id = ++requestId.current// ...const result = await fetchInvoices()if (id !== requestId.current) return // a newer request owns the table nowsetData(result)