AniUI

Data Table

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

Installation#

npx @aniui/cli add data-table
NameEmailRoleStatus
Alice Johnsonalice@acme.comAdminActive
Bob Smithbob@acme.comEditorActive
Charlie Browncharlie@acme.comViewerInactive
1-3 of 6
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 JohnsonAdminActive
Bob SmithEditorActive
Charlie BrownViewerInactive
Diana PrinceAdminActive
Eve WilsonEditorInactive
Frank MillerViewerActive
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@acme.com
Bob Smithbob@acme.com
Charlie Browncharlie@acme.com
Diana Princediana@acme.com
Eve Wilsoneve@acme.com
Frank Millerfrank@acme.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 JohnsonAdminActive
Bob SmithEditorActive
1-2 of 6
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
Charlie BrownInactive
Diana PrinceActive
Eve WilsonInactive
Frank MillerActive
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@acme.comAdmin
Bob Smithbob@acme.comEditor
Charlie Browncharlie@acme.comViewer
Diana Princediana@acme.comAdmin
Eve Wilsoneve@acme.comEditor
Frank Millerfrank@acme.comViewer
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, ScrollView, type ViewStyle } from "react-native";
import { cn } from "@/lib/utils";
import { useThemeColors } from "@/components/ui/theme-provider";
import { ChevronDown, ChevronUp } from "lucide-react-native";

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;
  /** When true, fit columns to container width and truncate long content with ellipsis.
   *  Default false: each column has a fixed width (col.width or defaultColumnWidth) and the
   *  table scrolls horizontally if total content exceeds the viewport. */
  truncate?: boolean;
  /** Default pixel width for columns that don't specify `col.width`. Used in scroll mode only. */
  defaultColumnWidth?: number;
}

function SortIcon({ order }: { order?: "asc" | "desc" }) {
  const colors = useThemeColors();
  return order === "asc" ? (
    <ChevronUp size={12} color={colors.mutedForeground} strokeWidth={2.5} />
  ) : (
    <ChevronDown size={12} color={colors.mutedForeground} strokeWidth={2.5} />
  );
}

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,
  truncate = false,
  defaultColumnWidth = 150,
  ...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 = useMemo(() => searchKeys ?? columns.map((c) => c.key), [searchKeys, columns]);

  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 [contentWidth, setContentWidth] = useState(0);
  const fixedSum = columns.reduce((s, c) => s + (c.width ?? 0), 0);
  const autoCount = columns.filter((c) => !c.width).length;
  const truncatedAutoWidth = autoCount > 0 && contentWidth > 0
    ? Math.max((contentWidth - fixedSum) / autoCount, 80)
    : 0;

  const colStyle = (col: DataTableColumn<T>): ViewStyle => {
    if (col.width) return { width: col.width, overflow: "hidden" };
    if (truncate) {
      if (truncatedAutoWidth > 0) return { width: truncatedAutoWidth, overflow: "hidden" };
      return { flexGrow: 1, flexShrink: 1, flexBasis: 0, minWidth: 0, overflow: "hidden" };
    }
    return { width: defaultColumnWidth, overflow: "hidden" };
  };

  const colors = useThemeColors();

  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={colors.mutedForeground}
            value={search}
            onChangeText={(v) => { setSearch(v); setPage(0); }}
            accessibilityLabel="Search table"
          />
        </View>
      )}
      <ScrollView horizontal showsHorizontalScrollIndicator={false}>
        <View
          style={{ minWidth: "100%" }}
          onLayout={truncate ? (e) => setContentWidth(e.nativeEvent.layout.width) : undefined}
        >
          {/* Header */}
          <View className="flex-row bg-muted/50">
            {columns.map((col) => (
              <Pressable
                key={col.key}
                className="flex-row items-center px-4 py-3 gap-1"
                style={colStyle(col)}
                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>
          {/* Body */}
          {paged.length === 0 ? (
            <View className="py-8 items-center">
              <Text className="text-sm text-muted-foreground">{emptyText}</Text>
            </View>
          ) : (
            paged.map((item, index) => (
              <View key={index} className={cn("flex-row border-t border-border", striped && index % 2 === 1 && "bg-muted/30")}>
                {columns.map((col) => (
                  <View key={col.key} className="px-4 py-3" style={colStyle(col)}>
                    {col.render ? (
                      col.render(item[col.key], item)
                    ) : (
                      <Text className="text-sm text-foreground" numberOfLines={1}>
                        {String(item[col.key] ?? "")}
                      </Text>
                    )}
                  </View>
                ))}
              </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>
  );
}