IndiaCN UI

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.json
import {
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

Recent applications by district.
ReferenceApplicantSchemeDistrict

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

TableDefault
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.

SelectReferenceApplicantStatus

ARN-4821

Aarav Sharma

Approved

ARN-4822

Priya Nair

In review

ARN-4823

Rohan Gupta

Rejected

ARN-4824

Meera Menon

Approved

TableSelectable
'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.

OfficerCentreRatingAction

VMVihaan Mehta

Pune East

ARAnanya Rao

Nashik City

KMKaran Malhotra

Thane West

3 officers

Average 4.0

TableRichCells
'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

  • TableHead defaults to scope='col'. Use TableRowHeader for 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 TableCaption whenever 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

PropTypeDefaultDescription
selectedbooleanfalseFills the row with primary-50 and sets aria-selected

TableHead

PropTypeDefaultDescription
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).

On this page