Shadcn Data Table
React data table component with sorting, filtering, pagination, and row selection. Built with TypeScript and Tailwind CSS for Next.js using TanStack Table.
Data table not cooperating?
Join our Discord community for help from other developers.
Ever built an admin dashboard where users complained they couldn't sort the user list? Or watched someone give up on finding data because your table had 500 rows and no search? Yeah, basic HTML tables don't cut it for real applications. This shadcn/ui data table brings spreadsheet-like power to your React apps without the complexity.
Data table showcase
Tables that actually help users find what they need:
"use client"import * as React from "react"import { ColumnDef, ColumnFiltersState, flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, SortingState, useReactTable, VisibilityState,} from "@tanstack/react-table"import { ArrowUpDown, ChevronDown, MoreHorizontal } from "lucide-react"import { Button } from "@/components/ui/button"import { Checkbox } from "@/components/ui/checkbox"import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/components/ui/dropdown-menu"import { Input } from "@/components/ui/input"import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"const data: Payment[] = [ { id: "m5gr84i9", amount: 316, status: "success", email: "[email protected]", }, { id: "3u1reuv4", amount: 242, status: "success", email: "[email protected]", }, { id: "derv1ws0", amount: 837, status: "processing", email: "[email protected]", }, { id: "5kma53ae", amount: 874, status: "success", email: "[email protected]", }, { id: "bhqecj4p", amount: 721, status: "failed", email: "[email protected]", },]export type Payment = { id: string amount: number status: "pending" | "processing" | "success" | "failed" email: string}export const columns: ColumnDef<Payment>[] = [ { id: "select", header: ({ table }) => ( <Checkbox checked={ table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate") } onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} aria-label="Select all" /> ), cell: ({ row }) => ( <Checkbox checked={row.getIsSelected()} onCheckedChange={(value) => row.toggleSelected(!!value)} aria-label="Select row" /> ), enableSorting: false, enableHiding: false, }, { accessorKey: "status", header: "Status", cell: ({ row }) => ( <div className="capitalize">{row.getValue("status")}</div> ), }, { accessorKey: "email", header: ({ column }) => { return ( <Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} > Email <ArrowUpDown /> </Button> ) }, cell: ({ row }) => <div className="lowercase">{row.getValue("email")}</div>, }, { accessorKey: "amount", header: () => <div className="text-right">Amount</div>, cell: ({ row }) => { const amount = parseFloat(row.getValue("amount")) // Format the amount as a dollar amount const formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }).format(amount) return <div className="text-right font-medium">{formatted}</div> }, }, { id: "actions", enableHiding: false, cell: ({ row }) => { const payment = row.original return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" className="h-8 w-8 p-0"> <span className="sr-only">Open menu</span> <MoreHorizontal /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuItem onClick={() => navigator.clipboard.writeText(payment.id)} > Copy payment ID </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem>View customer</DropdownMenuItem> <DropdownMenuItem>View payment details</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ) }, },]export default function DataTableDemo() { const [sorting, setSorting] = React.useState<SortingState>([]) const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>( [] ) const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({}) const [rowSelection, setRowSelection] = React.useState({}) const table = useReactTable({ data, columns, onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), onColumnVisibilityChange: setColumnVisibility, onRowSelectionChange: setRowSelection, state: { sorting, columnFilters, columnVisibility, rowSelection, }, }) return ( <div className="w-full p-6"> <div className="flex items-center py-4"> <Input placeholder="Filter emails..." value={(table.getColumn("email")?.getFilterValue() as string) ?? ""} onChange={(event) => table.getColumn("email")?.setFilterValue(event.target.value) } className="max-w-sm" /> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" className="ml-auto"> Columns <ChevronDown /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> {table .getAllColumns() .filter((column) => column.getCanHide()) .map((column) => { return ( <DropdownMenuCheckboxItem key={column.id} className="capitalize" checked={column.getIsVisible()} onCheckedChange={(value) => column.toggleVisibility(!!value) } > {column.id} </DropdownMenuCheckboxItem> ) })} </DropdownMenuContent> </DropdownMenu> </div> <div className="overflow-hidden rounded-md border"> <Table> <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => { return ( <TableHead key={header.id}> {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} </TableHead> ) })} </TableRow> ))} </TableHeader> <TableBody> {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( <TableRow key={row.id} data-state={row.getIsSelected() && "selected"} > {row.getVisibleCells().map((cell) => ( <TableCell key={cell.id}> {flexRender( cell.column.columnDef.cell, cell.getContext() )} </TableCell> ))} </TableRow> )) ) : ( <TableRow> <TableCell colSpan={columns.length} className="h-24 text-center" > No results. </TableCell> </TableRow> )} </TableBody> </Table> </div> <div className="flex items-center justify-end space-x-2 py-4"> <div className="text-muted-foreground flex-1 text-sm"> {table.getFilteredSelectedRowModel().rows.length} of{" "} {table.getFilteredRowModel().rows.length} row(s) selected. </div> <div className="space-x-2"> <Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} > Previous </Button> <Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} > Next </Button> </div> </div> </div> )}
import * as React from "react"import { Slot } from "@radix-ui/react-slot"import { cva, type VariantProps } from "class-variance-authority"import { cn } from "@/lib/utils"const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", icon: "size-9", }, }, defaultVariants: { variant: "default", size: "default", }, })function Button({ className, variant, size, asChild = false, ...props}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { asChild?: boolean }) { const Comp = asChild ? Slot : "button" return ( <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} /> )}export { Button, buttonVariants }
Built on TanStack Table—the same library powering tables at Linear, Notion, and other data-heavy applications. Styled with Tailwind CSS so it matches your design system instead of looking like a 2005 DataGrid.
npx shadcn@latest add table
npm install @tanstack/react-table
Why data tables actually solve user problems
Here's the thing—users don't want to see your data. They want to find specific information, compare values, and take actions. A basic table just dumps everything and hopes users figure it out. Smart tables help users work with data effectively.
Think about how you use GitHub's repository list or Gmail's inbox. You sort by date, filter by status, search for keywords. That's not fancy features—that's basic usability for any data-heavy interface. Users bring these expectations to your app.
This free shadcn data table handles the complex parts—sorting algorithms, filter logic, pagination math, keyboard navigation—while you focus on what actions users need to take. Whether you're building admin panels, analytics dashboards, or user management interfaces in your Next.js applications, tables that respond instantly make users more productive in your JavaScript projects.
Common data table patterns you'll actually use
Basic data display
Clean tabular data without the complexity:
"use client"import * as React from "react"import { ColumnDef, flexRender, getCoreRowModel, useReactTable,} from "@tanstack/react-table"import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"const data: Payment[] = [ { id: "728ed52f", amount: 100, status: "pending", email: "[email protected]", }, { id: "489e1d42", amount: 125, status: "processing", email: "[email protected]", }, { id: "7c8f3a9b", amount: 75, status: "success", email: "[email protected]", },]export type Payment = { id: string amount: number status: "pending" | "processing" | "success" | "failed" email: string}export const columns: ColumnDef<Payment>[] = [ { accessorKey: "status", header: "Status", }, { accessorKey: "email", header: "Email", }, { accessorKey: "amount", header: "Amount", },]interface DataTableProps<TData, TValue> { columns: ColumnDef<TData, TValue>[] data: TData[]}function DataTable<TData, TValue>({ columns, data,}: DataTableProps<TData, TValue>) { const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), }) return ( <div className="overflow-hidden rounded-md border"> <Table> <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => { return ( <TableHead key={header.id}> {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} </TableHead> ) })} </TableRow> ))} </TableHeader> <TableBody> {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( <TableRow key={row.id} data-state={row.getIsSelected() && "selected"} > {row.getVisibleCells().map((cell) => ( <TableCell key={cell.id}> {flexRender(cell.column.columnDef.cell, cell.getContext())} </TableCell> ))} </TableRow> )) ) : ( <TableRow> <TableCell colSpan={columns.length} className="h-24 text-center"> No results. </TableCell> </TableRow> )} </TableBody> </Table> </div> )}export default function DataTableSimple() { return ( <div className="w-full p-6"> <DataTable columns={columns} data={data} /> </div> )}
"use client"import * as React from "react"import { cn } from "@/lib/utils"function Table({ className, ...props }: React.ComponentProps<"table">) { return ( <div data-slot="table-container" className="relative w-full overflow-x-auto" > <table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} /> </div> )}function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { return ( <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} /> )}function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { return ( <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} /> )}function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { return ( <tfoot data-slot="table-footer" className={cn( "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className )} {...props} /> )}function TableRow({ className, ...props }: React.ComponentProps<"tr">) { return ( <tr data-slot="table-row" className={cn( "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", className )} {...props} /> )}function TableHead({ className, ...props }: React.ComponentProps<"th">) { return ( <th data-slot="table-head" className={cn( "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className )} {...props} /> )}function TableCell({ className, ...props }: React.ComponentProps<"td">) { return ( <td data-slot="table-cell" className={cn( "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className )} {...props} /> )}function TableCaption({ className, ...props}: React.ComponentProps<"caption">) { return ( <caption data-slot="table-caption" className={cn("text-muted-foreground mt-4 text-sm", className)} {...props} /> )}export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption,}
Sortable columns
Click any header to sort—exactly what users expect:
"use client"import * as React from "react"import { ColumnDef, SortingState, flexRender, getCoreRowModel, getSortedRowModel, useReactTable,} from "@tanstack/react-table"import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react"import { Button } from "@/components/ui/button"import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"const data: Payment[] = [ { id: "728ed52f", amount: 100, status: "pending", email: "[email protected]", }, { id: "489e1d42", amount: 125, status: "processing", email: "[email protected]", }, { id: "7c8f3a9b", amount: 75, status: "success", email: "[email protected]", }, { id: "a1b2c3d4", amount: 200, status: "failed", email: "[email protected]", }, { id: "e5f6g7h8", amount: 50, status: "success", email: "[email protected]", },]export type Payment = { id: string amount: number status: "pending" | "processing" | "success" | "failed" email: string}export const columns: ColumnDef<Payment>[] = [ { accessorKey: "status", header: ({ column }) => { return ( <Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} > Status {column.getIsSorted() === "asc" ? ( <ArrowUp className="ml-2 h-4 w-4" /> ) : column.getIsSorted() === "desc" ? ( <ArrowDown className="ml-2 h-4 w-4" /> ) : ( <ArrowUpDown className="ml-2 h-4 w-4" /> )} </Button> ) }, cell: ({ row }) => ( <div className="capitalize">{row.getValue("status")}</div> ), }, { accessorKey: "email", header: ({ column }) => { return ( <Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} > Email {column.getIsSorted() === "asc" ? ( <ArrowUp className="ml-2 h-4 w-4" /> ) : column.getIsSorted() === "desc" ? ( <ArrowDown className="ml-2 h-4 w-4" /> ) : ( <ArrowUpDown className="ml-2 h-4 w-4" /> )} </Button> ) }, cell: ({ row }) => <div className="lowercase">{row.getValue("email")}</div>, }, { accessorKey: "amount", header: ({ column }) => { return ( <Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} > Amount {column.getIsSorted() === "asc" ? ( <ArrowUp className="ml-2 h-4 w-4" /> ) : column.getIsSorted() === "desc" ? ( <ArrowDown className="ml-2 h-4 w-4" /> ) : ( <ArrowUpDown className="ml-2 h-4 w-4" /> )} </Button> ) }, cell: ({ row }) => { const amount = parseFloat(row.getValue("amount")) const formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }).format(amount) return <div className="text-right font-medium">{formatted}</div> }, },]export default function DataTableSorting() { const [sorting, setSorting] = React.useState<SortingState>([]) const table = useReactTable({ data, columns, onSortingChange: setSorting, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), state: { sorting, }, }) return ( <div className="w-full p-6"> <div className="overflow-hidden rounded-md border"> <Table> <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => { return ( <TableHead key={header.id}> {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} </TableHead> ) })} </TableRow> ))} </TableHeader> <TableBody> {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( <TableRow key={row.id} data-state={row.getIsSelected() && "selected"} > {row.getVisibleCells().map((cell) => ( <TableCell key={cell.id}> {flexRender( cell.column.columnDef.cell, cell.getContext() )} </TableCell> ))} </TableRow> )) ) : ( <TableRow> <TableCell colSpan={columns.length} className="h-24 text-center" > No results. </TableCell> </TableRow> )} </TableBody> </Table> </div> </div> )}
import * as React from "react"import { Slot } from "@radix-ui/react-slot"import { cva, type VariantProps } from "class-variance-authority"import { cn } from "@/lib/utils"const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", icon: "size-9", }, }, defaultVariants: { variant: "default", size: "default", }, })function Button({ className, variant, size, asChild = false, ...props}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { asChild?: boolean }) { const Comp = asChild ? Slot : "button" return ( <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} /> )}export { Button, buttonVariants }
Search and filter
Users need to find specific rows quickly:
"use client"import * as React from "react"import { ColumnDef, ColumnFiltersState, flexRender, getCoreRowModel, getFilteredRowModel, useReactTable,} from "@tanstack/react-table"import { Input } from "@/components/ui/input"import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"const data: Payment[] = [ { id: "728ed52f", amount: 100, status: "pending", email: "[email protected]", }, { id: "489e1d42", amount: 125, status: "processing", email: "[email protected]", }, { id: "7c8f3a9b", amount: 75, status: "success", email: "[email protected]", }, { id: "a1b2c3d4", amount: 200, status: "failed", email: "[email protected]", }, { id: "e5f6g7h8", amount: 50, status: "success", email: "[email protected]", }, { id: "i9j0k1l2", amount: 300, status: "pending", email: "[email protected]", },]export type Payment = { id: string amount: number status: "pending" | "processing" | "success" | "failed" email: string}export const columns: ColumnDef<Payment>[] = [ { accessorKey: "status", header: "Status", cell: ({ row }) => ( <div className="capitalize">{row.getValue("status")}</div> ), }, { accessorKey: "email", header: "Email", cell: ({ row }) => <div className="lowercase">{row.getValue("email")}</div>, }, { accessorKey: "amount", header: () => <div className="text-right">Amount</div>, cell: ({ row }) => { const amount = parseFloat(row.getValue("amount")) const formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }).format(amount) return <div className="text-right font-medium">{formatted}</div> }, },]export default function DataTableFiltering() { const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>( [] ) const table = useReactTable({ data, columns, onColumnFiltersChange: setColumnFilters, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), state: { columnFilters, }, }) return ( <div className="w-full p-6"> <div className="flex items-center py-4 space-x-4"> <Input placeholder="Filter emails..." value={(table.getColumn("email")?.getFilterValue() as string) ?? ""} onChange={(event) => table.getColumn("email")?.setFilterValue(event.target.value) } className="max-w-sm" /> <Input placeholder="Filter status..." value={(table.getColumn("status")?.getFilterValue() as string) ?? ""} onChange={(event) => table.getColumn("status")?.setFilterValue(event.target.value) } className="max-w-sm" /> </div> <div className="overflow-hidden rounded-md border"> <Table> <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => { return ( <TableHead key={header.id}> {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} </TableHead> ) })} </TableRow> ))} </TableHeader> <TableBody> {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( <TableRow key={row.id} data-state={row.getIsSelected() && "selected"} > {row.getVisibleCells().map((cell) => ( <TableCell key={cell.id}> {flexRender( cell.column.columnDef.cell, cell.getContext() )} </TableCell> ))} </TableRow> )) ) : ( <TableRow> <TableCell colSpan={columns.length} className="h-24 text-center" > No results. </TableCell> </TableRow> )} </TableBody> </Table> </div> <div className="flex items-center justify-end space-x-2 py-4"> <div className="text-muted-foreground text-sm"> {table.getFilteredRowModel().rows.length} of{" "} {data.length} row(s) showing. </div> </div> </div> )}
import * as React from "react"import { cn } from "@/lib/utils"function Input({ className, type, ...props }: React.ComponentProps<"input">) { return ( <input type={type} data-slot="input" className={cn( "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className )} {...props} /> )}export { Input }
Pagination for large datasets
Keep things fast and manageable:
"use client"import * as React from "react"import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, useReactTable,} from "@tanstack/react-table"import { Button } from "@/components/ui/button"import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"// Generate more data for pagination democonst generateData = (count: number): Payment[] => { const statuses = ["pending", "processing", "success", "failed"] as const const domains = ["example.com", "gmail.com", "yahoo.com", "hotmail.com"] return Array.from({ length: count }, (_, i) => ({ id: `payment-${i + 1}`, amount: Math.floor(Math.random() * 1000) + 50, status: statuses[Math.floor(Math.random() * statuses.length)], email: `user${i + 1}@${domains[Math.floor(Math.random() * domains.length)]}`, }))}const data: Payment[] = generateData(50)export type Payment = { id: string amount: number status: "pending" | "processing" | "success" | "failed" email: string}export const columns: ColumnDef<Payment>[] = [ { accessorKey: "status", header: "Status", cell: ({ row }) => ( <div className="capitalize">{row.getValue("status")}</div> ), }, { accessorKey: "email", header: "Email", cell: ({ row }) => <div className="lowercase">{row.getValue("email")}</div>, }, { accessorKey: "amount", header: () => <div className="text-right">Amount</div>, cell: ({ row }) => { const amount = parseFloat(row.getValue("amount")) const formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }).format(amount) return <div className="text-right font-medium">{formatted}</div> }, },]export default function DataTablePagination() { const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), initialState: { pagination: { pageSize: 5, }, }, }) return ( <div className="w-full p-6"> <div className="overflow-hidden rounded-md border"> <Table> <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => { return ( <TableHead key={header.id}> {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} </TableHead> ) })} </TableRow> ))} </TableHeader> <TableBody> {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( <TableRow key={row.id} data-state={row.getIsSelected() && "selected"} > {row.getVisibleCells().map((cell) => ( <TableCell key={cell.id}> {flexRender( cell.column.columnDef.cell, cell.getContext() )} </TableCell> ))} </TableRow> )) ) : ( <TableRow> <TableCell colSpan={columns.length} className="h-24 text-center" > No results. </TableCell> </TableRow> )} </TableBody> </Table> </div> <div className="flex items-center justify-between space-x-2 py-4"> <div className="flex items-center space-x-2"> <p className="text-sm font-medium">Rows per page</p> <select value={table.getState().pagination.pageSize} onChange={(e) => { table.setPageSize(Number(e.target.value)) }} className="h-8 w-[70px] rounded border border-input bg-background px-3 py-1 text-sm" > {[5, 10, 20, 30, 40].map((pageSize) => ( <option key={pageSize} value={pageSize}> {pageSize} </option> ))} </select> </div> <div className="flex items-center space-x-6 lg:space-x-8"> <div className="flex w-[100px] items-center justify-center text-sm font-medium"> Page {table.getState().pagination.pageIndex + 1} of{" "} {table.getPageCount()} </div> <div className="flex items-center space-x-2"> <Button variant="outline" size="sm" onClick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()} > First </Button> <Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} > Previous </Button> <Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} > Next </Button> <Button variant="outline" size="sm" onClick={() => table.setPageIndex(table.getPageCount() - 1)} disabled={!table.getCanNextPage()} > Last </Button> </div> </div> </div> </div> )}
import * as React from "react"import { Slot } from "@radix-ui/react-slot"import { cva, type VariantProps } from "class-variance-authority"import { cn } from "@/lib/utils"const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", icon: "size-9", }, }, defaultVariants: { variant: "default", size: "default", }, })function Button({ className, variant, size, asChild = false, ...props}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { asChild?: boolean }) { const Comp = asChild ? Slot : "button" return ( <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} /> )}export { Button, buttonVariants }
Multi-select with checkboxes
Bulk actions require row selection:
"use client"import * as React from "react"import { ColumnDef, flexRender, getCoreRowModel, useReactTable, RowSelectionState,} from "@tanstack/react-table"import { Button } from "@/components/ui/button"import { Checkbox } from "@/components/ui/checkbox"import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"import { Badge } from "@/components/ui/badge"const data: Payment[] = [ { id: "728ed52f", amount: 100, status: "pending", email: "[email protected]", }, { id: "489e1d42", amount: 125, status: "processing", email: "[email protected]", }, { id: "7c8f3a9b", amount: 75, status: "success", email: "[email protected]", }, { id: "a1b2c3d4", amount: 200, status: "failed", email: "[email protected]", }, { id: "e5f6g7h8", amount: 50, status: "success", email: "[email protected]", },]export type Payment = { id: string amount: number status: "pending" | "processing" | "success" | "failed" email: string}const getStatusColor = (status: string) => { switch (status) { case "success": return "default" case "processing": return "secondary" case "pending": return "outline" case "failed": return "destructive" default: return "outline" }}export const columns: ColumnDef<Payment>[] = [ { id: "select", header: ({ table }) => ( <Checkbox checked={ table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate") } onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} aria-label="Select all" /> ), cell: ({ row }) => ( <Checkbox checked={row.getIsSelected()} onCheckedChange={(value) => row.toggleSelected(!!value)} aria-label="Select row" /> ), enableSorting: false, enableHiding: false, }, { accessorKey: "status", header: "Status", cell: ({ row }) => { const status = row.getValue("status") as string return ( <Badge variant={getStatusColor(status)}> {status} </Badge> ) }, }, { accessorKey: "email", header: "Email", cell: ({ row }) => <div className="lowercase">{row.getValue("email")}</div>, }, { accessorKey: "amount", header: () => <div className="text-right">Amount</div>, cell: ({ row }) => { const amount = parseFloat(row.getValue("amount")) const formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }).format(amount) return <div className="text-right font-medium">{formatted}</div> }, },]export default function DataTableRowSelection() { const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({}) const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), onRowSelectionChange: setRowSelection, state: { rowSelection, }, }) const selectedRows = table.getFilteredSelectedRowModel().rows const totalAmount = selectedRows.reduce((sum, row) => { return sum + (row.original.amount || 0) }, 0) return ( <div className="w-full p-6"> {selectedRows.length > 0 && ( <div className="flex items-center justify-between rounded-md border border-dashed p-4 mb-4"> <div className="flex items-center space-x-4"> <p className="text-sm font-medium"> {selectedRows.length} row(s) selected </p> <p className="text-sm text-muted-foreground"> Total: {new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }).format(totalAmount)} </p> </div> <div className="flex items-center space-x-2"> <Button variant="outline" size="sm" onClick={() => { // Handle bulk action console.log('Processing selected payments:', selectedRows.map(row => row.original)) }} > Process Selected </Button> <Button variant="outline" size="sm" onClick={() => setRowSelection({})} > Clear Selection </Button> </div> </div> )} <div className="overflow-hidden rounded-md border"> <Table> <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => { return ( <TableHead key={header.id}> {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} </TableHead> ) })} </TableRow> ))} </TableHeader> <TableBody> {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( <TableRow key={row.id} data-state={row.getIsSelected() && "selected"} > {row.getVisibleCells().map((cell) => ( <TableCell key={cell.id}> {flexRender( cell.column.columnDef.cell, cell.getContext() )} </TableCell> ))} </TableRow> )) ) : ( <TableRow> <TableCell colSpan={columns.length} className="h-24 text-center" > No results. </TableCell> </TableRow> )} </TableBody> </Table> </div> <div className="flex items-center justify-end space-x-2 py-4"> <div className="text-muted-foreground text-sm"> {selectedRows.length} of {data.length} row(s) selected. </div> </div> </div> )}
import * as React from "react"import { Slot } from "@radix-ui/react-slot"import { cva, type VariantProps } from "class-variance-authority"import { cn } from "@/lib/utils"const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", icon: "size-9", }, }, defaultVariants: { variant: "default", size: "default", }, })function Button({ className, variant, size, asChild = false, ...props}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { asChild?: boolean }) { const Comp = asChild ? Slot : "button" return ( <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} /> )}export { Button, buttonVariants }
Column visibility controls
Let users customize their view:
"use client"import * as React from "react"import { ColumnDef, VisibilityState, flexRender, getCoreRowModel, useReactTable,} from "@tanstack/react-table"import { ChevronDown, Eye, EyeOff } from "lucide-react"import { Button } from "@/components/ui/button"import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/components/ui/dropdown-menu"import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"import { Badge } from "@/components/ui/badge"const data: Payment[] = [ { id: "728ed52f", amount: 100, status: "pending", email: "[email protected]", customer: "John Doe", date: "2024-01-15", }, { id: "489e1d42", amount: 125, status: "processing", email: "[email protected]", customer: "Jane Smith", date: "2024-01-16", }, { id: "7c8f3a9b", amount: 75, status: "success", email: "[email protected]", customer: "Bob Johnson", date: "2024-01-17", }, { id: "a1b2c3d4", amount: 200, status: "failed", email: "[email protected]", customer: "Alice Brown", date: "2024-01-18", },]export type Payment = { id: string amount: number status: "pending" | "processing" | "success" | "failed" email: string customer: string date: string}const getStatusColor = (status: string) => { switch (status) { case "success": return "default" case "processing": return "secondary" case "pending": return "outline" case "failed": return "destructive" default: return "outline" }}export const columns: ColumnDef<Payment>[] = [ { accessorKey: "id", header: "ID", cell: ({ row }) => ( <div className="font-mono text-xs">{row.getValue("id")}</div> ), }, { accessorKey: "customer", header: "Customer", cell: ({ row }) => <div className="font-medium">{row.getValue("customer")}</div>, }, { accessorKey: "email", header: "Email", cell: ({ row }) => <div className="lowercase">{row.getValue("email")}</div>, }, { accessorKey: "status", header: "Status", cell: ({ row }) => { const status = row.getValue("status") as string return ( <Badge variant={getStatusColor(status)}> {status} </Badge> ) }, }, { accessorKey: "date", header: "Date", cell: ({ row }) => { const date = new Date(row.getValue("date")) return date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", }) }, }, { accessorKey: "amount", header: () => <div className="text-right">Amount</div>, cell: ({ row }) => { const amount = parseFloat(row.getValue("amount")) const formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }).format(amount) return <div className="text-right font-medium">{formatted}</div> }, },]export default function DataTableColumnVisibility() { const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({ id: false, // Hide ID column by default }) const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), onColumnVisibilityChange: setColumnVisibility, state: { columnVisibility, }, }) const visibleColumns = table.getAllColumns().filter(column => column.getIsVisible()) const hiddenColumns = table.getAllColumns().filter(column => !column.getIsVisible()) return ( <div className="w-full p-6"> <div className="flex items-center py-4"> <div className="flex items-center space-x-2"> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" className="ml-auto"> <Eye className="mr-2 h-4 w-4" /> View <ChevronDown className="ml-2 h-4 w-4" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-[200px]"> <DropdownMenuLabel>Toggle columns</DropdownMenuLabel> <DropdownMenuSeparator /> {table .getAllColumns() .filter((column) => column.getCanHide()) .map((column) => { return ( <DropdownMenuCheckboxItem key={column.id} className="capitalize" checked={column.getIsVisible()} onCheckedChange={(value) => column.toggleVisibility(!!value) } > <div className="flex items-center space-x-2"> {column.getIsVisible() ? ( <Eye className="h-4 w-4" /> ) : ( <EyeOff className="h-4 w-4" /> )} <span>{column.id}</span> </div> </DropdownMenuCheckboxItem> ) })} </DropdownMenuContent> </DropdownMenu> </div> <div className="flex-1" /> <div className="flex items-center space-x-4 text-sm text-muted-foreground"> <span>{visibleColumns.length} visible</span> <span>{hiddenColumns.length} hidden</span> </div> </div> <div className="overflow-hidden rounded-md border"> <Table> <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => { return ( <TableHead key={header.id}> {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} </TableHead> ) })} </TableRow> ))} </TableHeader> <TableBody> {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( <TableRow key={row.id} data-state={row.getIsSelected() && "selected"} > {row.getVisibleCells().map((cell) => ( <TableCell key={cell.id}> {flexRender( cell.column.columnDef.cell, cell.getContext() )} </TableCell> ))} </TableRow> )) ) : ( <TableRow> <TableCell colSpan={columns.length} className="h-24 text-center" > No results. </TableCell> </TableRow> )} </TableBody> </Table> </div> </div> )}
import * as React from "react"import { Slot } from "@radix-ui/react-slot"import { cva, type VariantProps } from "class-variance-authority"import { cn } from "@/lib/utils"const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", icon: "size-9", }, }, defaultVariants: { variant: "default", size: "default", }, })function Button({ className, variant, size, asChild = false, ...props}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { asChild?: boolean }) { const Comp = asChild ? Slot : "button" return ( <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} /> )}export { Button, buttonVariants }
Features
This free open source data table component includes everything you need:
- TypeScript-first - Full type safety with TanStack Table integration
- TanStack Table powered - Battle-tested sorting, filtering, and pagination logic
- Flexible columns - Custom cells, sorting, filtering, and visibility controls
- Tailwind CSS styled - Customize with utilities, not fighting component CSS
- Keyboard accessible - Tab navigation, Enter to sort, proper ARIA labels
- Mobile responsive - Horizontal scroll and adaptive column display
- Row selection - Single and multi-select with checkbox controls
- Performance optimized - Virtual scrolling for large datasets
API Reference
Core Components
Component | Purpose | Key Props |
---|---|---|
Table | Main container | className for responsive styling |
TableHeader | Column headers | Groups header rows |
TableBody | Data rows | Contains all table content |
TableRow | Individual row | className for selection states |
TableHead | Header cell | className for sorting indicators |
TableCell | Data cell | Custom content rendering |
TanStack Integration
Hook | Purpose | Key Props |
---|---|---|
useReactTable | Table instance | data , columns , row models |
getCoreRowModel | Basic functionality | Always required |
getSortedRowModel | Column sorting | Enables clickable headers |
getFilteredRowModel | Search/filter | Global and column filters |
getPaginationRowModel | Page navigation | Handles large datasets |
Column Configuration
const columns: ColumnDef<DataType>[] = [
{
accessorKey: "field", // Data property
header: "Display Name", // Column title
cell: ({ row }) => <CustomCell />, // Custom rendering
enableSorting: true, // Sortable column
enableHiding: true, // Show/hide toggle
},
]
Production tips
Handle empty states gracefully. This free shadcn/ui data table works beautifully with data, but users need to know when there's nothing to show. Add helpful messages like "No users found" or "Try adjusting your filters." Your React component renders the empty state—you provide the context that actually helps users.
Test with realistic data volumes. Those neat demos with 10 rows work great until you have 10,000 customer records. Performance changes dramatically with large datasets. This TypeScript component handles pagination efficiently, but test with production data sizes in your Next.js applications.
Make sorting discoverable. Users expect clickable column headers, but they need visual hints. Add chevron icons or hover states to show what's sortable. This open source shadcn component provides the sorting logic—you provide the visual cues that guide users.
Keep column headers scannable. "Email Address" beats "Email", "Created Date" beats "Date". Users scan headers quickly to understand your data structure. Your JavaScript data table should use descriptive labels that make sense without context.
Consider mobile carefully. Tables get cramped on small screens. Maybe show fewer columns by default, or switch to a card layout below certain breakpoints. This Tailwind CSS component is responsive, but you need to decide what information mobile users actually need.
Integration with other components
Data tables naturally work with Button components for actions like "Add User" or "Export Data" above the table. Use Dialog components for edit forms triggered by row actions.
For filtering interfaces, combine with Input components for search fields and Select components for dropdown filters. This open source pattern keeps filter controls separate from table logic while maintaining clear data flow.
When building admin interfaces, pair data tables with Badge components to show status indicators in cells. Checkbox components handle row selection naturally. Your React application benefits from consistent component patterns across different data views.
For complex data operations, use data tables with Sheet components for detailed row editing or Popover components for quick actions. The table remains the source of truth while related components handle specific interactions.
Questions you might have
Shadcn Context Menu
React context menu component for right-click actions and submenus. Built with TypeScript and Tailwind CSS for Next.js applications using Radix UI primitives.
Shadcn Date Picker
React date picker component with calendar popup and keyboard navigation. Built with TypeScript and Tailwind CSS for Next.js using Popover and Calendar.