Data Table
Sortable, filterable data table with pagination and custom cell rendering.
Installation#
npx @aniui/cli add data-table| Name | Role | Status | |
|---|---|---|---|
| Alice Johnson | alice@example.com | Engineer | Active |
| Bob Smith | bob@example.com | Designer | Active |
| Carol Williams | carol@example.com | Manager | Away |
1-3 of 5
Web preview — components render natively on iOS & Android
import { DataTable } from "@/components/ui/data-table";
const columns = [
{ key: "name", header: "Name", sortable: true },
{ key: "email", header: "Email", sortable: true },
{ key: "role", header: "Role" },
{ key: "status", header: "Status" },
];
const data = [
{ name: "Alice Johnson", email: "alice@example.com", role: "Engineer", status: "Active" },
{ name: "Bob Smith", email: "bob@example.com", role: "Designer", status: "Active" },
{ name: "Carol Williams", email: "carol@example.com", role: "Manager", status: "Away" },
];
export function MyScreen() {
return <DataTable columns={columns} data={data} />;
}Usage#
app/index.tsx
import { DataTable } from "@/components/ui/data-table";
const columns = [
{ key: "name", header: "Name", sortable: true },
{ key: "email", header: "Email", sortable: true },
{ key: "role", header: "Role" },
{ key: "status", header: "Status" },
];
const data = [
{ name: "Alice Johnson", email: "alice@example.com", role: "Engineer", status: "Active" },
{ name: "Bob Smith", email: "bob@example.com", role: "Designer", status: "Active" },
{ name: "Carol Williams", email: "carol@example.com", role: "Manager", status: "Away" },
];
export function MyScreen() {
return <DataTable columns={columns} data={data} />;
}Sorting#
Mark columns as sortable: true to enable click-to-sort. Supports both uncontrolled (internal state) and controlled modes via sortBy, sortOrder, and onSort.
| Name | Role | Status |
|---|---|---|
| Alice Johnson | Engineer | Active |
| Bob Smith | Designer | Active |
| Carol Williams | Manager | Away |
| David Brown | Engineer | Inactive |
| Eva Martinez | Designer | Active |
Web preview — components render natively on iOS & Android
const columns = [
{ key: "name", header: "Name", sortable: true },
{ key: "email", header: "Email", sortable: true },
{ key: "role", header: "Role", sortable: true },
{ key: "status", header: "Status" },
];
// Uncontrolled (internal state)
<DataTable columns={columns} data={data} />
// Controlled
const [sortBy, setSortBy] = useState("name");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
<DataTable
columns={columns}
data={data}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={(key, order) => {
setSortBy(key);
setSortOrder(order);
}}
/>Search#
Enable search with searchable. Limit which columns are searchable with searchKeys.
| Name | |
|---|---|
| Alice Johnson | alice@example.com |
| Bob Smith | bob@example.com |
| Carol Williams | carol@example.com |
| David Brown | david@example.com |
| Eva Martinez | eva@example.com |
Web preview — components render natively on iOS & Android
<DataTable
columns={columns}
data={data}
searchable
searchKeys={["name", "email"]}
searchPlaceholder="Search by name or email..."
/>Pagination#
Set pageSize to enable pagination with Prev/Next controls. The current range and total count are displayed automatically.
| Name | Role | Status |
|---|---|---|
| Alice Johnson | Engineer | Active |
| Bob Smith | Designer | Active |
1-2 of 5
Web preview — components render natively on iOS & Android
<DataTable
columns={columns}
data={data}
pageSize={5}
/>Custom Cell Rendering#
Use the render prop on a column to provide custom cell content. The render function receives the cell value and full row.
| Name | Status |
|---|---|
| Alice Johnson | Active |
| Bob Smith | Active |
| Carol Williams | Away |
| David Brown | Inactive |
| Eva Martinez | Active |
Web preview — components render natively on iOS & Android
const columns = [
{ key: "name", header: "Name", sortable: true },
{ key: "email", header: "Email" },
{
key: "status",
header: "Status",
render: (value: unknown) => (
<Badge variant={value === "Active" ? "default" : "secondary"}>
{String(value)}
</Badge>
),
},
];
<DataTable columns={columns} data={data} />Striped Rows#
Add alternating row backgrounds with the striped prop.
| Name | Role | |
|---|---|---|
| Alice Johnson | alice@example.com | Engineer |
| Bob Smith | bob@example.com | Designer |
| Carol Williams | carol@example.com | Manager |
| David Brown | david@example.com | Engineer |
| Eva Martinez | eva@example.com | Designer |
Web preview — components render natively on iOS & Android
<DataTable
columns={columns}
data={data}
striped
/>Props#
DataTableProps#
PropTypeDefault
columnsDataTableColumn<T>[]-dataT[]-sortBystring-sortOrder"asc" | "desc""asc"onSort(key: string, order: "asc" | "desc") => void-searchablebooleanfalsesearchKeysstring[]all column keyssearchPlaceholderstring"Search..."pageSizenumber-emptyTextstring"No data"stripedbooleanfalseclassNamestring-DataTableColumn<T>#
PropTypeDefault
keykeyof T & string-headerstring-sortablebooleanfalsewidthnumber-render(value: T[keyof T], row: T) => ReactNode-Also accepts all View props from React Native.
Accessibility#
- Sortable column headers have
accessibilityRole="button"; non-sortable headers useaccessibilityRole="text". - Search input has
accessibilityLabel="Search table". - Pagination buttons have descriptive
accessibilityLabelvalues. - Empty state is announced to screen readers.
Source#
components/ui/data-table.tsx
import React, { useState, useMemo, useCallback } from "react";
import { View, Text, TextInput, Pressable, FlatList, ScrollView } from "react-native";
import { cn } from "@/lib/utils";
import Svg, { Path } from "react-native-svg";
export interface DataTableColumn<T> {
key: keyof T & string;
header: string;
sortable?: boolean;
width?: number;
render?: (value: T[keyof T], row: T) => React.ReactNode;
}
export interface DataTableProps<T> extends React.ComponentPropsWithoutRef<typeof View> {
columns: DataTableColumn<T>[];
data: T[];
sortBy?: string;
sortOrder?: "asc" | "desc";
onSort?: (key: string, order: "asc" | "desc") => void;
searchable?: boolean;
searchKeys?: string[];
searchPlaceholder?: string;
pageSize?: number;
emptyText?: string;
className?: string;
striped?: boolean;
}
function SortIcon({ order }: { order?: "asc" | "desc" }) {
return (
<Svg width={12} height={12} viewBox="0 0 24 24" fill="none" stroke="#71717a" strokeWidth={2.5}>
{order === "asc" ? <Path d="m18 15-6-6-6 6" /> : <Path d="m6 9 6 6 6-6" />}
</Svg>
);
}
export function DataTable<T extends Record<string, unknown>>({
columns,
data,
sortBy: controlledSortBy,
sortOrder: controlledSortOrder,
onSort,
searchable = false,
searchKeys,
searchPlaceholder = "Search...",
pageSize,
emptyText = "No data",
className,
striped = false,
...props
}: DataTableProps<T>) {
const [internalSortBy, setInternalSortBy] = useState<string | undefined>();
const [internalSortOrder, setInternalSortOrder] = useState<"asc" | "desc">("asc");
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const sortBy = controlledSortBy ?? internalSortBy;
const sortOrder = controlledSortOrder ?? internalSortOrder;
const handleSort = useCallback((key: string) => {
if (onSort) {
onSort(key, sortBy === key && sortOrder === "asc" ? "desc" : "asc");
} else {
setInternalSortOrder(sortBy === key && internalSortOrder === "asc" ? "desc" : "asc");
setInternalSortBy(key);
}
setPage(0);
}, [sortBy, sortOrder, internalSortOrder, onSort]);
const keys = searchKeys ?? columns.map((c) => c.key);
const filtered = useMemo(() => {
if (!search.trim()) return data;
const q = search.toLowerCase();
return data.filter((row) =>
keys.some((k) => String(row[k] ?? "").toLowerCase().includes(q))
);
}, [data, search, keys]);
const sorted = useMemo(() => {
if (!sortBy) return filtered;
return [...filtered].sort((a, b) => {
const aVal = a[sortBy] ?? "";
const bVal = b[sortBy] ?? "";
const cmp = String(aVal).localeCompare(String(bVal), undefined, { numeric: true });
return sortOrder === "asc" ? cmp : -cmp;
});
}, [filtered, sortBy, sortOrder]);
const totalPages = pageSize ? Math.max(1, Math.ceil(sorted.length / pageSize)) : 1;
const paged = pageSize ? sorted.slice(page * pageSize, (page + 1) * pageSize) : sorted;
const renderRow = useCallback(({ item, index }: { item: T; index: number }) => (
<View className={cn("flex-row border-b border-border", striped && index % 2 === 1 && "bg-muted/30")}>
{columns.map((col) => (
<View key={col.key} className="flex-1 px-4 py-3" style={col.width ? { width: col.width, flex: 0 } : undefined}>
{col.render ? (
col.render(item[col.key], item)
) : (
<Text className="text-sm text-foreground" numberOfLines={1}>
{String(item[col.key] ?? "")}
</Text>
)}
</View>
))}
</View>
), [columns, striped]);
return (
<View className={cn("rounded-md border border-border overflow-hidden", className)} {...props}>
{searchable && (
<View className="px-4 py-3 border-b border-border bg-card">
<TextInput
className="min-h-10 px-3 rounded-md border border-input bg-background text-foreground text-sm"
placeholder={searchPlaceholder}
placeholderTextColor="#71717a"
value={search}
onChangeText={(v) => { setSearch(v); setPage(0); }}
accessibilityLabel="Search table"
/>
</View>
)}
<ScrollView horizontal>
<View className="min-w-full">
<View className="flex-row bg-muted/50">
{columns.map((col) => (
<Pressable
key={col.key}
className="flex-1 flex-row items-center px-4 py-3 gap-1"
style={col.width ? { width: col.width, flex: 0 } : undefined}
onPress={() => col.sortable && handleSort(col.key)}
disabled={!col.sortable}
accessible={true}
accessibilityRole={col.sortable ? "button" : "text"}
>
<Text className="text-sm font-medium text-muted-foreground">{col.header}</Text>
{col.sortable && sortBy === col.key && <SortIcon order={sortOrder} />}
</Pressable>
))}
</View>
<FlatList
data={paged}
keyExtractor={(_, i) => String(i)}
renderItem={renderRow}
ListEmptyComponent={
<View className="py-8 items-center">
<Text className="text-sm text-muted-foreground">{emptyText}</Text>
</View>
}
/>
</View>
</ScrollView>
{pageSize && totalPages > 1 && (
<View className="flex-row items-center justify-between px-4 py-3 border-t border-border bg-card">
<Text className="text-xs text-muted-foreground">
{page * pageSize + 1}-{Math.min((page + 1) * pageSize, sorted.length)} of {sorted.length}
</Text>
<View className="flex-row gap-2">
<Pressable
onPress={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
className={cn("px-3 py-1.5 rounded-md border border-input", page === 0 && "opacity-40")}
accessibilityRole="button"
accessibilityLabel="Previous page"
>
<Text className="text-xs text-foreground">Prev</Text>
</Pressable>
<Pressable
onPress={() => setPage(Math.min(totalPages - 1, page + 1))}
disabled={page >= totalPages - 1}
className={cn("px-3 py-1.5 rounded-md border border-input", page >= totalPages - 1 && "opacity-40")}
accessibilityRole="button"
accessibilityLabel="Next page"
>
<Text className="text-xs text-foreground">Next</Text>
</Pressable>
</View>
</View>
)}
</View>
);
}