# Table **Category**: react **URL**: https://www.blakeui.com/en/docs/react/components/table **Source**: https://raw.githubusercontent.com/myblakebox/BlakeUI/refs/heads/main/apps/docs/content/docs/en/react/components/(data-display)/table.mdx > Tables display structured data in rows and columns with support for sorting, selection, column resizing, and infinite scrolling. *** ## Import ```tsx import { Table } from '@blakeui/react'; ``` ### Usage ```tsx import {Table} from "@blakeui/react"; export function Basic() { return ( Name Role Status Email Kate Moore CEO Active kate@acme.com John Smith CTO Active john@acme.com Sara Johnson CMO On Leave sara@acme.com Michael Brown CFO Active michael@acme.com
); } ``` ### Anatomy Import the Table component and access all parts using dot notation. ```tsx import { Table } from '@blakeui/react'; export default () => ( Name Role Kate Moore CEO {/* Optional footer content */}
); ``` ### Secondary Variant ```tsx import {Table} from "@blakeui/react"; export function SecondaryVariant() { return ( Name Role Status Email Kate Moore CEO Active kate@acme.com John Smith CTO Active john@acme.com Sara Johnson CMO On Leave sara@acme.com Michael Brown CFO Active michael@acme.com
); } ``` ### Sorting Columns can be made sortable using the `allowsSorting` prop on `Table.Column`. Use `sortDescriptor` and `onSortChange` on `Table.Content` to manage sort state. ```tsx "use client"; import type {SortDescriptor} from "@blakeui/react"; import {Table, cn} from "@blakeui/react"; import {Icon} from "@iconify/react"; import {useMemo, useState} from "react"; interface User { id: number; name: string; role: string; status: string; email: string; } const users: User[] = [ {email: "kate@acme.com", id: 1, name: "Kate Moore", role: "CEO", status: "Active"}, {email: "john@acme.com", id: 2, name: "John Smith", role: "CTO", status: "Active"}, {email: "sara@acme.com", id: 3, name: "Sara Johnson", role: "CMO", status: "On Leave"}, {email: "michael@acme.com", id: 4, name: "Michael Brown", role: "CFO", status: "Active"}, { email: "emily@acme.com", id: 5, name: "Emily Davis", role: "Product Manager", status: "Inactive", }, ]; function SortableColumnHeader({ children, sortDirection, }: { children: React.ReactNode; sortDirection?: "ascending" | "descending"; }) { return ( {children} {!!sortDirection && ( )} ); } export function Sorting() { const [sortDescriptor, setSortDescriptor] = useState({ column: "name", direction: "ascending", }); const sortedUsers = useMemo(() => { return [...users].sort((a, b) => { const col = sortDescriptor.column as keyof User; const first = String(a[col]); const second = String(b[col]); let cmp = first.localeCompare(second); if (sortDescriptor.direction === "descending") { cmp *= -1; } return cmp; }); }, [sortDescriptor]); return ( {({sortDirection}) => ( Name )} {({sortDirection}) => ( Role )} {({sortDirection}) => ( Status )} {({sortDirection}) => ( Email )} {sortedUsers.map((user) => ( {user.name} {user.role} {user.status} {user.email} ))}
); } ``` ### Selection Enable row selection with `selectionMode` on `Table.Content`. Use `Checkbox` with `slot="selection"` for select-all and per-row checkboxes. ```tsx "use client"; import type {Selection} from "@blakeui/react"; import {Checkbox, Table} from "@blakeui/react"; import {useState} from "react"; const users = [ {email: "kate@acme.com", id: 1, name: "Kate Moore", role: "CEO", status: "Active"}, {email: "john@acme.com", id: 2, name: "John Smith", role: "CTO", status: "Active"}, {email: "sara@acme.com", id: 3, name: "Sara Johnson", role: "CMO", status: "On Leave"}, {email: "michael@acme.com", id: 4, name: "Michael Brown", role: "CFO", status: "Active"}, ]; export function SelectionDemo() { const [selectedKeys, setSelectedKeys] = useState(new Set()); return (
Name Role Status Email {users.map((user) => ( {user.name} {user.role} {user.status} {user.email} ))}

Selected:{" "} {selectedKeys === "all" ? "All" : selectedKeys.size > 0 ? Array.from(selectedKeys).join(", ") : "None"}

); } ``` ### Custom Cells ```tsx "use client"; import type {Selection, SortDescriptor} from "@blakeui/react"; import {Avatar, Button, Checkbox, Chip, Table, cn} from "@blakeui/react"; import {Icon} from "@iconify/react"; import {useMemo, useState} from "react"; interface User { id: number; name: string; image_url: string; role: string; status: "Active" | "Inactive" | "On Leave"; email: string; } const statusColorMap: Record = { Active: "success", Inactive: "danger", "On Leave": "warning", }; const users: User[] = [ { email: "kate@acme.com", id: 4586932, image_url: "https://cdn.blakeui.com/avatars/red.jpg", name: "Kate Moore", role: "Chief Executive Officer", status: "Active", }, { email: "john@acme.com", id: 5273849, image_url: "https://cdn.blakeui.com/avatars/green.jpg", name: "John Smith", role: "Chief Technology Officer", status: "Active", }, { email: "sara@acme.com", id: 7492836, image_url: "https://cdn.blakeui.com/avatars/blue.jpg", name: "Sara Johnson", role: "Chief Marketing Officer", status: "On Leave", }, { email: "michael@acme.com", id: 8293746, image_url: "https://cdn.blakeui.com/avatars/purple.jpg", name: "Michael Brown", role: "Chief Financial Officer", status: "Active", }, { email: "emily@acme.com", id: 1234567, image_url: "https://cdn.blakeui.com/avatars/orange.jpg", name: "Emily Davis", role: "Product Manager", status: "Inactive", }, ]; function SortableColumnHeader({ children, sortDirection, }: { children: React.ReactNode; sortDirection?: "ascending" | "descending"; }) { return ( {children} {!!sortDirection && ( )} ); } export function CustomCells() { const [selectedKeys, setSelectedKeys] = useState(new Set()); const [sortDescriptor, setSortDescriptor] = useState({ column: "name", direction: "ascending", }); const sortedUsers = useMemo(() => { return [...users].sort((a, b) => { const col = sortDescriptor.column as keyof User; const first = String(a[col]); const second = String(b[col]); let cmp = first.localeCompare(second); if (sortDescriptor.direction === "descending") { cmp *= -1; } return cmp; }); }, [sortDescriptor]); return ( {({sortDirection}) => ( Worker ID )} {({sortDirection}) => ( Member )} {({sortDirection}) => ( Role )} {({sortDirection}) => ( Status )} Actions {sortedUsers.map((user) => (
#{user.id.toString()}{" "}
{user.name .split(" ") .map((n) => n[0]) .join("")}
{user.name} {user.email}
{user.role} {user.status}
))}
); } ``` ### Expandable Rows Rows can be nested to display hierarchical data. Use the `treeColumn` prop to designate a column, and render a `Button` with `slot="chevron"` in that column’s cells so users can expand and collapse the row. Use the `expandedKeys` prop to control which rows are expanded. ```tsx "use client"; import type {Selection} from "@blakeui/react"; import {Button, Table, cn} from "@blakeui/react"; import {Icon} from "@iconify/react"; import {useState} from "react"; export function ExpandableRows() { type Row = { children: Row[]; date: string; id: string; title: string; type: string; }; const data: Row[] = [ { children: [ { children: [ {children: [], date: "7/10/2025", id: "3", title: "Weekly Report", type: "File"}, {children: [], date: "8/20/2025", id: "4", title: "Budget", type: "File"}, ], date: "8/2/2025", id: "2", title: "Project", type: "Directory", }, ], date: "10/20/2025", id: "1", title: "Documents", type: "Directory", }, { children: [ {children: [], date: "1/23/2026", id: "6", title: "Image 1", type: "File"}, {children: [], date: "2/3/2026", id: "7", title: "Image 2", type: "File"}, ], date: "2/3/2026", id: "5", title: "Photos", type: "Directory", }, ]; const [expandedKeys, setExpandedKeys] = useState(() => new Set(["1"])); const renderExpandableRow = (item: Row) => { return ( {({hasChildItems, isDisabled, isExpanded, isTreeColumn}) => ( {hasChildItems && isTreeColumn ? ( ) : null} {item.title} )} {item.type} {item.date} {renderExpandableRow} ); }; return ( Name Type Date Modified {renderExpandableRow}
); } ``` ### Pagination Use `Table.Footer` to add a pagination component below the table. ```tsx "use client"; import {Pagination, Table} from "@blakeui/react"; import {useMemo, useState} from "react"; const columns = [ {id: "name", name: "Name"}, {id: "role", name: "Role"}, {id: "status", name: "Status"}, {id: "email", name: "Email"}, ]; const users = [ {email: "kate@acme.com", id: 1, name: "Kate Moore", role: "CEO", status: "Active"}, {email: "john@acme.com", id: 2, name: "John Smith", role: "CTO", status: "Active"}, {email: "sara@acme.com", id: 3, name: "Sara Johnson", role: "CMO", status: "On Leave"}, {email: "michael@acme.com", id: 4, name: "Michael Brown", role: "CFO", status: "Active"}, { email: "emily@acme.com", id: 5, name: "Emily Davis", role: "Product Manager", status: "Inactive", }, {email: "davis@acme.com", id: 6, name: "Davis Wilson", role: "Lead Designer", status: "Active"}, { email: "olivia@acme.com", id: 7, name: "Olivia Martinez", role: "Frontend Engineer", status: "Active", }, { email: "james@acme.com", id: 8, name: "James Taylor", role: "Backend Engineer", status: "Active", }, ]; const ROWS_PER_PAGE = 4; export function PaginationDemo() { const [page, setPage] = useState(1); const totalPages = Math.ceil(users.length / ROWS_PER_PAGE); const pages = Array.from({length: totalPages}, (_, i) => i + 1); const paginatedItems = useMemo(() => { const start = (page - 1) * ROWS_PER_PAGE; return users.slice(start, start + ROWS_PER_PAGE); }, [page]); const start = (page - 1) * ROWS_PER_PAGE + 1; const end = Math.min(page * ROWS_PER_PAGE, users.length); return ( {(column) => ( {column.name} )} {(user) => ( {(column) => {user[column.id as keyof typeof user]}} )} {start} to {end} of {users.length} results setPage((p) => Math.max(1, p - 1))} > Prev {pages.map((p) => ( setPage(p)}> {p} ))} setPage((p) => Math.min(totalPages, p + 1))} > Next
); } ``` ### Column Resizing Wrap the table in `Table.ResizableContainer` and add `Table.ColumnResizer` inside each resizable column. ```tsx import {Chip, Table} from "@blakeui/react"; export function ColumnResizing() { return ( Name Role Status Email Kate Moore CEO Active kate@acme.com John Smith CTO Active john@acme.com Sara Johnson CMO On Leave sara@acme.com Michael Brown CFO Active michael@acme.com Emily Davis Product Manager Inactive emily@acme.com
); } ``` ### Empty State Use `renderEmptyState` on `Table.Body` to display a custom message when the table has no data. ```tsx "use client"; import {EmptyState, Table} from "@blakeui/react"; import {Icon} from "@iconify/react"; export function EmptyStateDemo() { return ( Name Role Status Email ( No results found )} > {[]}
); } ``` ### Async Loading Use `Table.LoadMore` for infinite scrolling. It renders a sentinel row that triggers `onLoadMore` when scrolled into view. ```tsx "use client"; import {Chip, Spinner, Table} from "@blakeui/react"; import {useCallback, useRef, useState} from "react"; interface User { id: number; name: string; role: string; status: string; email: string; } const statusColorMap: Record = { Active: "success", Inactive: "danger", "On Leave": "warning", }; const allUsers: User[] = [ {email: "kate@acme.com", id: 1, name: "Kate Moore", role: "CEO", status: "Active"}, {email: "john@acme.com", id: 2, name: "John Smith", role: "CTO", status: "Active"}, {email: "sara@acme.com", id: 3, name: "Sara Johnson", role: "CMO", status: "On Leave"}, {email: "michael@acme.com", id: 4, name: "Michael Brown", role: "CFO", status: "Active"}, { email: "emily@acme.com", id: 5, name: "Emily Davis", role: "Product Manager", status: "Inactive", }, {email: "davis@acme.com", id: 6, name: "Davis Wilson", role: "Lead Designer", status: "Active"}, { email: "olivia@acme.com", id: 7, name: "Olivia Martinez", role: "Frontend Engineer", status: "Active", }, { email: "james@acme.com", id: 8, name: "James Taylor", role: "Backend Engineer", status: "Active", }, { email: "sophia@acme.com", id: 9, name: "Sophia Anderson", role: "QA Engineer", status: "On Leave", }, {email: "liam@acme.com", id: 10, name: "Liam Thomas", role: "DevOps Engineer", status: "Active"}, { email: "lucas@acme.com", id: 11, name: "Lucas Martinez", role: "Product Manager", status: "Active", }, { email: "emma@acme.com", id: 12, name: "Emma Johnson", role: "Frontend Engineer", status: "Active", }, {email: "noah@acme.com", id: 13, name: "Noah Davis", role: "Backend Engineer", status: "Active"}, {email: "ava@acme.com", id: 14, name: "Ava Wilson", role: "Lead Designer", status: "Active"}, { email: "oliver@acme.com", id: 15, name: "Oliver Martinez", role: "Frontend Engineer", status: "Active", }, { email: "isabella@acme.com", id: 16, name: "Isabella Johnson", role: "Backend Engineer", status: "Active", }, {email: "mia@acme.com", id: 17, name: "Mia Davis", role: "Lead Designer", status: "Active"}, { email: "william@acme.com", id: 18, name: "William Wilson", role: "Frontend Engineer", status: "Active", }, ]; const ITEMS_PER_PAGE = 6; const columns = [ {id: "name", name: "Name"}, {id: "role", name: "Role"}, {id: "status", name: "Status"}, {id: "email", name: "Email"}, ]; export function AsyncLoading() { const [items, setItems] = useState(() => allUsers.slice(0, ITEMS_PER_PAGE)); const [isLoading, setIsLoading] = useState(false); const isLoadingRef = useRef(false); const hasMore = items.length < allUsers.length; const loadMore = useCallback(() => { if (!hasMore || isLoadingRef.current) return; isLoadingRef.current = true; setIsLoading(true); setTimeout(() => { setItems((prev) => allUsers.slice(0, prev.length + ITEMS_PER_PAGE)); setIsLoading(false); requestAnimationFrame(() => { isLoadingRef.current = false; }); }, 1500); }, [hasMore]); return ( {columns.map((col) => ( {col.name} ))} {(user) => ( {user.name} {user.role} {user.status} {user.email} )} {!!hasMore && ( )}
); } ``` ### Virtualization Table supports virtualization through [Virtualizer](https://react-aria.adobe.com/Virtualizer), enabling efficient rendering of large datasets by displaying only the rows visible within the viewport. ```tsx "use client"; import {Table, TableLayout, Virtualizer} from "@blakeui/react"; interface User { id: number; name: string; role: string; email: string; } export function Virtualization() { const roles = [ "Software Engineer", "Senior Engineer", "Staff Engineer", "Product Manager", "Designer", "Data Analyst", "QA Engineer", "DevOps Engineer", "Marketing Manager", "Sales Representative", ]; const firstNames = [ "Emma", "Liam", "Olivia", "Noah", "Ava", "James", "Sophia", "Oliver", "Isabella", "Lucas", "Mia", "Ethan", "Charlotte", "Mason", "Amelia", "Logan", "Harper", "Alexander", "Ella", "Benjamin", ]; const lastNames = [ "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez", "Anderson", "Taylor", "Thomas", "Jackson", "White", "Harris", "Clark", "Lewis", "Robinson", "Walker", ]; function generateUsers(count: number): User[] { const users: User[] = []; for (let i = 0; i < count; i++) { const firstName = firstNames[i % firstNames.length]; const lastName = lastNames[Math.floor(i / firstNames.length) % lastNames.length]; const name = `${firstName} ${lastName}`; users.push({ email: `${firstName?.toLowerCase()}.${lastName?.toLowerCase()}@acme.com`, id: i + 1, name, role: roles[i % roles.length] || "", }); } return users; } const virtualizedUsers = generateUsers(1000); return ( Name Role Email {(user) => ( {user.name} {user.role} {user.email} )}
); } ``` ### TanStack Table blakeUI's Table works as a rendering layer on top of headless table libraries. This example uses [TanStack Table](https://tanstack.com/table) for column definitions, sorting, and pagination — while blakeUI handles styling and accessibility. ```tsx "use client"; import type {SortDescriptor} from "@blakeui/react"; import type {SortingState} from "@tanstack/react-table"; import {Chip, Pagination, Table, cn} from "@blakeui/react"; import {Icon} from "@iconify/react"; import { createColumnHelper, flexRender, getCoreRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, } from "@tanstack/react-table"; import {useMemo, useState} from "react"; // --- Data ----------------------------------------------------------------- interface User { id: number; name: string; role: string; status: "Active" | "Inactive" | "On Leave"; email: string; } const statusColorMap: Record = { Active: "success", Inactive: "danger", "On Leave": "warning", }; const users: User[] = [ {email: "kate@acme.com", id: 1, name: "Kate Moore", role: "CEO", status: "Active"}, {email: "john@acme.com", id: 2, name: "John Smith", role: "CTO", status: "Active"}, {email: "sara@acme.com", id: 3, name: "Sara Johnson", role: "CMO", status: "On Leave"}, {email: "michael@acme.com", id: 4, name: "Michael Brown", role: "CFO", status: "Active"}, { email: "emily@acme.com", id: 5, name: "Emily Davis", role: "Product Manager", status: "Inactive", }, {email: "davis@acme.com", id: 6, name: "Davis Wilson", role: "Lead Designer", status: "Active"}, { email: "olivia@acme.com", id: 7, name: "Olivia Martinez", role: "Frontend Engineer", status: "Active", }, { email: "james@acme.com", id: 8, name: "James Taylor", role: "Backend Engineer", status: "Active", }, ]; // --- TanStack Column Definitions ------------------------------------------ const columnHelper = createColumnHelper(); const columns = [ columnHelper.accessor("name", {header: "Name"}), columnHelper.accessor("role", {header: "Role"}), columnHelper.accessor("status", { cell: (info) => ( {info.getValue()} ), header: "Status", }), columnHelper.accessor("email", {header: "Email"}), ]; // --- Sorting Bridge ------------------------------------------------------- // Convert TanStack SortingState → React Aria SortDescriptor function toSortDescriptor(sorting: SortingState): SortDescriptor | undefined { const first = sorting[0]; if (!first) return undefined; return { column: first.id, direction: first.desc ? "descending" : "ascending", }; } // Convert React Aria SortDescriptor → TanStack SortingState function toSortingState(descriptor: SortDescriptor): SortingState { return [{desc: descriptor.direction === "descending", id: descriptor.column as string}]; } // --- Sort Header ---------------------------------------------------------- function SortableColumnHeader({ children, sortDirection, }: { children: React.ReactNode; sortDirection?: "ascending" | "descending"; }) { return ( {children} {!!sortDirection && ( )} ); } // --- Component ------------------------------------------------------------ const PAGE_SIZE = 4; export function TanstackTable() { const [sorting, setSorting] = useState([]); // eslint-disable-next-line react-hooks/incompatible-library const table = useReactTable({ columns, data: users, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), initialState: {pagination: {pageSize: PAGE_SIZE}}, onSortingChange: setSorting, state: {sorting}, }); const sortDescriptor = useMemo(() => toSortDescriptor(sorting), [sorting]); const {pageIndex} = table.getState().pagination; const pageCount = table.getPageCount(); const pages = Array.from({length: pageCount}, (_, i) => i + 1); const start = pageIndex * PAGE_SIZE + 1; const end = Math.min((pageIndex + 1) * PAGE_SIZE, users.length); return ( setSorting(toSortingState(d))} > {table.getHeaderGroups()[0]!.headers.map((header) => ( {({sortDirection}) => ( {flexRender(header.column.columnDef.header, header.getContext())} )} ))} {table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} ))} {start} to {end} of {users.length} results table.previousPage()} > Prev {pages.map((p) => ( table.setPageIndex(p - 1)} > {p} ))} table.nextPage()} > Next
); } ``` ### With Filters Compose a filter toolbar above the table: search, a combined Status / Role filters popover, column reordering and show / hide, row selection, and pagination. This is composition in the consumer — the Table component itself is unchanged. ```tsx "use client"; import type {Dispatch, ReactNode, SetStateAction} from "react"; import type {Key, Selection, SortDescriptor} from "react-aria-components"; import { Avatar, Button, Checkbox, CheckboxGroup, Chip, EmptyState, Label, ListBox, Pagination, Popover, SearchField, Separator, Table, cn, } from "@blakeui/react"; import {Icon} from "@iconify/react"; import {useMemo, useState} from "react"; import {useDragAndDrop} from "react-aria-components"; interface User { id: number; name: string; image_url: string; role: string; status: "Active" | "Inactive" | "On Leave"; email: string; } const statusColorMap: Record = { Active: "success", Inactive: "danger", "On Leave": "warning", }; const users: User[] = [ { email: "kate@acme.com", id: 4586932, image_url: "https://cdn.blakeui.com/avatars/red.jpg", name: "Kate Moore", role: "Chief Executive Officer", status: "Active", }, { email: "john@acme.com", id: 5273849, image_url: "https://cdn.blakeui.com/avatars/green.jpg", name: "John Smith", role: "Chief Technology Officer", status: "Active", }, { email: "sara@acme.com", id: 7492836, image_url: "https://cdn.blakeui.com/avatars/blue.jpg", name: "Sara Johnson", role: "Chief Marketing Officer", status: "On Leave", }, { email: "michael@acme.com", id: 8293746, image_url: "https://cdn.blakeui.com/avatars/purple.jpg", name: "Michael Brown", role: "Chief Financial Officer", status: "Active", }, { email: "emily@acme.com", id: 1234567, image_url: "https://cdn.blakeui.com/avatars/orange.jpg", name: "Emily Davis", role: "Product Manager", status: "Inactive", }, { email: "davis@acme.com", id: 9876543, image_url: "https://cdn.blakeui.com/avatars/black.jpg", name: "Davis Wilson", role: "Lead Designer", status: "Active", }, { email: "olivia@acme.com", id: 3456789, image_url: "https://cdn.blakeui.com/avatars/red.jpg", name: "Olivia Martinez", role: "Frontend Engineer", status: "Active", }, { email: "james@acme.com", id: 4567890, image_url: "https://cdn.blakeui.com/avatars/green.jpg", name: "James Taylor", role: "Backend Engineer", status: "Active", }, { email: "sophia@acme.com", id: 5678901, image_url: "https://cdn.blakeui.com/avatars/blue.jpg", name: "Sophia Anderson", role: "QA Engineer", status: "On Leave", }, { email: "liam@acme.com", id: 6789012, image_url: "https://cdn.blakeui.com/avatars/purple.jpg", name: "Liam Thomas", role: "DevOps Engineer", status: "Active", }, { email: "ava@acme.com", id: 7890123, image_url: "https://cdn.blakeui.com/avatars/orange.jpg", name: "Ava Jackson", role: "Data Analyst", status: "Inactive", }, { email: "noah@acme.com", id: 8901234, image_url: "https://cdn.blakeui.com/avatars/black.jpg", name: "Noah White", role: "Security Engineer", status: "Active", }, ]; const STATUS_OPTIONS = [ {id: "Active", name: "Active"}, {id: "Inactive", name: "Inactive"}, {id: "On Leave", name: "On Leave"}, ] as const; // Distinct roles, derived from the data. const ROLE_OPTIONS = Array.from(new Set(users.map((u) => u.role))).map((role) => ({ id: role, name: role, })); const PAGE_SIZE = 5; /** * Column definitions — header columns and body cells are driven from this single list (filtered by * visibleColumns) via Table.Collection, so the cell count always matches the column count. */ interface ColumnDef { id: string; label: string; isRowHeader?: boolean; cellClassName?: string; renderCell: (user: User) => ReactNode; } const DATA_COLUMNS: ColumnDef[] = [ { cellClassName: "font-medium", id: "id", label: "Worker ID", renderCell: (user) => (
#{user.id.toString()}{" "}
), }, { id: "name", isRowHeader: true, label: "Member", renderCell: (user) => (
{user.name .split(" ") .map((n) => n[0]) .join("")}
{user.name} {user.email}
), }, { cellClassName: "min-w-52", id: "role", label: "Role", renderCell: (user) => user.role, }, { cellClassName: "min-w-25", id: "status", label: "Status", renderCell: (user) => ( {user.status} ), }, ]; // Member ("name") is the anchor: always first, non-draggable, non-hideable. The remaining data // columns can be reordered and hidden freely. const DATA_COLUMN_BY_ID: Record = Object.fromEntries( DATA_COLUMNS.map((c) => [c.id, c]), ); const MEMBER_COLUMN = DATA_COLUMNS.find((c) => c.id === "name") as ColumnDef; const REORDERABLE_COLUMN_IDS = DATA_COLUMNS.filter((c) => c.id !== "name").map((c) => c.id); function SortableColumnHeader({ children, sortDirection, }: { children: ReactNode; sortDirection?: "ascending" | "descending"; }) { return ( {children} {!!sortDirection && ( )} ); } /** * One reusable CheckboxGroup facet (Status or Role). Filters apply live as boxes toggle. State stays * a Set for the data pipeline; converted to/from string[] at the CheckboxGroup edge. */ function FilterFacet({ label, onChange, options, scroll, value, }: { label: string; onChange: (next: Set) => void; options: ReadonlyArray<{id: string; name: string}>; scroll?: boolean; value: Set; }) { return ( onChange(new Set(values))} >
{options.map((option) => ( ))}
); } /** * Single "Filters" button → Popover holding the Status and Role facets (separated by a Separator) * and a "Clear all" footer. The button shows a count badge when any filter is active. */ function FiltersPopover({ roleFilter, setRoleFilter, setStatusFilter, statusFilter, }: { roleFilter: Set; setRoleFilter: (next: Set) => void; setStatusFilter: (next: Set) => void; statusFilter: Set; }) { const activeCount = statusFilter.size + roleFilter.size; return (
); } /** A single visibility checkbox matching the table's row selection boxes (variant="secondary"). */ function VisibilityToggle({ isDisabled, isSelected, label, onChange, }: { isDisabled?: boolean; isSelected: boolean; label: string; onChange?: (isSelected: boolean) => void; }) { return ( ); } /** * Columns popover: reorder + show/hide the data columns. Member is the anchor — pinned first, * non-draggable and always visible. Worker ID / Role / Status reorder (drag handle) and hide * (visibility toggle) freely. The selection and Actions columns are structural and never appear here. */ function ColumnsPopover({ columnOrder, setColumnOrder, setVisibleColumns, visibleColumns, }: { columnOrder: string[]; setColumnOrder: Dispatch>; setVisibleColumns: (next: Set) => void; visibleColumns: Set; }) { const {dragAndDropHooks} = useDragAndDrop({ getItems: (keys) => [...keys].map((key) => ({"text/plain": String(key)})), onReorder(e) { setColumnOrder((prev) => { const moved = prev.filter((k) => e.keys.has(k)); const rest = prev.filter((k) => !e.keys.has(k)); let index = rest.indexOf(String(e.target.key)); if (index === -1) return prev; if (e.target.dropPosition === "after") index += 1; rest.splice(index, 0, ...moved); return rest; }); }, }); const toggle = (id: string, isSelected: boolean) => { setVisibleColumns( (() => { const next = new Set(visibleColumns); if (isSelected) next.add(id); else next.delete(id); return next; })(), ); }; const items = columnOrder .map((id) => DATA_COLUMN_BY_ID[id]) .filter((column): column is ColumnDef => column !== undefined) .map((column) => ({id: column.id, label: column.label})); return ( Columns {/* Member — pinned first, locked on. px-3.5 = ListBox p-1.5 (6px) + item px-2 (8px) so its grip/checkbox stay aligned with the reorderable rows below. */}
Member
{(item) => (
{item.label} toggle(item.id, isSelected)} />
)}
); } export function WithFilters() { const [searchQuery, setSearchQuery] = useState(""); const [statusFilter, setStatusFilter] = useState>(new Set()); const [roleFilter, setRoleFilter] = useState>(new Set()); const [visibleColumns, setVisibleColumns] = useState>( () => new Set(REORDERABLE_COLUMN_IDS), ); const [columnOrder, setColumnOrder] = useState(() => [...REORDERABLE_COLUMN_IDS]); const [selectedKeys, setSelectedKeys] = useState(new Set()); const [sortDescriptor, setSortDescriptor] = useState({ column: "name", direction: "ascending", }); const [page, setPage] = useState(1); /* --- Data pipeline: filter -> sort -> paginate --- */ const filteredItems = useMemo(() => { const query = searchQuery.trim().toLowerCase(); return users.filter((user) => { const matchesSearch = !query || user.name.toLowerCase().includes(query) || user.email.toLowerCase().includes(query); const matchesStatus = statusFilter.size === 0 || statusFilter.has(user.status); const matchesRole = roleFilter.size === 0 || roleFilter.has(user.role); return matchesSearch && matchesStatus && matchesRole; }); }, [searchQuery, statusFilter, roleFilter]); const sortedItems = useMemo(() => { const items = [...filteredItems]; const column = sortDescriptor.column as keyof User; items.sort((a, b) => { let cmp: number; if (column === "id") { cmp = Number(a.id) - Number(b.id); } else { cmp = String(a[column]).localeCompare(String(b[column])); } return sortDescriptor.direction === "descending" ? cmp * -1 : cmp; }); return items; }, [filteredItems, sortDescriptor]); const filteredTotal = sortedItems.length; const totalPages = Math.max(1, Math.ceil(filteredTotal / PAGE_SIZE)); // Reset to the first page whenever the filters change. Adjusting state during render (rather than // in an effect) is the idiomatic pattern: https://react.dev/learn/you-might-not-need-an-effect const filterSignature = `${searchQuery}|${[...statusFilter].sort().join(",")}|${[...roleFilter] .sort() .join(",")}`; const [lastFilterSignature, setLastFilterSignature] = useState(filterSignature); if (filterSignature !== lastFilterSignature) { setLastFilterSignature(filterSignature); setPage(1); } const currentPage = Math.min(page, totalPages); const paginatedItems = useMemo(() => { const start = (currentPage - 1) * PAGE_SIZE; return sortedItems.slice(start, start + PAGE_SIZE); }, [sortedItems, currentPage]); /* --- Selection: select-all targets the filtered set, count reflects filtered total --- */ const filteredIdSet = useMemo(() => new Set(sortedItems.map((u) => u.id)), [sortedItems]); const handleSelectionChange = (keys: Selection) => { // React Aria emits "all" for the header select-all — expand it across the whole filtered set. setSelectedKeys(keys === "all" ? new Set(sortedItems.map((u) => u.id)) : keys); }; const selectedCount = selectedKeys === "all" ? filteredTotal : [...selectedKeys].filter((key) => filteredIdSet.has(key as number)).length; const pages = Array.from({length: totalPages}, (_, i) => i + 1); // Member ("name") is always first; the rest follow columnOrder, filtered by visibleColumns. Header // and body both map over this list so their counts can never diverge. const visibleDataColumns = useMemo( () => [ MEMBER_COLUMN, ...columnOrder .map((id) => DATA_COLUMN_BY_ID[id]) .filter( (column): column is ColumnDef => column !== undefined && visibleColumns.has(column.id), ), ], [columnOrder, visibleColumns], ); return (
{/* Toolbar — single row inset to match the header pill (no px): search left, actions right. */}
{/* shadow-none: drop the field's subtle elevation inside the table card. */}
{(column) => ( {({sortDirection}) => ( {column.label} )} )} Actions ( No members match your filters )} > {(user) => ( {(column) => ( {column.renderCell(user)} )}
)}
{selectedCount > 0 ? `${selectedCount} of ${filteredTotal} selected` : `${filteredTotal} ${filteredTotal === 1 ? "result" : "results"}`} setPage((p) => Math.max(1, p - 1))} > Prev {pages.map((p) => ( setPage(p)}> {p} ))} setPage((p) => Math.min(totalPages, p + 1))} > Next
); } ``` ## Related Components - **Pagination**: Page navigation with composable page links and controls - **Checkbox**: Binary choice input control - **Chip**: Compact elements for tags and filters ## Styling ### Passing Tailwind CSS classes You can customize individual Table parts: ```tsx import { Table } from '@blakeui/react'; function CustomTable() { return ( Name Kate Moore
); } ``` ### Customizing the component classes To customize the Table component classes, you can use the `@layer components` directive.
[Learn more](https://tailwindcss.com/docs/adding-custom-styles#adding-component-classes). ```css @layer components { .table-root { @apply relative grid w-full overflow-clip; } .table__header { @apply bg-gray-100; } .table__column { @apply px-4 py-2.5 text-left text-xs font-medium text-gray-600; } .table__row { @apply bg-white border-b border-gray-200; } .table__cell { @apply px-4 py-3 text-sm; } .table__footer { @apply flex items-center px-4 py-2.5; } } ``` blakeUI follows the [BEM](https://getbem.com/) methodology to ensure component variants and states are reusable and easy to customize. ### CSS Classes The Table component uses these CSS classes ([View source styles](https://github.com/myblakebox/BlakeUI/blob/main/packages/styles/components/table.css)): #### Base Classes - `.table-root` - Root container (named `table-root` instead of `table` because `table` is a built-in Tailwind CSS utility class for `display: table`) - `.table__scroll-container` - Horizontal scroll wrapper with custom scrollbar - `.table__content` - The `` element - `.table__header` - Header row (``) - `.table__column` - Column header cell (``) - `.table__row` - Row element (``) - `.table__cell` - Data cell (`
`) - `.table__body` - Body section (`
`) - `.table__footer` - Footer container (outside table) #### Advanced Classes - `.table__column-resizer` - Drag handle for column resizing - `.table__resizable-container` - Wrapper enabling column resizing - `.table__load-more` - Sentinel row for infinite scrolling - `.table__load-more-content` - Styled container for the loading indicator #### Variant Classes - `.table-root--primary` - Gray background container with card-style body (default) - `.table-root--secondary` - No background, standalone rounded headers ### Interactive States The Table supports both CSS pseudo-classes and data attributes for flexibility: - **Hover**: `:hover` or `[data-hovered="true"]` (row background change) - **Selected**: `[data-selected="true"]` (row highlight) - **Focus**: `:focus-visible` or `[data-focus-visible="true"]` (inset focus ring on rows, columns, and cells) - **Disabled**: `:disabled` or `[aria-disabled="true"]` (reduced opacity) - **Sortable**: `[data-allows-sorting="true"]` (interactive cursor on columns) - **Dragging**: `[data-dragging="true"]` (reduced opacity) - **Drop Target**: `[data-drop-target="true"]` (accent background) ## API Reference ### Table Props | Prop | Type | Default | Description | |------|------|---------|-------------| | `variant` | `"primary" \| "secondary"` | `"primary"` | Visual variant. Primary has a gray background container; secondary is flat with transparent rows. | | `className` | `string` | - | Additional CSS classes for the root container | | `children` | `React.ReactNode` | - | Table content (ScrollContainer, Footer, etc.) | ### Table.ScrollContainer Props | Prop | Type | Default | Description | |------|------|---------|-------------| | `className` | `string` | - | Additional CSS classes | | `children` | `React.ReactNode` | - | Table.Content element | ### Table.Content Props Inherits from [React Aria Table](https://react-spectrum.adobe.com/react-aria/Table.html). | Prop | Type | Default | Description | |------|------|---------|-------------| | `aria-label` | `string` | - | Accessible label for the table | | `selectionMode` | `"none" \| "single" \| "multiple"` | `"none"` | Selection behavior | | `selectedKeys` | `Selection` | - | Controlled selected keys | | `onSelectionChange` | `(keys: Selection) => void` | - | Selection change handler | | `sortDescriptor` | `SortDescriptor` | - | Current sort state | | `onSortChange` | `(descriptor: SortDescriptor) => void` | - | Sort change handler | | `className` | `string` | - | Additional CSS classes | ### Table.Header Props Inherits from [React Aria TableHeader](https://react-spectrum.adobe.com/react-aria/Table.html#tableheader). | Prop | Type | Default | Description | |------|------|---------|-------------| | `columns` | `T[]` | - | Dynamic column data for render prop pattern | | `children` | `React.ReactNode \| (column: T) => React.ReactNode` | - | Static columns or render prop | ### Table.Column Props Inherits from [React Aria Column](https://react-spectrum.adobe.com/react-aria/Table.html#column). | Prop | Type | Default | Description | |------|------|---------|-------------| | `id` | `string` | - | Column identifier | | `allowsSorting` | `boolean` | `false` | Whether the column is sortable | | `isRowHeader` | `boolean` | `false` | Whether this column is a row header | | `defaultWidth` | `string \| number` | - | Default width for resizable columns | | `minWidth` | `number` | - | Minimum width for resizable columns | | `children` | `React.ReactNode \| (values: ColumnRenderProps) => React.ReactNode` | - | Column content or render prop with sort direction | ### Table.Body Props Inherits from [React Aria TableBody](https://react-spectrum.adobe.com/react-aria/Table.html#tablebody). | Prop | Type | Default | Description | |------|------|---------|-------------| | `items` | `T[]` | - | Dynamic row data for render prop pattern | | `renderEmptyState` | `() => React.ReactNode` | - | Content to display when the table is empty | | `children` | `React.ReactNode \| (item: T) => React.ReactNode` | - | Static rows or render prop | ### Table.Row Props Inherits from [React Aria Row](https://react-spectrum.adobe.com/react-aria/Table.html#row). | Prop | Type | Default | Description | |------|------|---------|-------------| | `id` | `string \| number` | - | Row identifier | | `className` | `string` | - | Additional CSS classes | | `children` | `React.ReactNode` | - | Row cells | ### Table.Cell Props Inherits from [React Aria Cell](https://react-spectrum.adobe.com/react-aria/Table.html#cell). | Prop | Type | Default | Description | |------|------|---------|-------------| | `className` | `string` | - | Additional CSS classes | | `children` | `React.ReactNode` | - | Cell content | ### Table.Footer Props | Prop | Type | Default | Description | |------|------|---------|-------------| | `className` | `string` | - | Additional CSS classes | | `children` | `React.ReactNode` | - | Footer content (e.g., pagination) | ### Table.ColumnResizer Props Inherits from [React Aria ColumnResizer](https://react-spectrum.adobe.com/react-aria/Table.html#columnresizer). | Prop | Type | Default | Description | |------|------|---------|-------------| | `className` | `string` | - | Additional CSS classes | ### Table.ResizableContainer Props Inherits from [React Aria ResizableTableContainer](https://react-spectrum.adobe.com/react-aria/Table.html#resizabletablecontainer). | Prop | Type | Default | Description | |------|------|---------|-------------| | `className` | `string` | - | Additional CSS classes | | `children` | `React.ReactNode` | - | Table.Content element | ### Table.LoadMore Props Inherits from [React Aria TableLoadMoreItem](https://react-spectrum.adobe.com/react-aria/Table.html). | Prop | Type | Default | Description | |------|------|---------|-------------| | `isLoading` | `boolean` | `false` | Whether data is currently loading | | `onLoadMore` | `() => void` | - | Handler called when the sentinel row is visible | | `children` | `React.ReactNode` | - | Loading indicator content | ### Table.LoadMoreContent Props | Prop | Type | Default | Description | |------|------|---------|-------------| | `className` | `string` | - | Additional CSS classes | | `children` | `React.ReactNode` | - | Loading indicator content (e.g., Spinner) | ### Table.Collection Props Re-exported from React Aria `Collection`. Used to render dynamic cells within rows alongside static cells (e.g., checkboxes). | Prop | Type | Default | Description | |------|------|---------|-------------| | `items` | `T[]` | - | Collection items | | `children` | `(item: T) => React.ReactNode` | - | Render prop for each item | ### TableLayout | Name | Type | Default | Description | |------|------|---------|-------------| | `rowHeight` | `number \| undefined` | 48 | The fixed height of a row in px. | | `estimatedRowHeight` | `number \| undefined` | — | The estimated height of a row, when row heights are variable. | | `headingHeight` | `number \| undefined` | 48 | The fixed height of a section header in px. | | `estimatedHeadingHeight` | `number \| undefined` | — | The estimated height of a section header, when the height is variable. | | `loaderHeight` | `number \| undefined` | 48 | The fixed height of a loader element in px. This loader is specifically for "load more" elements rendered when loading more rows at the root level or inside nested row/sections. | | `dropIndicatorThickness` | `number \| undefined` | 2 | The thickness of the drop indicator. | | `gap` | `number \| undefined` | 0 | The gap between items. | | `padding` | `number \| undefined` | 0 | The padding around the list. |