AniUI

Data Table

Sortable, filterable data table with pagination and custom cell rendering.

Installation#

npx @aniui/cli add data-table
NameEmailRoleStatus
Alice Johnsonalice@example.comEngineerActive
Bob Smithbob@example.comDesignerActive
Carol Williamscarol@example.comManagerAway
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.

NameRoleStatus
Alice JohnsonEngineerActive
Bob SmithDesignerActive
Carol WilliamsManagerAway
David BrownEngineerInactive
Eva MartinezDesignerActive
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);
  }}
/>

Enable search with searchable. Limit which columns are searchable with searchKeys.

NameEmail
Alice Johnsonalice@example.com
Bob Smithbob@example.com
Carol Williamscarol@example.com
David Browndavid@example.com
Eva Martinezeva@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.

NameRoleStatus
Alice JohnsonEngineerActive
Bob SmithDesignerActive
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.

NameStatus
Alice JohnsonActive
Bob SmithActive
Carol WilliamsAway
David BrownInactive
Eva MartinezActive
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.

NameEmailRole
Alice Johnsonalice@example.comEngineer
Bob Smithbob@example.comDesigner
Carol Williamscarol@example.comManager
David Browndavid@example.comEngineer
Eva Martinezeva@example.comDesigner
Web preview — components render natively on iOS & Android
<DataTable
  columns={columns}
  data={data}
  striped
/>

Props#

DataTableProps#

PropTypeDefault
columns
DataTableColumn<T>[]
-
data
T[]
-
sortBy
string
-
sortOrder
"asc" | "desc"
"asc"
onSort
(key: string, order: "asc" | "desc") => void
-
searchable
boolean
false
searchKeys
string[]
all column keys
searchPlaceholder
string
"Search..."
pageSize
number
-
emptyText
string
"No data"
striped
boolean
false
className
string
-

DataTableColumn<T>#

PropTypeDefault
key
keyof T & string
-
header
string
-
sortable
boolean
false
width
number
-
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 use accessibilityRole="text".
  • Search input has accessibilityLabel="Search table".
  • Pagination buttons have descriptive accessibilityLabel values.
  • 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>
  );
}