Table
Tabular data with a header, optional selection and a summary footer.
Introduction
A semantic <table> in a horizontally scrolling container, so a wide table never widens the page. The header sits on neutral-50, rows are divided by a primary-100 rule, and a selected row fills primary-50 — all measured from the UX4G kit at 1:1.
Two things this component does that a <div> grid cannot: screen readers announce "row 3, column 2, District" as you move through it, and browser find-in-page works across the whole table.
Installation
npx shadcn@latest add https://indiacn.in/r/table.jsonimport {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
TableRowHeader,
} from '@/components/ui/table';Usage
<Table>
<TableHeader>
<TableRow>
<TableHead>Reference</TableHead>
<TableHead>Applicant</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableRowHeader>ARN-4821</TableRowHeader>
<TableCell>Aarav Sharma</TableCell>
</TableRow>
</TableBody>
</Table>Examples
Default
| Reference | Applicant | Scheme | District |
|---|---|---|---|
ARN-4821 | Aarav Sharma | Ration card | Pune |
ARN-4822 | Priya Nair | Scholarship | Nashik |
ARN-4823 | Rohan Gupta | Pension | Nagpur |
ARN-4824 | Meera Menon | Ration card | Thane |
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
TableRowHeader,
} from '@/components/ui/table';
const APPLICATIONS = [
{ id: 'ARN-4821', name: 'Aarav Sharma', scheme: 'Ration card', district: 'Pune' },
{ id: 'ARN-4822', name: 'Priya Nair', scheme: 'Scholarship', district: 'Nashik' },
{ id: 'ARN-4823', name: 'Rohan Gupta', scheme: 'Pension', district: 'Nagpur' },
];
export default function Component() {
return (
<Table>
<TableCaption>Recent applications by district.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Reference</TableHead>
<TableHead>Applicant</TableHead>
<TableHead>Scheme</TableHead>
<TableHead>District</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{APPLICATIONS.map(row => (
<TableRow key={row.id}>
<TableRowHeader>{row.id}</TableRowHeader>
<TableCell>{row.name}</TableCell>
<TableCell>{row.scheme}</TableCell>
<TableCell>{row.district}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}Selectable rows
selected fills the row and sets aria-selected, so the highlight is not the only signal.
| Select | Reference | Applicant | Status |
|---|---|---|---|
ARN-4821 | Aarav Sharma | Approved | |
ARN-4822 | Priya Nair | In review | |
ARN-4823 | Rohan Gupta | Rejected | |
ARN-4824 | Meera Menon | Approved |
'use client';
import { Star } from 'lucide-react';
import { useCallback, useState } from 'react';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
TableRowHeader,
} from '@/components/ui/table';
import { Label3 } from '@/components/ui/typography';
const APPLICATIONS = [
{
id: 'ARN-4821',
name: 'Aarav Sharma',
scheme: 'Ration card',
district: 'Pune',
status: 'Approved',
},
{
id: 'ARN-4822',
name: 'Priya Nair',
scheme: 'Scholarship',
district: 'Nashik',
status: 'In review',
},
{
id: 'ARN-4823',
name: 'Rohan Gupta',
scheme: 'Pension',
district: 'Nagpur',
status: 'Rejected',
},
{
id: 'ARN-4824',
name: 'Meera Menon',
scheme: 'Ration card',
district: 'Thane',
status: 'Approved',
},
];
const STATUS_THEME = {
Approved: 'success',
'In review': 'warning',
Rejected: 'danger',
} as const;
export function TableDefault() {
return (
<Table>
<TableCaption>Recent applications by district.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Reference</TableHead>
<TableHead>Applicant</TableHead>
<TableHead>Scheme</TableHead>
<TableHead>District</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{APPLICATIONS.map(row => (
<TableRow key={row.id}>
<TableRowHeader>{row.id}</TableRowHeader>
<TableCell>{row.name}</TableCell>
<TableCell>{row.scheme}</TableCell>
<TableCell>{row.district}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
/** One selectable row. Split out so its handler is not rebuilt each render. */
function SelectableRow({
row,
selected,
onToggle,
}: {
row: (typeof APPLICATIONS)[number];
selected: boolean;
onToggle: (id: string) => void;
}) {
const handleChange = useCallback(() => onToggle(row.id), [onToggle, row.id]);
return (
<TableRow selected={selected}>
<TableCell className='w-12'>
<Checkbox
checked={selected}
onCheckedChange={handleChange}
aria-label={`Select ${row.id}`}
/>
</TableCell>
<TableRowHeader>{row.id}</TableRowHeader>
<TableCell>{row.name}</TableCell>
<TableCell>
<Badge theme={STATUS_THEME[row.status as keyof typeof STATUS_THEME]} variant='tonal'>
{row.status}
</Badge>
</TableCell>
</TableRow>
);
}
export function TableSelectable() {
const [selected, setSelected] = useState<string[]>(['ARN-4822']);
const toggle = useCallback(
(id: string) =>
setSelected(current =>
current.includes(id) ? current.filter(value => value !== id) : [...current, id],
),
[],
);
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-12'>
<Label3 className='sr-only'>Select</Label3>
</TableHead>
<TableHead>Reference</TableHead>
<TableHead>Applicant</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{APPLICATIONS.map(row => (
<SelectableRow
key={row.id}
row={row}
selected={selected.includes(row.id)}
onToggle={toggle}
/>
))}
</TableBody>
</Table>
);
}
export function TableRichCells() {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Officer</TableHead>
<TableHead>Centre</TableHead>
<TableHead>Rating</TableHead>
<TableHead className='text-right'>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[
{ name: 'Vihaan Mehta', initials: 'VM', centre: 'Pune East', rating: 4 },
{ name: 'Ananya Rao', initials: 'AR', centre: 'Nashik City', rating: 5 },
{ name: 'Karan Malhotra', initials: 'KM', centre: 'Thane West', rating: 3 },
].map(row => (
<TableRow key={row.name}>
<TableRowHeader>
<Label3 className='flex items-center gap-2.5 text-[inherit]'>
<Avatar size='sm'>
<AvatarFallback>{row.initials}</AvatarFallback>
</Avatar>
{row.name}
</Label3>
</TableRowHeader>
<TableCell>{row.centre}</TableCell>
<TableCell>
<Label3 className='flex gap-0.5' role='img' aria-label={`${row.rating} out of 5`}>
{[1, 2, 3, 4, 5].map(star => (
<Star
key={star}
aria-hidden
className={
star <= row.rating
? 'fill-primary text-primary size-3.5'
: 'size-3.5 fill-neutral-100 text-neutral-100'
}
/>
))}
</Label3>
</TableCell>
<TableCell className='text-right'>
<Button variant='text' size='sm'>
View
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableRowHeader>3 officers</TableRowHeader>
<TableCell />
<TableCell>Average 4.0</TableCell>
<TableCell />
</TableRow>
</TableFooter>
</Table>
);
}
Rich cells
Cells take any content — avatars, badges, ratings, actions. The rating below carries an aria-label, because five icons on their own say nothing.
| Officer | Centre | Rating | Action |
|---|---|---|---|
VMVihaan Mehta | Pune East | ||
ARAnanya Rao | Nashik City | ||
KMKaran Malhotra | Thane West | ||
3 officers | Average 4.0 |
'use client';
import { Star } from 'lucide-react';
import { useCallback, useState } from 'react';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
TableRowHeader,
} from '@/components/ui/table';
import { Label3 } from '@/components/ui/typography';
const APPLICATIONS = [
{
id: 'ARN-4821',
name: 'Aarav Sharma',
scheme: 'Ration card',
district: 'Pune',
status: 'Approved',
},
{
id: 'ARN-4822',
name: 'Priya Nair',
scheme: 'Scholarship',
district: 'Nashik',
status: 'In review',
},
{
id: 'ARN-4823',
name: 'Rohan Gupta',
scheme: 'Pension',
district: 'Nagpur',
status: 'Rejected',
},
{
id: 'ARN-4824',
name: 'Meera Menon',
scheme: 'Ration card',
district: 'Thane',
status: 'Approved',
},
];
const STATUS_THEME = {
Approved: 'success',
'In review': 'warning',
Rejected: 'danger',
} as const;
export function TableDefault() {
return (
<Table>
<TableCaption>Recent applications by district.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Reference</TableHead>
<TableHead>Applicant</TableHead>
<TableHead>Scheme</TableHead>
<TableHead>District</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{APPLICATIONS.map(row => (
<TableRow key={row.id}>
<TableRowHeader>{row.id}</TableRowHeader>
<TableCell>{row.name}</TableCell>
<TableCell>{row.scheme}</TableCell>
<TableCell>{row.district}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
/** One selectable row. Split out so its handler is not rebuilt each render. */
function SelectableRow({
row,
selected,
onToggle,
}: {
row: (typeof APPLICATIONS)[number];
selected: boolean;
onToggle: (id: string) => void;
}) {
const handleChange = useCallback(() => onToggle(row.id), [onToggle, row.id]);
return (
<TableRow selected={selected}>
<TableCell className='w-12'>
<Checkbox
checked={selected}
onCheckedChange={handleChange}
aria-label={`Select ${row.id}`}
/>
</TableCell>
<TableRowHeader>{row.id}</TableRowHeader>
<TableCell>{row.name}</TableCell>
<TableCell>
<Badge theme={STATUS_THEME[row.status as keyof typeof STATUS_THEME]} variant='tonal'>
{row.status}
</Badge>
</TableCell>
</TableRow>
);
}
export function TableSelectable() {
const [selected, setSelected] = useState<string[]>(['ARN-4822']);
const toggle = useCallback(
(id: string) =>
setSelected(current =>
current.includes(id) ? current.filter(value => value !== id) : [...current, id],
),
[],
);
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-12'>
<Label3 className='sr-only'>Select</Label3>
</TableHead>
<TableHead>Reference</TableHead>
<TableHead>Applicant</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{APPLICATIONS.map(row => (
<SelectableRow
key={row.id}
row={row}
selected={selected.includes(row.id)}
onToggle={toggle}
/>
))}
</TableBody>
</Table>
);
}
export function TableRichCells() {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Officer</TableHead>
<TableHead>Centre</TableHead>
<TableHead>Rating</TableHead>
<TableHead className='text-right'>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[
{ name: 'Vihaan Mehta', initials: 'VM', centre: 'Pune East', rating: 4 },
{ name: 'Ananya Rao', initials: 'AR', centre: 'Nashik City', rating: 5 },
{ name: 'Karan Malhotra', initials: 'KM', centre: 'Thane West', rating: 3 },
].map(row => (
<TableRow key={row.name}>
<TableRowHeader>
<Label3 className='flex items-center gap-2.5 text-[inherit]'>
<Avatar size='sm'>
<AvatarFallback>{row.initials}</AvatarFallback>
</Avatar>
{row.name}
</Label3>
</TableRowHeader>
<TableCell>{row.centre}</TableCell>
<TableCell>
<Label3 className='flex gap-0.5' role='img' aria-label={`${row.rating} out of 5`}>
{[1, 2, 3, 4, 5].map(star => (
<Star
key={star}
aria-hidden
className={
star <= row.rating
? 'fill-primary text-primary size-3.5'
: 'size-3.5 fill-neutral-100 text-neutral-100'
}
/>
))}
</Label3>
</TableCell>
<TableCell className='text-right'>
<Button variant='text' size='sm'>
View
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableRowHeader>3 officers</TableRowHeader>
<TableCell />
<TableCell>Average 4.0</TableCell>
<TableCell />
</TableRow>
</TableFooter>
</Table>
);
}
Accessibility
TableHeaddefaults toscope='col'. UseTableRowHeaderfor the cell that names the row — it renders<th scope='row'>, which lets a screen reader read "ARN-4821, District, Pune" instead of "Pune".- Give the table a
TableCaptionwhenever the surrounding heading does not already name it. The caption is read before the data. - Selection is announced through
aria-selected; do not rely on the fill alone. - The scroll container is focusable, so a table wider than the viewport can be scrolled with the keyboard.
API Reference
Table
Renders <table> inside a scrolling <div>. Accepts every <table> prop.
TableRow
| Prop | Type | Default | Description |
|---|---|---|---|
selected | boolean | false | Fills the row with primary-50 and sets aria-selected |
TableHead
| Prop | Type | Default | Description |
|---|---|---|---|
scope | 'col' | 'row' | 'col' | Which cells this header labels |
TableHeader, TableBody, TableFooter, TableCell, TableRowHeader and TableCaption accept the props of the element they render (thead, tbody, tfoot, td, th, caption).