Data Table
Free interactive data table shell with filter bar, toolbar actions, bulk action bar, sortable columns, row selection, pagination, loading/error/empty states, and server-mode hooks.
Overview
The Advanced Data Table is the interactive data table surface for sorting, filtering, pagination, row selection, column visibility, and bulk actions. It extends the basic Table primitive with a toolbar (search, filters), selectable rows, sortable column headers, a bulk action bar, and a pagination footer.
This component lives inside the Data Table Page pattern. Use the page pattern for the full page layout (sidebar, page header, dashboard shell) and the Advanced Data Table for the table surface and interaction model.
Byteion provides a DataTable component in React with built-in controlled state for search, sort, page, and row selection. The Vue DataTable handles search, sort, and pagination internally. For Vue apps requiring selection and bulk actions, compose with semantic HTML tokens as shown below.
What this component includes
- Search / filter toolbar
- Sortable column headers with direction indicators
- Selectable rows with per-row checkboxes
- Select-all checkbox in header
- Bulk action bar (appears when rows are selected)
- Row action menu or button per row
- Status badges with semantic tone colours
- Pagination footer with page navigation
- Rows-per-page selector
- Column visibility toggles (optional)
- Loading state — skeleton placeholders
- Empty state — no-records message
- Error state — error message with retry
- Selected state — highlighted rows with count
Use cases
User / Customer Management
Search, filter, sort, select, and bulk-update user records. Status badges for active/pending/inactive.
Order Management
Filter by status, date range, payment method. Bulk update fulfillment status. Sort by amount or date.
Product Inventory
Search by SKU or name, filter by category or stock level, sort by price or quantity.
CRM Contact Lists
Filter by lead status, assigned rep, date added. Bulk assign or export selected contacts.
Subscription List
Filter by plan, status. Sort by MRR, start date. Bulk cancel or upgrade.
Invoice / Payment Records
Search by invoice number, filter by status, sort by amount or due date.
Audit Logs
Chronological table with date range filters and action type filtering. Read-only, no selection needed.
Support Tickets
Filter by status, priority, assignee. Sort by created date. Bulk reassign tickets.
ERP / POS Reports
Procurement, ledger, and sales reports with custom date ranges and sortable columns.
Ecommerce Product Catalog
Search products, filter by category/status, sort by price/stock, bulk publish or archive.
When to use
- Users need to search, filter, sort, or paginate through records
- Users need to select multiple rows and perform bulk actions
- Users need to hide or show columns based on their workflow
- Users manage operational records (customers, orders, inventory, tickets)
- Admin CRUD screens with sortable, filterable datasets
When not to use
- Very small lists that fit in a few lines — use List or the basic Table instead
- Visual card grids or image galleries — use a card grid instead
- Feed or timeline content — use Timeline
- Chart-first analytics — use KPI widgets and chart components
- Step-by-step workflows — use Stepper
- Comparison screens where all rows must remain visible together
Basic example
Semantic token HTML with accessible table structure, checkboxes, status badges, and row actions:
| Name | Status | Role | Actions | ||
|---|---|---|---|---|---|
| Olivia Martin | olivia.martin@email.com | Active | Admin | ||
| Jackson Lee | jackson.lee@email.com | Pending | Member |
<div class="overflow-x-auto rounded-xl border border-border">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border bg-muted">
<th class="w-12 px-4 py-3 text-left" scope="col">
<input type="checkbox" class="h-4 w-4 rounded border-border accent-primary-600"
aria-label="Select all rows on this page">
</th>
<th class="px-4 py-3 text-left font-medium text-foreground" scope="col">Name</th>
<th class="px-4 py-3 text-left font-medium text-foreground" scope="col">Email</th>
<th class="px-4 py-3 text-left font-medium text-foreground" scope="col">Status</th>
<th class="px-4 py-3 text-left font-medium text-foreground" scope="col">Role</th>
<th class="w-20 px-4 py-3 text-right font-medium text-foreground" scope="col">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr class="hover:bg-muted/30 transition">
<td class="px-4 py-3">
<input type="checkbox" class="h-4 w-4 rounded border-border accent-primary-600"
aria-label="Select Olivia Martin">
</td>
<td class="px-4 py-3 font-medium text-foreground">Olivia Martin</td>
<td class="px-4 py-3 text-muted-foreground">olivia.martin@email.com</td>
<td class="px-4 py-3">
<span class="inline-flex items-center gap-1.5 rounded-full bg-success-50
px-2.5 py-0.5 text-xs font-medium text-success-700">
Active
</span>
</td>
<td class="px-4 py-3 text-muted-foreground">Admin</td>
<td class="px-4 py-3 text-right">
<button class="p-1 rounded-lg hover:bg-muted text-muted-foreground
hover:text-foreground transition"
aria-label="Actions for Olivia Martin">
<i class="ti ti-dots-vertical" aria-hidden="true"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
Advanced example
Full-featured data table with toolbar, bulk action bar, pagination, and record count:
| Name | Status | Role | Actions | ||
|---|---|---|---|---|---|
| Olivia Martin | olivia.martin@email.com | Active | Admin | ||
| Jackson Lee | jackson.lee@email.com | Pending | Member | ||
| Isabella Chen | isabella.chen@email.com | Active | Member |
<!-- Data table shell -->
<div class="overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
<!-- Toolbar -->
<div class="flex flex-col gap-3 border-b border-border px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-1 flex-wrap items-center gap-2">
<div class="relative w-full lg:max-w-sm">
<i class="ti ti-search pointer-events-none absolute left-3 top-1/2 -translate-y-1/2
text-base text-muted-foreground" aria-hidden="true"></i>
<input type="search" placeholder="Search customers…" aria-label="Search customers"
class="h-10 w-full rounded-xl border border-border bg-card pl-9 pr-3
text-sm text-foreground outline-none transition shadow-field
hover:shadow-field-hover focus-visible:border-primary-400
focus-visible:shadow-field-focus
placeholder:text-muted-foreground placeholder:font-normal font-medium">
</div>
<select aria-label="Filter by status"
class="h-10 rounded-xl border border-border bg-card px-3 text-sm
text-foreground outline-none shadow-field
focus-visible:border-primary-400 focus-visible:shadow-field-focus">
<option>All statuses</option><option>Active</option><option>Pending</option><option>Inactive</option>
</select>
</div>
<div class="flex items-center gap-2">
<button class="inline-flex items-center justify-center gap-2 h-10 rounded-xl
border border-border bg-background px-4 text-sm font-medium
text-foreground shadow-sm hover:bg-muted transition">
<i class="ti ti-columns-3 text-base" aria-hidden="true"></i> Columns
</button>
<button class="inline-flex items-center justify-center gap-2 h-10 rounded-xl
bg-primary-600 px-4 text-sm font-medium text-white shadow-sm
hover:bg-primary-700 transition">
<i class="ti ti-plus text-base" aria-hidden="true"></i> New customer
</button>
</div>
</div>
<!-- Bulk action bar (shown when rows are selected) -->
<div class="flex items-center justify-between border-b border-border bg-primary-50/30 px-4 py-3">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-foreground">2 selected</span>
</div>
<div class="flex items-center gap-2">
<button class="inline-flex items-center justify-center h-8 rounded-lg border border-border
bg-background px-3 text-sm font-medium text-foreground shadow-sm
hover:bg-muted transition">Archive</button>
<button class="inline-flex items-center justify-center h-8 rounded-lg bg-danger-600 px-3
text-sm font-medium text-white shadow-sm hover:bg-danger-700 transition">Delete</button>
</div>
</div>
<!-- Table -->
<div class="overflow-x-auto">
<table class="min-w-[760px] w-full">
<thead>
<tr class="border-b border-border">
<th class="w-10 px-4 py-3">
<input type="checkbox" class="h-4 w-4 cursor-pointer rounded border-border
bg-card accent-primary-600"
aria-label="Select all on page">
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground" scope="col">
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground"
aria-sort="ascending">
Name <i class="ti ti-arrow-up text-xs" aria-hidden="true"></i>
</button>
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground" scope="col">
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground">
Email <i class="ti ti-arrows-up-down text-xs opacity-40" aria-hidden="true"></i>
</button>
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground" scope="col">
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground">
Status <i class="ti ti-arrows-up-down text-xs opacity-40" aria-hidden="true"></i>
</button>
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground" scope="col">Role</th>
<th class="w-20 px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-muted-foreground" scope="col">Actions</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-border hover:bg-muted/40" data-bi-state="selected">
<td class="w-10 px-4 py-3">
<input type="checkbox" checked class="h-4 w-4 cursor-pointer rounded
border-border bg-card accent-primary-600"
aria-label="Select Olivia Martin">
</td>
<td class="px-4 py-3 text-sm font-medium text-foreground">Olivia Martin</td>
<td class="px-4 py-3 text-sm text-muted-foreground">olivia.martin@email.com</td>
<td class="px-4 py-3 text-sm">
<span class="inline-flex items-center gap-1.5 rounded-full bg-success-50
px-2.5 py-0.5 text-xs font-medium text-success-700">Active</span>
</td>
<td class="px-4 py-3 text-sm text-muted-foreground">Admin</td>
<td class="px-4 py-3 text-right text-sm">
<button class="p-1 rounded-lg hover:bg-muted text-muted-foreground
hover:text-foreground transition"
aria-label="Actions for Olivia Martin">
<i class="ti ti-dots-vertical" aria-hidden="true"></i>
</button>
</td>
</tr>
<tr class="border-b border-border hover:bg-muted/40">
<td class="w-10 px-4 py-3">
<input type="checkbox" class="h-4 w-4 cursor-pointer rounded border-border
bg-card accent-primary-600"
aria-label="Select Jackson Lee">
</td>
<td class="px-4 py-3 text-sm font-medium text-foreground">Jackson Lee</td>
<td class="px-4 py-3 text-sm text-muted-foreground">jackson.lee@email.com</td>
<td class="px-4 py-3 text-sm">
<span class="inline-flex items-center gap-1.5 rounded-full bg-warning-50
px-2.5 py-0.5 text-xs font-medium text-warning-700">Pending</span>
</td>
<td class="px-4 py-3 text-sm text-muted-foreground">Member</td>
<td class="px-4 py-3 text-right text-sm">
<button class="p-1 rounded-lg hover:bg-muted text-muted-foreground
hover:text-foreground transition"
aria-label="Actions for Jackson Lee">
<i class="ti ti-dots-vertical" aria-hidden="true"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Footer -->
<div class="flex flex-col gap-3 border-t border-border px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-4">
<p class="text-sm text-muted-foreground">2 of 32 rows selected.</p>
<p class="text-sm text-muted-foreground">Showing 1–10 of 32 customers.</p>
</div>
<nav class="flex items-center gap-1" aria-label="Pagination">
<button type="button" disabled aria-label="Previous"
class="inline-flex h-8 w-8 items-center justify-center rounded-md
text-sm text-muted-foreground hover:bg-muted disabled:opacity-50">
<i class="ti ti-chevron-left text-base" aria-hidden="true"></i>
</button>
<button type="button" aria-current="page"
class="inline-flex h-8 w-8 items-center justify-center rounded-md
bg-foreground text-sm font-medium text-background">1</button>
<button type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md
text-sm text-muted-foreground hover:bg-muted">2</button>
<button type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md
text-sm text-muted-foreground hover:bg-muted">3</button>
<button type="button" aria-label="Next"
class="inline-flex h-8 w-8 items-center justify-center rounded-md
text-sm text-muted-foreground hover:bg-muted disabled:opacity-50">
<i class="ti ti-chevron-right text-base" aria-hidden="true"></i>
</button>
</nav>
</div>
</div>
React example
Using the DataTable component from @byteion/react with controlled state for search, sort, pagination, and row selection:
import { useState } from 'react'
import { DataTable, Badge, Button, Dropdown, Modal } from '@byteion/react'
import type { DataTableColumn } from '@byteion/react'
interface Customer {
id: string
name: string
email: string
status: 'active' | 'pending' | 'inactive'
role: string
}
const columns: DataTableColumn[] = [
{ key: 'name', label: 'Name', sortAccessor: (r) => r.name },
{ key: 'email', label: 'Email' },
{
key: 'status', label: 'Status',
render: (r) => (
<Badge tone={r.status === 'active' ? 'success' : r.status === 'pending' ? 'warning' : 'neutral'}>
{r.status}
</Badge>
),
sortAccessor: (r) => r.status,
},
{ key: 'role', label: 'Role', sortAccessor: (r) => r.role },
{
key: 'actions', label: '', align: 'right',
render: (r) => (
<Dropdown>
<Dropdown.Item>View</Dropdown.Item>
<Dropdown.Item>Edit</Dropdown.Item>
<Dropdown.Item tone="danger">Delete</Dropdown.Item>
</Dropdown>
),
},
]
function CustomersTable() {
const [selected, setSelected] = useState<string[]>([])
const [search, setSearch] = useState('')
const [sort, setSort] = useState<{ key: string; direction: 'asc' | 'desc' } | null>(null)
const [page, setPage] = useState(1)
const [deleting, setDeleting] = useState(false)
const [confirmOpen, setConfirmOpen] = useState(false)
const handleBulkDelete = async () => {
setDeleting(true)
try {
await fetch('/api/customers/bulk-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: selected }),
})
setSelected([])
setConfirmOpen(false)
} finally {
setDeleting(false)
}
}
return (
<div>
<DataTable
data={data}
columns={columns}
rowKey={(r) => r.id}
search={search}
onSearchChange={(q) => { setSearch(q); setPage(1) }}
sort={sort}
onSortChange={(s) => { setSort(s); setPage(1) }}
page={page}
onPageChange={setPage}
selectable
selectedRowIds={selected}
onSelectionChange={setSelected}
searchPlaceholder="Search customers…"
toolbarRight={
<Button>New customer</Button>
}
/>
{selected.length > 0 && (
<div className="mt-3 flex items-center justify-between rounded-lg border border-border bg-primary-50/30 px-4 py-3">
<span className="text-sm font-medium text-foreground">
{selected.length} selected
</span>
<div className="flex gap-2">
<Button variant="outline">Archive</Button>
<Button tone="danger" onClick={() => setConfirmOpen(true)}>Delete</Button>
</div>
</div>
)}
<Modal open={confirmOpen} onClose={() => setConfirmOpen(false)}>
<Modal.Header>Delete {selected.length} customer(s)?</Modal.Header>
<Modal.Body>This action cannot be undone.</Modal.Body>
<Modal.Footer>
<Button variant="outline" onClick={() => setConfirmOpen(false)}>Cancel</Button>
<Button tone="danger" loading={deleting} onClick={handleBulkDelete}>Delete</Button>
</Modal.Footer>
</Modal>
</div>
)
}
Vue example
The Vue BiDataTable component handles search, sort, and pagination internally with data and columns props. For row selection and bulk actions, compose with semantic HTML using Byteion tokens. This example shows a custom data table with selection state management:
<template>
<div>
<!-- Option A: Use BiDataTable for search/sort/pagination -->
<BiDataTable
:data="customers"
:columns="columns"
row-key="id"
search-placeholder="Search customers…"
hide-search
/>
<!-- Option B: Custom table with selection and bulk actions -->
<div class="overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
<!-- Toolbar -->
<div class="border-b border-border px-4 py-4">
<div class="relative w-full lg:max-w-sm">
<i class="ti ti-search pointer-events-none absolute left-3 top-1/2 -translate-y-1/2
text-base text-muted-foreground" aria-hidden="true"></i>
<input v-model="search" type="search" placeholder="Search customers…"
aria-label="Search customers"
class="h-10 w-full rounded-xl border border-border bg-card pl-9 pr-3
text-sm text-foreground outline-none shadow-field
focus-visible:border-primary-400 focus-visible:shadow-field-focus
placeholder:text-muted-foreground">
</div>
</div>
<!-- Bulk action bar -->
<div v-if="selected.length > 0"
class="flex items-center justify-between border-b border-border bg-primary-50/30 px-4 py-3">
<span class="text-sm font-medium text-foreground">{{ selected.length }} selected</span>
<div class="flex gap-2">
<button class="inline-flex h-8 items-center justify-center rounded-lg border border-border
bg-background px-3 text-sm font-medium text-foreground shadow-sm
hover:bg-muted transition">Archive</button>
<button @click="confirmOpen = true"
class="inline-flex h-8 items-center justify-center rounded-lg bg-danger-600 px-3
text-sm font-medium text-white shadow-sm hover:bg-danger-700 transition">Delete</button>
</div>
</div>
<!-- Custom table -->
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border">
<th class="w-10 px-4 py-3">
<input type="checkbox" :checked="allSelected" @change="toggleAll"
class="h-4 w-4 cursor-pointer rounded border-border accent-primary-600"
aria-label="Select all on page">
</th>
<th v-for="col in columns" :key="col.key"
class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{{ col.header ?? col.key }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in pageRows" :key="row.id"
class="border-b border-border hover:bg-muted/40 transition"
:class="selected.includes(row.id) && 'bg-primary-50/30'">
<td class="w-10 px-4 py-3">
<input type="checkbox" :checked="selected.includes(row.id)"
@change="toggleRow(row.id)"
class="h-4 w-4 cursor-pointer rounded border-border accent-primary-600"
:aria-label="`Select ${row.name}`">
</td>
<td v-for="col in columns" :key="col.key"
class="px-4 py-3 text-sm text-foreground">
{{ row[col.key] }}
</td>
</tr>
<tr v-if="pageRows.length === 0">
<td :colspan="columns.length + 1" class="px-4 py-12 text-center text-sm text-muted-foreground">
No customers found.
</td>
</tr>
</tbody>
</table>
</div>
<!-- Footer -->
<div class="flex flex-col gap-3 border-t border-border px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-sm text-muted-foreground">
{{ selected.length }} of {{ customers.length }} rows selected. Showing {{ pageRows.length }} of {{ customers.length }}.
</p>
<nav class="flex items-center gap-1" aria-label="Pagination">
<button :disabled="page === 1" @click="page--"
class="inline-flex h-8 w-8 items-center justify-center rounded-md
text-sm text-muted-foreground hover:bg-muted disabled:opacity-50"
aria-label="Previous page">
<i class="ti ti-chevron-left text-base" aria-hidden="true"></i>
</button>
<span class="px-2 text-sm text-foreground">{{ page }} of {{ totalPages }}</span>
<button :disabled="page === totalPages" @click="page++"
class="inline-flex h-8 w-8 items-center justify-center rounded-md
text-sm text-muted-foreground hover:bg-muted disabled:opacity-50"
aria-label="Next page">
<i class="ti ti-chevron-right text-base" aria-hidden="true"></i>
</button>
</nav>
</div>
</div>
<!-- Delete confirmation modal -->
<div v-if="confirmOpen" class="fixed inset-0 z-50 flex items-center justify-center"
role="dialog" aria-modal="true" aria-labelledby="confirm-title">
<div class="absolute inset-0 bg-foreground/50" @click="confirmOpen = false"></div>
<div class="relative rounded-xl border border-border bg-card p-6 shadow-lg max-w-md w-full mx-4">
<h3 id="confirm-title" class="font-semibold text-foreground">
Delete {{ selected.length }} customer(s)?
</h3>
<p class="text-sm text-muted-foreground mt-2">This action cannot be undone.</p>
<div class="flex justify-end gap-3 mt-6">
<button @click="confirmOpen = false"
class="inline-flex h-9 items-center justify-center rounded-lg border border-border
bg-background px-4 text-sm font-medium text-foreground shadow-sm
hover:bg-muted transition">Cancel</button>
<button :disabled="deleting" @click="handleBulkDelete"
class="inline-flex h-9 items-center justify-center rounded-lg bg-danger-600 px-4
text-sm font-medium text-white shadow-sm hover:bg-danger-700
transition disabled:opacity-60">
{{ deleting ? 'Deleting…' : 'Delete' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { BiDataTable } from '@byteion/vue'
import type { DataTableColumn } from '@byteion/vue'
interface Customer { id: string; name: string; email: string; status: string; role: string }
const customers = ref<Customer[]>([])
const columns: DataTableColumn<Customer>[] = [
{ key: 'name', header: 'Name', sortAccessor: (r) => r.name },
{ key: 'email', header: 'Email' },
{ key: 'status', header: 'Status', sortAccessor: (r) => r.status },
{ key: 'role', header: 'Role' },
]
// Selection states (custom implementation)
const selected = ref<string[]>([])
const page = ref(1)
const pageSize = 5
const search = ref('')
const deleting = ref(false)
const confirmOpen = ref(false)
const filtered = computed(() => {
if (!search.value) return customers.value
const q = search.value.toLowerCase()
return customers.value.filter((r) =>
Object.values(r).some((v) => String(v ?? '').toLowerCase().includes(q))
)
})
const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / pageSize)))
const pageRows = computed(() => filtered.value.slice((page.value - 1) * pageSize, page.value * pageSize))
const allSelected = computed(() => pageRows.value.length > 0 && pageRows.value.every((r) => selected.value.includes(r.id)))
function toggleAll() {
const ids = pageRows.value.map((r) => r.id)
allSelected.value
? selected.value = selected.value.filter((id) => !ids.includes(id))
: selected.value = [...new Set([...selected.value, ...ids])]
}
function toggleRow(id: string) {
selected.value = selected.value.includes(id)
? selected.value.filter((x) => x !== id)
: [...selected.value, id]
}
async function handleBulkDelete() {
deleting.value = true
try {
await fetch('/api/customers/bulk-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: selected.value }),
})
selected.value = []
confirmOpen.value = false
} finally {
deleting.value = false
}
}
</script>
Architecture
The Advanced Data Table is composed of four independent layers:
- Toolbar — search input, filter controls, column visibility toggle, and primary action button (e.g. "New customer"). Can show right-aligned extras for export, refresh, or bulk action triggers.
- Bulk action bar — conditional bar that appears when rows are selected. Shows selected count and actions (Archive, Delete, Assign, etc.). Should include confirmation for destructive actions.
- Table body — the semantic table with header row, data rows, selection checkboxes, sortable column headers, status badges, and row action menus.
- Footer — result count ("Showing 1–10 of 32"), rows-per-page selector, and pagination controls.
Each layer can be used independently. For example, an audit log table may omit the bulk action bar and selection, while an order management table may require all four layers.
Column sorting
- Make columns sortable only where sorting is meaningful — name, date, amount, status, email. Avoid columns with mixed or ambiguous values.
- Show the current sort direction with an icon (up arrow for ascending, down arrow for descending). Use a neutral sort icon (
ti-arrows-up-down) with reduced opacity for non-sorted columns. - Use
aria-sort="ascending",aria-sort="descending", or omit it on non-sorted columns. - Toggle direction on click: none → asc → desc → none. Three-state toggling allows the user to clear sorting.
- Preserve sort across pagination — changing the page should not reset the sort order.
- Reset page to 1 when sort changes if the backend handles sorting (the top of the sorted dataset changes).
- Prefer server-side sorting for datasets over 500 rows. Client-side
sortAccessorworks when all data is loaded.
Filtering and search
- Use search for broad text matching across multiple fields. The React
DataTablesearches by default across all column values; override withsearchMatchfor custom logic. - Use filters for structured fields like status, role, category, and date range. Place filters next to the search input in the toolbar.
- Debounce backend search — wait 300–400ms after the user stops typing before sending API requests.
- Preserve filter state in URL query when deep linking matters (e.g.
/customers?search=olivia&status=active). - Show active filter indicators (e.g. filter chips, badge counts) when filters are applied.
- Provide a reset filters action to clear all filters at once.
- Show a filtered empty state ("No customers match your search") distinct from the fully empty state ("No customers yet").
Pagination and data loading
- Use server-side pagination for large datasets — never load all records into the browser just to paginate on the client.
- Keep page, search, sort, and filter state together — reset page to 1 when search or filters change.
- Show a loading state while fetching data (skeleton rows or a subtle shimmer).
- Show an error state with a retry action when data loading fails.
- Show an empty state when no records exist (before any filters are applied).
- Show the result count: "Showing 1–10 of 32 customers." With selection enabled, show: "2 of 32 rows selected."
- Test page 1, middle pages, last page, totalPages=1, and empty data responses.
Row selection and bulk actions
- Show the bulk action bar only when rows are selected — hide it entirely when no rows are selected.
- Show the selected count: "3 selected". Keep the count visible in the bulk bar.
- Support select all visible rows via the header checkbox. The React
DataTabletoggles only rows on the current page. - Require confirmation for destructive actions — use a Modal before executing bulk delete, archive, or any irreversible operation.
- Disable actions while processing — show a loading state and prevent duplicate submissions.
- Handle partial failure — if some items succeed and some fail, show a clear message detailing the results.
- Clear selection after success — reset the selected array after a successful bulk operation.
- Do not keep selection across filter or page changes unless the product intentionally requires multi-page selection.
- Backend must enforce permissions for bulk actions — never rely on UI-only restrictions.
- Audit important bulk actions — bulk delete, export, and role changes should be logged server-side.
Column visibility
- Allow users to hide secondary columns via a dropdown or panel toggled from the toolbar.
- Keep essential identity columns visible — name, ID, primary identifier — and never let them be hidden.
- Persist column visibility preferences when helpful (localStorage or user preferences API).
- Do not hide action or status columns by default — users need status badges and action menus.
- On mobile, automatically hide less important columns using responsive utility classes (
hidden sm:table-cell).
Row actions
- Provide a direct primary action where possible — clicking the row's name or ID should navigate to the detail view.
- Place secondary actions (Edit, Duplicate, Archive, Delete) in a Dropdown menu triggered by an icon button.
- Use accessible labels: "Actions for Olivia Martin" instead of just "Actions".
- Confirm destructive row actions with a modal, just like bulk actions.
- Hide or disable actions based on user permissions, but always validate server-side.
States
Default
Normal table with data rows, toolbar, and pagination. All controls visible.
Hover
Row background changes on hover (hover:bg-muted/40) to indicate interactivity.
Selected
Selected row gets highlighted background (bg-primary-50/30). Bulk action bar appears with count.
Loading
Data being fetched. Show skeleton placeholders or a subtle shimmer. Toolbar remains visible.
Empty
No records exist. Show a centred message, optional illustration, and CTA to create the first record.
Filtered empty
No records match the current filters. Differentiate from fully empty state. Provide clear filters action.
Error
Data loading failed. Show error message with a Retry button. Toolbar remains available.
Processing
Bulk or row action in progress. Action buttons disabled with loading indicator.
Partial failure
Some items in a bulk action succeeded, some failed. Show a clear breakdown.
Accessibility
- Use semantic HTML:
<table>,<thead>,<tbody>,<tr>,<th scope="col">,<td>. - Use
aria-sorton sortable column headers —"ascending","descending", or omit if not sorted. - Row selection checkboxes need descriptive accessible names:
aria-label="Select Olivia Martin". - The select-all checkbox must clearly describe its scope:
aria-label="Select all on page". - Status badges must include text labels, not rely on colour alone.
- Row action buttons need descriptive accessible names:
aria-label="Actions for Olivia Martin". - Wrap pagination in
<nav aria-label="Pagination">. Page buttons should be keyboard accessible. - Bulk action status and selected count should be readable as text for screen readers.
- Announce loading and error states with appropriate live regions or accessible text.
- Keep visible focus states — never remove
outlinewithout providing an alternative indicator. - Keep focus predictable after sorting, filtering, pagination, and bulk actions — do not move focus unexpectedly.
Responsive behaviour
- Use horizontal scroll inside the table container (
overflow-x-auto) for dense tables on small screens. - Consider a card view for mobile-first workflows where horizontal scroll is impractical.
- Keep the primary identifier column visible — it's the user's anchor for understanding each row.
- Hide secondary columns on small screens using responsive utility classes (
hidden sm:table-cell). - Move row actions into a compact icon button on mobile — a
ti ti-dots-verticalmenu trigger. - Avoid more than 5–6 visible columns on mobile — prioritise the most meaningful data.
- Keep the bulk action bar full-width and accessible on small screens.
Performance guidance
- Prefer server-side pagination, filtering, and sorting for datasets over 500 rows. The backend should handle
?page=2&perPage=25&search=olivia&sort=name&order=ascquery parameters. - Use virtualisation only when many rows must remain visible in a single client-side view (1000+ visible rows at once). For paginated tables, server-side pagination is simpler and more performant.
- Debounce backend search — wait 300–400ms after the user stops typing before making API calls.
- Avoid expensive cell renderers (rich text editors, charts) inside table cells. Keep row rendering lightweight.
- Memoise derived rows in React/Vue when filtering or sorting large client-side datasets.
- Cache query results where appropriate — avoid re-fetching the same page if the user navigates away and back.
Security and permissions
- Backend must enforce permissions for every row action and bulk action — never rely on UI-only hiding.
- Hide or disable actions based on user permissions in the UI only as a convenience; always validate server-side.
- Confirm destructive actions — a single click should never delete or mutate records. Use a modal dialog.
- Audit important admin actions — bulk delete, data export, role changes should be logged server-side with user, action, and timestamp.
- Avoid exposing sensitive columns unnecessarily (full card numbers, personal data).
- Mask sensitive data where needed — show only last 4 digits of phone numbers or partial email addresses.
Best practices
Do
- Keep search and filters above the table
- Show selected count and result count
- Preserve search, filter, and sort across pagination
- Use semantic tokens for all surfaces, borders, and text
- Use text labels inside status badges
- Confirm destructive actions with a modal
- Test empty, loading, error, and large-data states
Don't
- Show too many columns on mobile
- Run destructive bulk actions without confirmation
- Rely only on colour for status indicators
- Reset filters unexpectedly
- Load huge datasets into the browser unnecessarily
- Use raw colours outside Byteion semantic tokens
- Hide all row actions inside a menu when one action is clearly primary
Developer tips
/customers?search=olivia&page=2&sort=name&order=asc.
• Store user preferences for visible columns, rows per page, and sort order when helpful (localStorage or API).
• Coordinate table state with the API client, router, loading state, permission context, and toast/notification system.
• In Laravel apps, use query params for search/filter/sort/page and return paginated results with Model::paginate().
• In React apps, use DataTable controlled props (search, sort, page, selectedRowIds + on*Change) for full state management.
• In Vue apps, BiDataTable manages search/sort/page internally. For selection and bulk actions, compose a custom table with semantic tokens as shown in the Vue example.
• Test with empty data, long text, many columns, slow API responses, failed API responses, and permission-restricted users.Related
- Table — semantic table primitive with compound sub-components
- Data Table Page — full page layout pattern
- Pagination — page navigation component
- Input — search field
- Select — filter dropdowns
- Button — action buttons with loading state
- Badge — status indicators
- Checkbox — row selection
- Dropdown / Menu — row action menus
- Alert — error and success messages
- Empty State — no-data display
- Skeleton / Loading — loading placeholders
- Modal / Dialog — destructive confirmation
- Toast — success and error notifications
Runtime events
Byteion keeps framework APIs idiomatic: HTML uses DOM custom events, React uses callback props, and Vue uses emitted events / v-model updates.
HTML / JS events
bi:data-table:errorbi:data-table:filter-changebi:data-table:loadbi:data-table:page-changebi:data-table:per-page-changebi:data-table:renderbi:data-table:requestbi:data-table:search-changebi:data-table:selection-changebi:data-table:sort-changebyteion:data-table-errorbyteion:data-table-filter-changebyteion:data-table-loadbyteion:data-table-page-changebyteion:data-table-per-page-changebyteion:data-table-renderbyteion:data-table-requestbyteion:data-table-search-changebyteion:data-table-selection-changebyteion:data-table-sort-change
React callbacks
onSearchChange(q: string) => voidonSortChange(sort: SortState | null) => voidonPageChange(page: number) => voidonPerPageChange(perPage: number) => voidonSelectionChange(ids: string[]) => void
Vue emits
update:search[value: string]update:sort[value: DataTableSortState | null]update:page[value: number]update:perPage[value: number]update:selectedRowIds[value: string[]]selectionChange[value: string[]]
Remote / AJAX
Server data
Add data-bi-source and data-bi-server-mode="true" to request rows from a backend instead of relying only on DOM filtering. The runtime sends search, page, per-page, and sort query parameters and emits request/load/error events with the normalized payload.
Attributes
data-bi-sourcedata-bi-server-modedata-bi-search-paramdata-bi-page-paramdata-bi-per-page-paramdata-bi-sort-paramdata-bi-data-table-loadingdata-bi-data-table-emptydata-bi-data-table-error
Runtime events
byteion:data-table-requestbyteion:data-table-loadbyteion:data-table-errorbyteion:data-table-search-changebyteion:data-table-sort-changebyteion:data-table-page-changebyteion:data-table-selection-change
Accessibility: loading sets aria-busy="true"; empty states use role="status"; error states use alert/status messaging where the runtime renders remote status rows.
Unlock Premium Patterns
Get access to copy-paste ready Pro blocks, SaaS templates, and advanced component patterns.
Explore Pro Components