DataTable
A data table with sorting, Uzbek-aware search, filters, selection, pagination, server mode and virtual scrolling.
| Holat | |||||
|---|---|---|---|---|---|
| #1042 | Jasur Karimov | Toshkent | 21.10.2026 | To'langan | 85 000 so'm |
| #1043 | Nodira Saidova | Buxoro | 20.10.2026 | Kutilmoqda | 462 311 so'm |
| #1044 | Malika Rahimova | Xorazm | 19.10.2026 | Yo'lda | 839 622 so'm |
| #1045 | Aziz Normatov | Samarqand | 18.10.2026 | Bekor qilingan | 1 216 933 so'm |
| #1046 | Bekzod G'aniyev | Farg'ona | 17.10.2026 | Kutilmoqda | 1 594 244 so'm |
'use client';
import { formatDate, formatMoney } from '@akhatmirzo/core';
import { Badge, Button, DataTable, toast, type DataTableColumn } from '@akhatmirzo/ui';
import { Plus } from 'lucide-react';
type Status = 'paid' | 'pending' | 'shipped' | 'cancelled';
interface Order {
id: number;
customer: string;
region: string;
date: Date;
status: Status;
total: number;
}
const STATUS = {
paid: { label: "To'langan", color: 'success' },
pending: { label: 'Kutilmoqda', color: 'warning' },
shipped: { label: "Yo'lda", color: 'info' },
cancelled: { label: 'Bekor qilingan', color: 'danger' },
} as const;
const NAMES = [
'Jasur Karimov',
'Malika Rahimova',
"Bekzod G'aniyev",
'Nodira Saidova',
'Aziz Normatov',
];
const REGIONS = ['Toshkent', 'Samarqand', 'Buxoro', "Farg'ona", 'Xorazm'];
const STATUSES = Object.keys(STATUS) as Status[];
const orders: Order[] = Array.from({ length: 48 }, (_, i) => ({
id: 1042 + i,
customer: NAMES[(i * 3) % NAMES.length]!,
region: REGIONS[(i * 7) % REGIONS.length]!,
date: new Date(2026, 9, 21 - (i % 30)),
status: STATUSES[(i * 5 + (i >> 2)) % 4]!,
total: 85_000 + ((i * 377_311) % 4_800_000),
}));
const columns: DataTableColumn<Order>[] = [
{ key: 'id', header: '№', width: 88, sortable: true, cell: (o) => `#${o.id}` },
{ key: 'customer', header: 'Mijoz', sortable: true },
{ key: 'region', header: 'Viloyat', sortable: true, filter: 'select' },
{ key: 'date', header: 'Sana', sortable: true, cell: (o) => formatDate(o.date) },
{
key: 'status',
header: 'Holat',
filter: 'multiselect',
filterOptions: STATUSES.map((s) => ({ value: s, label: STATUS[s].label })),
searchValue: (o) => STATUS[o.status].label,
cell: (o) => (
<Badge dot color={STATUS[o.status].color}>
{STATUS[o.status].label}
</Badge>
),
},
{
key: 'total',
header: 'Summa',
numeric: true,
sortable: true,
cell: (o) => formatMoney(o.total),
},
];
export default function DataTableBasic() {
return (
<DataTable
label="Buyurtmalar"
data={orders}
columns={columns}
searchable
pagination={{ pageSize: 5, pageSizeOptions: [5, 10, 25] }}
className="w-full"
toolbar={
<Button variant="primary" leftIcon={<Plus />} onClick={() => toast('Yangi buyurtma')}>
Qo'shish
</Button>
}
/>
);
}
Import
import { DataTable, type DataTableColumn } from '@akhatmirzo/ui';DataTable is built on TanStack Table, but you don't need to know
its API: give it data and a list of columns, and sorting, search, filters, pagination and the
loading, empty and error states are ready to go.
When to use
- Many records with the same shape — orders, customers, payments, employees. Users compare, sort and search.
- For a short list (5–10 rows, no sorting) a plain
<table>or a few Cards are enough. - Lots of data (thousands of rows) — two options: paginate on the server (
manual), or, if everything is already in the browser, usevirtual. - Rows that open something — use
onRowClick; for several actions per row userowActions(usually a DropdownMenu). - Mark number columns as
numeric— they align right and use equal-width digits (tabular-nums), so amounts are easy to compare at a glance.
Columns
Each column is a DataTableColumn<T> object. The minimum is key and header: the value comes
from row[key] and is rendered as text. Everything else is optional:
import { formatDate, formatMoney } from '@akhatmirzo/core';
import { Badge, type DataTableColumn } from '@akhatmirzo/ui';
export interface Order {
id: number;
customer: { name: string; phone: string };
date: Date;
status: 'paid' | 'pending';
total: number;
}
export const columns: DataTableColumn<Order>[] = [
{ key: 'id', header: '№', width: 88, sortable: true, hideable: false },
{
key: 'customer',
header: 'Mijoz',
accessor: (o) => o.customer.name, // sorting, filters and search use this value
sortable: true,
},
{
key: 'phone',
header: 'Telefon',
accessor: (o) => o.customer.phone,
hidden: true, // can be turned on from the "Columns" menu
},
{
key: 'date',
header: 'Sana',
sortable: true,
filter: 'date',
searchable: false,
cell: (o) => formatDate(o.date, 'dd.MM.yyyy, HH:mm'),
},
{
key: 'status',
header: 'Holat',
filter: 'multiselect',
filterOptions: [
{ value: 'paid', label: "To'langan" },
{ value: 'pending', label: 'Kutilmoqda' },
],
searchValue: (o) => (o.status === 'paid' ? "To'langan" : 'Kutilmoqda'),
cell: (o) =>
o.status === 'paid' ? <Badge color="success">To'langan</Badge> : <Badge>Kutilmoqda</Badge>,
},
{
key: 'total',
header: 'Summa',
numeric: true,
sortable: true,
filter: 'range',
cell: (o) => formatMoney(o.total),
},
];| Prop | Type | Default | Description |
|---|---|---|---|
keyrequired | string | — | Column id; without accessor, the value is row[key] |
headerrequired | ReactNode | — | Column heading |
accessor | (row: T) => unknown | — | Value used for sorting, filters and search (defaults to row[key]) |
cell | (row: T) => ReactNode | — | How the cell renders (defaults to the value as text; dates as dd.MM.yyyy) |
width | number | string | — | Width: px or any CSS value |
align | 'left' | 'center' | 'right' | — | Alignment (left by default, right for numeric) |
numeric | boolean | — | Right-aligned, tabular-nums |
sortable | boolean | — | Sort by clicking the header |
filter | 'select' | 'multiselect' | 'range' | 'date' | — | Filter type (in the "Filters" popover) |
filterOptions | DataTableFilterOption[] | — | select / multiselect options { value, label } (defaults to unique values in the data) |
filterLabel | string | — | Name in the filter and its badge (defaults to the header text or key) |
searchable | boolean | true | Include the column in the global search |
searchValue | (row: T) => string | — | Text to search (e.g. a status name instead of its code) |
hideable | boolean | true | Can be hidden from the "Columns" menu |
hidden | boolean | — | Hidden initially |
className | string | — | Class for the cell (td) |
Keep value and display apart
cell is for display only. Sorting, filters and search work on the value (accessor or
row[key]). So store amounts as number and dates as Date, and format them in cell — sorting
stays correct, and a search finds both "1042" and "1 042".
Sorting
Click a sortable header: ascending → descending → off. Hold Shift to add the column to the
current sort (multi-column sorting); the header shows its position. Sorting understands the value
type: numbers, dates, booleans and text (Uzbek alphabet order, "10" > "9"); empty values go
last.
Set an initial sort with initialState:
<DataTable
data={orders}
columns={columns}
initialState={{ sorting: [{ id: 'date', desc: true }] }}
/>Search
searchable adds a search field above the table. It's debounced by 300ms and understands how
Uzbek is written: Cyrillic and Latin match each other (жасур → "Jasur"), and oʻ, o' and
o‘ are treated the same. It searches every column except those with searchable: false; give
columns whose display differs from the value a searchValue.
Filters
Add filter to a column and a "Filtrlar" (filters) button appears in the toolbar. Active filters
show up as badges above the table and can be removed with ✕. When nothing matches, a "Filtrlarni
tozalash" (clear filters) button appears.
| Type | Control | Value (DataTableFilterValue) |
|---|---|---|
select | Radio: one value | string |
multiselect | Checkboxes: several values | string[] |
range | Two numbers: from — to | { min?: number; max?: number } |
date | Two dates: start — end | { from?: Date | null; to?: Date | null } |
select and multiselect options come from filterOptions, or from the unique values in the
data, each with a row count next to it. A date range includes both ends (the end day too). The
demo below sets an initial filter through initialState.
| № | Mijoz | To'lov turi | Viloyat | ||
|---|---|---|---|---|---|
| #501 | Dilnoza Yusupova | Click | Toshkent | 25.10.2026 | 50 000 so'm |
| #505 | Dilnoza Yusupova | Click | Samarqand | 21.10.2026 | 903 828 so'm |
| #509 | Dilnoza Yusupova | Click | Andijon | 17.10.2026 | 1 757 656 so'm |
| #513 | Dilnoza Yusupova | Click | Namangan | 13.10.2026 | 2 611 484 so'm |
| #517 | Dilnoza Yusupova | Click | Toshkent | 09.10.2026 | 465 312 so'm |
'use client';
import { formatDate, formatMoney } from '@akhatmirzo/core';
import { DataTable, type DataTableColumn } from '@akhatmirzo/ui';
interface Payment {
id: number;
client: string;
method: string;
region: string;
date: Date;
amount: number;
}
const METHODS = ['Click', 'Payme', 'Uzcard', 'Naqd'];
const REGIONS = ['Toshkent', 'Samarqand', 'Andijon', 'Namangan'];
const CLIENTS = ['Dilnoza Yusupova', 'Otabek Qodirov', 'Shahnoza Ergasheva', 'Rustam Xolmatov'];
const payments: Payment[] = Array.from({ length: 40 }, (_, i) => ({
id: 501 + i,
client: CLIENTS[(i * 3) % CLIENTS.length]!,
method: METHODS[(i * 5) % METHODS.length]!,
region: REGIONS[(i + (i >> 2)) % REGIONS.length]!,
date: new Date(2026, 9, 25 - i),
amount: 50_000 + ((i * 213_457) % 3_000_000),
}));
const columns: DataTableColumn<Payment>[] = [
{ key: 'id', header: '№', width: 72, cell: (p) => `#${p.id}` },
{ key: 'client', header: 'Mijoz' },
{ key: 'method', header: "To'lov turi", filter: 'select' },
{ key: 'region', header: 'Viloyat', filter: 'multiselect' },
{ key: 'date', header: 'Sana', filter: 'date', sortable: true, cell: (p) => formatDate(p.date) },
{
key: 'amount',
header: 'Summa',
numeric: true,
sortable: true,
filter: 'range',
cell: (p) => formatMoney(p.amount),
},
];
export default function DataTableFilters() {
return (
<DataTable
label="To'lovlar"
data={payments}
columns={columns}
searchable
pagination={{ pageSize: 5 }}
initialState={{ filters: { method: 'Click' } }}
className="w-full"
/>
);
}
Selection and bulk actions
selectable adds a checkbox column. The header checkbox selects the current page (and shows an
indeterminate state for a partial selection). Once a row is selected, the toolbar turns into a
bulk actions bar: "2 ta tanlandi" (2 selected), your bulkActions buttons and "Bekor qilish"
(clear). Shift-click a checkbox to select a range.
| Artikul | Amallar | |||
|---|---|---|---|---|
| Paxta ko'ylak | KY-001 | 42 | ||
| Atlas ro'mol | RM-014 | 8 | ||
| Choynak (1.5 l) | CH-203 | 0 | ||
| Do'ppi | DP-077 | 120 | ||
| Sopol lagan | LG-310 | 15 |
'use client';
import {
DataTable,
DropdownMenu,
IconButton,
toast,
type DataTableColumn,
Button,
} from '@akhatmirzo/ui';
import { Download, Ellipsis, Eye, Trash2 } from 'lucide-react';
import { useState } from 'react';
interface Product {
id: string;
name: string;
sku: string;
stock: number;
}
const initial: Product[] = [
{ id: 'p1', name: "Paxta ko'ylak", sku: 'KY-001', stock: 42 },
{ id: 'p2', name: "Atlas ro'mol", sku: 'RM-014', stock: 8 },
{ id: 'p3', name: 'Choynak (1.5 l)', sku: 'CH-203', stock: 0 },
{ id: 'p4', name: "Do'ppi", sku: 'DP-077', stock: 120 },
{ id: 'p5', name: 'Sopol lagan', sku: 'LG-310', stock: 15 },
];
const columns: DataTableColumn<Product>[] = [
{ key: 'name', header: 'Mahsulot', sortable: true },
{ key: 'sku', header: 'Artikul' },
{ key: 'stock', header: 'Qoldiq', numeric: true, sortable: true },
];
export default function DataTableSelection() {
const [products, setProducts] = useState(initial);
const remove = (ids: string[]) => setProducts((prev) => prev.filter((p) => !ids.includes(p.id)));
return (
<DataTable
label="Mahsulotlar"
data={products}
columns={columns}
selectable
className="w-full"
onRowClick={(p) => toast(`${p.name} ochildi`)}
bulkActions={(rows, clear) => (
<>
<Button
variant="outline"
size="sm"
leftIcon={<Download />}
onClick={() => toast(`${rows.length} ta eksport qilindi`)}
>
Eksport
</Button>
<Button
variant="danger"
size="sm"
leftIcon={<Trash2 />}
onClick={() => {
remove(rows.map((r) => r.id));
clear();
}}
>
O'chirish
</Button>
</>
)}
rowActions={(p) => (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton variant="ghost" size="sm" aria-label={`${p.name}: amallar`}>
<Ellipsis />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item icon={<Eye />} onSelect={() => toast(p.name)}>
Ko'rish
</DropdownMenu.Item>
<DropdownMenu.Item variant="danger" icon={<Trash2 />} onSelect={() => remove([p.id])}>
O'chirish
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)}
/>
);
}
<DataTable
data={products}
columns={columns}
selectable
getRowId={(p) => p.sku} // defaults to row.id, or the row index
onSelectionChange={(ids) => setSelectedIds(ids)}
bulkActions={(rows, clearSelection) => (
<Button
size="sm"
onClick={() => {
toast(`${rows.length} products archived`);
clearSelection();
}}
>
Archive
</Button>
)}
/>Selection can be controlled too: selectedIds + onSelectionChange. rowActions renders actions
at the end of each row (clicking them doesn't trigger onRowClick).
Pagination
pagination={{ pageSize: 10 }} adds "1–10 / 248", page numbers and a page size picker at the
bottom (options 10, 25, 50, 100 by default; change them with pageSizeOptions). Changing the
sort, search or filters jumps back to the first page; if a deletion empties the current page, the
table moves to the last page that still exists. Without pagination, every row renders at once —
for large data sets use virtual (which also shows a row count at the bottom).
States
| State | What you see |
|---|---|
| First load | loading + empty data — skeleton rows |
| Refetching | loading + previous data — 60% opacity and a thin progress bar |
| No data | empty — icon, title, description, action button |
| Nothing found | Empty search/filter result — "Filtrlarni tozalash" |
| Error | error (text or true) + onRetry — a "Qayta urinish" (retry) button |
While refetching, the table doesn't jump — the old data stays in place. Click through the states:
| № | Mijoz | Summa |
|---|---|---|
'use client';
import { formatMoney } from '@akhatmirzo/core';
import { Button, DataTable, type DataTableColumn } from '@akhatmirzo/ui';
import { Inbox, Plus } from 'lucide-react';
import { useState } from 'react';
interface Order {
id: number;
customer: string;
total: number;
}
const orders: Order[] = [
{ id: 1042, customer: 'Jasur Karimov', total: 1_250_000 },
{ id: 1043, customer: 'Malika Rahimova', total: 480_000 },
{ id: 1044, customer: 'Aziz Normatov', total: 2_730_000 },
];
const columns: DataTableColumn<Order>[] = [
{ key: 'id', header: '№', width: 88 },
{ key: 'customer', header: 'Mijoz' },
{ key: 'total', header: 'Summa', numeric: true, cell: (o) => formatMoney(o.total) },
];
const STATES = {
data: "Ma'lumot",
first: 'Birinchi yuklanish',
refetch: 'Qayta yuklanish',
empty: "Bo'sh",
error: 'Xato',
} as const;
type State = keyof typeof STATES;
export default function DataTableStates() {
const [state, setState] = useState<State>('first');
return (
<div className="flex w-full flex-col gap-4">
<div className="flex flex-wrap gap-2">
{(Object.keys(STATES) as State[]).map((s) => (
<Button
key={s}
size="sm"
variant={s === state ? 'primary' : 'outline'}
aria-pressed={s === state}
onClick={() => setState(s)}
>
{STATES[s]}
</Button>
))}
</div>
<DataTable
label="Holatlar"
data={state === 'data' || state === 'refetch' ? orders : []}
columns={columns}
columnToggle={false}
loading={state === 'first' || state === 'refetch'}
error={
state === 'error' ? 'Server javob bermadi. Internet aloqasini tekshiring.' : undefined
}
onRetry={() => setState('data')}
empty={{
icon: <Inbox />,
title: "Hali buyurtmalar yo'q",
description: "Birinchi buyurtmani qo'shing — u shu yerda ko'rinadi.",
action: (
<Button size="sm" variant="primary" leftIcon={<Plus />}>
Buyurtma qo'shish
</Button>
),
}}
/>
</div>
);
}
Server mode
When the server sorts, filters and paginates, pass manual. The table leaves the data alone and
reports sorting, search, filters and page through onStateChange; you send that state to your
API. Pass the total number of records as rowCount for pagination.
'use client';
import { formatMoney } from '@akhatmirzo/core';
import { DataTable, type DataTableColumn, type DataTableState } from '@akhatmirzo/ui';
import { useEffect, useState } from 'react';
interface Order {
id: number;
customer: string;
total: number;
}
interface Page {
rows: Order[];
total: number;
}
const NAMES = ['Jasur Karimov', 'Malika Rahimova', 'Nodira Saidova', 'Otabek Qodirov'];
const DB: Order[] = Array.from({ length: 137 }, (_, i) => ({
id: 2001 + i,
customer: NAMES[i % NAMES.length]!,
total: 90_000 + ((i * 271_828) % 3_500_000),
}));
/** "Server": qidiradi, saralaydi, sahifalaydi. Haqiqiy loyihada — fetch('/api/orders?...') */
function fetchOrders({ search, sorting, pagination }: DataTableState): Promise<Page> {
let rows = DB.filter((o) => o.customer.toLowerCase().includes(search.toLowerCase()));
const sort = sorting[0];
if (sort) {
const key = sort.id as keyof Order;
rows = [...rows].sort((a, b) => (a[key] > b[key] ? 1 : -1) * (sort.desc ? -1 : 1));
}
const start = pagination.pageIndex * pagination.pageSize;
const page = { rows: rows.slice(start, start + pagination.pageSize), total: rows.length };
return new Promise((resolve) => setTimeout(() => resolve(page), 600));
}
const columns: DataTableColumn<Order>[] = [
{ key: 'id', header: '№', width: 88, sortable: true },
{ key: 'customer', header: 'Mijoz', sortable: true },
{
key: 'total',
header: 'Summa',
numeric: true,
sortable: true,
cell: (o) => formatMoney(o.total),
},
];
export default function DataTableServer() {
const [query, setQuery] = useState<DataTableState>({
sorting: [],
search: '',
filters: {},
pagination: { pageIndex: 0, pageSize: 10 },
});
const [result, setResult] = useState<Page>({ rows: [], total: 0 });
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
fetchOrders(query).then((page) => {
if (!active) return;
setResult(page);
setLoading(false);
});
return () => {
active = false;
};
}, [query]);
return (
<DataTable
label="Server buyurtmalari"
data={result.rows}
rowCount={result.total}
columns={columns}
manual
searchable
pagination={{ pageSize: 10 }}
loading={loading}
onStateChange={(state) => {
setLoading(true);
setQuery(state);
}}
className="w-full"
/>
);
}
The state passed to onStateChange (DataTableState):
interface DataTableState {
sorting: { id: string; desc: boolean }[]; // id — the column key
search: string; // value after the 300ms debounce
filters: Record<string, DataTableFilterValue>; // column key → filter value
pagination: { pageIndex: number; pageSize: number }; // pageIndex starts at 0
}In a real app you turn the state into URL parameters and fetch:
import { DataTable, type DataTableState } from '@akhatmirzo/ui';
import { useEffect, useState } from 'react';
import { columns, type Order } from './columns';
export function OrdersTable() {
const [state, setState] = useState<DataTableState>({
sorting: [],
search: '',
filters: {},
pagination: { pageIndex: 0, pageSize: 25 },
});
const [page, setPage] = useState<{ rows: Order[]; total: number }>({ rows: [], total: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>();
useEffect(() => {
const controller = new AbortController();
const params = new URLSearchParams({
page: String(state.pagination.pageIndex + 1),
size: String(state.pagination.pageSize),
q: state.search,
sort: state.sorting.map((s) => (s.desc ? '-' : '') + s.id).join(','),
filters: JSON.stringify(state.filters),
});
setLoading(true);
fetch(`/api/orders?${params}`, { signal: controller.signal })
.then((res) => res.json())
.then((data: { rows: Order[]; total: number }) => {
setPage(data);
setError(undefined);
setLoading(false);
})
.catch((err: Error) => {
if (err.name === 'AbortError') return;
setError('The server did not respond');
setLoading(false);
});
return () => controller.abort();
}, [state]);
return (
<DataTable
label="Orders"
data={page.rows}
rowCount={page.total}
columns={columns}
manual
searchable
pagination={{ pageSize: 25 }}
loading={loading}
error={error}
onRetry={() => setState({ ...state })}
onStateChange={setState}
/>
);
}Filter options in server mode
With manual, the table only sees the current page, so always pass filterOptions for select
and multiselect filters — otherwise the options are collected from the visible rows only. Row
counts next to the options aren't shown in this mode.
Virtual scrolling — 10,000 rows
If all the data is in the browser and you don't want pages, use virtual. Only the visible rows
(plus a small buffer, virtual={{ overscan: 10 }}) are in the DOM, while sorting, search and
filters still work on the full data set. The height comes from maxHeight (480px by default), and
the header is sticky. pagination is ignored together with virtual.
| Viloyat | |||
|---|---|---|---|
| 1 | Jasur Karimov | Toshkent | 50 000 so'm |
| 2 | Malika Rahimova | Navoiy | 427 311 so'm |
| 3 | Farhod Tursunov | Samarqand | 804 622 so'm |
| 4 | Mohira Nazarova | Xorazm | 1 181 933 so'm |
| 5 | Jasur Karimov | Buxoro | 1 559 244 so'm |
| 6 | Malika Rahimova | Toshkent | 1 936 555 so'm |
| 7 | Farhod Tursunov | Navoiy | 2 313 866 so'm |
| 8 | Mohira Nazarova | Samarqand | 2 691 177 so'm |
| 9 | Jasur Karimov | Xorazm | 3 068 488 so'm |
| 10 | Malika Rahimova | Buxoro | 3 445 799 so'm |
| 11 | Farhod Tursunov | Toshkent | 3 823 110 so'm |
| 12 | Mohira Nazarova | Navoiy | 4 200 421 so'm |
| 13 | Jasur Karimov | Samarqand | 4 577 732 so'm |
| 14 | Malika Rahimova | Xorazm | 155 043 so'm |
| 15 | Farhod Tursunov | Buxoro | 532 354 so'm |
| 16 | Mohira Nazarova | Toshkent | 909 665 so'm |
| 17 | Jasur Karimov | Navoiy | 1 286 976 so'm |
'use client';
import { formatMoney } from '@akhatmirzo/core';
import { DataTable, type DataTableColumn } from '@akhatmirzo/ui';
import { useMemo } from 'react';
interface Order {
id: number;
customer: string;
region: string;
total: number;
}
const NAMES = ['Jasur Karimov', 'Malika Rahimova', 'Farhod Tursunov', 'Mohira Nazarova'];
const REGIONS = ['Toshkent', 'Samarqand', 'Buxoro', 'Navoiy', 'Xorazm'];
const columns: DataTableColumn<Order>[] = [
{ key: 'id', header: '№', width: 96, sortable: true },
{ key: 'customer', header: 'Mijoz', sortable: true },
{ key: 'region', header: 'Viloyat', filter: 'select' },
{
key: 'total',
header: 'Summa',
numeric: true,
sortable: true,
cell: (o) => formatMoney(o.total),
},
];
export default function DataTableVirtual() {
const orders = useMemo<Order[]>(
() =>
Array.from({ length: 10_000 }, (_, i) => ({
id: i + 1,
customer: NAMES[i % NAMES.length]!,
region: REGIONS[(i * 3) % REGIONS.length]!,
total: 50_000 + ((i * 377_311) % 4_800_000),
})),
[],
);
return (
<DataTable
label="10 000 buyurtma"
data={orders}
columns={columns}
virtual
maxHeight={400}
density="compact"
searchable
columnToggle={false}
className="w-full"
/>
);
}
For lists that aren't tables, the same technique is available as VirtualList.
Density, sticky column and column visibility
density="compact"— 40px rows (the defaultcomfortableis 52px).stickyFirstColumn— the first column (and the selection checkbox) stays put during horizontal scrolling. On a narrow screen, scroll the demo sideways.- The "Ustunlar" (columns) menu (
columnToggle, on by default) shows and hides columns;hideable: falsecolumns aren't listed,hidden: truestarts them hidden. - With
maxHeight, the header sticks inside the table's scroll area.
| Xodim | Lavozim | Bo'lim | Telefon | Maosh | |
|---|---|---|---|---|---|
| Sarvar Abduxoliqov | Frontend dasturchi | IT | +998 90 123 45 67 | 14 500 000 so'm | |
| Gulnora O'rinova | Buxgalter | Moliya | +998 93 555 12 34 | 9 800 000 so'm | |
| Javohir Ismoilov | Dizayner | Marketing | +998 97 700 00 11 | 11 200 000 so'm |
'use client';
import { formatMoney } from '@akhatmirzo/core';
import { DataTable, type DataTableColumn } from '@akhatmirzo/ui';
interface Employee {
id: number;
name: string;
position: string;
department: string;
phone: string;
city: string;
salary: number;
}
const employees: Employee[] = [
{
id: 1,
name: 'Sarvar Abduxoliqov',
position: 'Frontend dasturchi',
department: 'IT',
phone: '+998 90 123 45 67',
city: 'Toshkent',
salary: 14_500_000,
},
{
id: 2,
name: "Gulnora O'rinova",
position: 'Buxgalter',
department: 'Moliya',
phone: '+998 93 555 12 34',
city: 'Samarqand',
salary: 9_800_000,
},
{
id: 3,
name: 'Javohir Ismoilov',
position: 'Dizayner',
department: 'Marketing',
phone: '+998 97 700 00 11',
city: 'Buxoro',
salary: 11_200_000,
},
];
const columns: DataTableColumn<Employee>[] = [
{ key: 'name', header: 'Xodim', width: 200, hideable: false },
{ key: 'position', header: 'Lavozim' },
{ key: 'department', header: "Bo'lim" },
{ key: 'phone', header: 'Telefon', className: 'tabular-nums' },
{ key: 'city', header: 'Shahar', hidden: true },
{ key: 'salary', header: 'Maosh', numeric: true, cell: (e) => formatMoney(e.salary) },
];
export default function DataTableDensity() {
return (
<DataTable
label="Xodimlar"
data={employees}
columns={columns}
density="compact"
stickyFirstColumn
selectable
tableClassName="min-w-[860px]"
className="w-full"
/>
);
}
Cards on phones
By default, a narrow screen scrolls the table horizontally. mobileView="cards" turns each row
into a card (header: value) below 640px. Make your browser window narrower to see it.
| № | Mijoz | Viloyat | Summa | |
|---|---|---|---|---|
| 1042 | Jasur Karimov | Toshkent | 1 250 000 so'm | |
| 1043 | Malika Rahimova | Samarqand | 480 000 so'm | |
| 1044 | Aziz Normatov | Farg'ona | 2 730 000 so'm |
'use client';
import { formatMoney } from '@akhatmirzo/core';
import { DataTable, toast, type DataTableColumn } from '@akhatmirzo/ui';
interface Order {
id: number;
customer: string;
region: string;
total: number;
}
const orders: Order[] = [
{ id: 1042, customer: 'Jasur Karimov', region: 'Toshkent', total: 1_250_000 },
{ id: 1043, customer: 'Malika Rahimova', region: 'Samarqand', total: 480_000 },
{ id: 1044, customer: 'Aziz Normatov', region: "Farg'ona", total: 2_730_000 },
];
const columns: DataTableColumn<Order>[] = [
{ key: 'id', header: '№' },
{ key: 'customer', header: 'Mijoz' },
{ key: 'region', header: 'Viloyat' },
{ key: 'total', header: 'Summa', numeric: true, cell: (o) => formatMoney(o.total) },
];
export default function DataTableCards() {
return (
<DataTable
label="Buyurtmalar"
data={orders}
columns={columns}
mobileView="cards"
selectable
onRowClick={(o) => toast(`Buyurtma #${o.id}`)}
className="w-full"
/>
);
}
Props
Accepts the remaining <div> attributes (on the root). className goes on the root,
tableClassName on the <table>.
Data
| Prop | Type | Default | Description |
|---|---|---|---|
datarequired | readonly T[] | — | Rows |
columnsrequired | readonly DataTableColumn<T>[] | — | Columns (see the table above) |
getRowId | (row: T, index: number) => string | — | Row id (for selection). Defaults to row.id, or the row index |
label | string | — | Table name for screen readers |
Features
| Prop | Type | Default | Description |
|---|---|---|---|
searchable | boolean | false | Global search (300ms, Uzbek-aware) |
selectable | boolean | false | Checkbox column |
selectedIds | string[] | — | Selected rows (controlled) |
defaultSelectedIds | string[] | — | |
onSelectionChange | (ids: string[], rows: T[]) => void | — | |
bulkActions | (rows: T[], clearSelection: () => void) => ReactNode | — | Bulk actions bar |
pagination | false | { pageSize?: number; pageSizeOptions?: number[] } | — | Pagination; off when omitted |
virtual | boolean | { overscan?: number } | false | Virtual scrolling (instead of pages) |
maxHeight | number | string | — | Height of the table area; 480 by default with virtual. Makes the header sticky |
columnToggle | boolean | true | The "Columns" menu |
initialState | Partial<DataTableState> | — | Initial sorting, search, filters and page |
onRowClick | (row: T) => void | — | Called on row click (or Enter) |
rowActions | (row: T) => ReactNode | — | Actions at the end of a row |
toolbar | ReactNode | — | Extra toolbar content (e.g. an "Add" button) |
Server mode
| Prop | Type | Default | Description |
|---|---|---|---|
manual | boolean | false | Sorting, filters and paging are not applied to data |
rowCount | number | — | Total records in manual mode |
onStateChange | (state: DataTableState) => void | — | Called when the state changes (in any mode) |
States and appearance
| Prop | Type | Default | Description |
|---|---|---|---|
loading | boolean | false | No data — skeleton; with data — faded + progress bar |
error | ReactNode | — | Error text, or true |
onRetry | () => void | — | Shows a retry button |
empty | DataTableEmptyState | — | Empty state: { icon, title, description, action } (all ReactNode) |
density | 'comfortable' | 'compact' | 'comfortable' | Row height: 52 / 40px |
stickyFirstColumn | boolean | false | |
mobileView | 'table' | 'cards' | 'table' | Layout on phones (< 640px) |
labels | DataTableLabels | — | All UI strings |
tableClassName | string | — | Class for the <table> |
Strings (labels)
Every string has an Uzbek default; override only the keys you need. Strings that include a number are functions:
<DataTable
labels={{
search: 'Search by customer, № or region',
selected: (n) => `${n} orders selected`,
range: (from, to, total) => `${from}–${to} of ${total}`,
}}
/>Keys: search, searchAria, filters, columns, selectAll, selectRow, selected,
clearSelection, emptyTitle, emptyDescription, noResultsTitle, noResultsDescription,
clearFilters, errorTitle, retry, range, total, noRows, pageSize, prevPage,
nextPage, page, pagination, rowActions, loading, all, min, max, from, to,
removeFilter.
Data attributes
| Attribute | Element | Value |
|---|---|---|
data-row-id | tr | the row id |
data-selected | tr | present on selected rows |
data-sorted | sort button | asc · desc |
data-slot: data-table, data-table-toolbar, data-table-bulk, data-table-scroll,
data-table-footer, data-table-state.
Keyboard
| Key | Action |
|---|---|
Tab | Search, filters, header buttons, rows, row actions |
Enter (on a header) | Sort: ascending → descending → off |
Enter (on a row) | onRowClick |
Space (on a row) | Toggle selection (selectable) |
Rows are focusable only when onRowClick or selectable is set.
Accessibility
- A real
<table>with<th scope="col">; the table is named throughlabel(aria-label). - Sorting is exposed on the header with
aria-sort(ascending/descending/none); sortable headers are<button>s. - Loading sets
aria-busyon the table; the refetch bar is arole="progressbar". - State messages (empty, nothing found) use
role="status"; errors userole="alert". - Selection: each checkbox is labelled
"Qatorni tanlash: <id>"(select row), the header one "Hammasini tanlash" (select all); "N ta tanlandi" is announced viaaria-live. - Pagination is a
<nav aria-label="Sahifalash">, the current page hasaria-current="page", and "1–10 / 248" lives in anaria-liveregion. - Filters: every group is labelled, and each badge's ✕ button has a name like "Holat filtrini olib tashlash" (remove the status filter).
- Virtual mode sets
aria-rowcountandaria-rowindexon each row, so screen readers know the total number of rows.