Data Table Page
Production-ready data table layouts with filtering, sorting, and bulk actions.
Overview
Data table pages are the main operating surface for SaaS dashboards, admin panels, ERP/POS systems, ecommerce back offices, CRM tools, inventory systems, and internal tools. They help users scan records, search and filter, sort and compare, select rows, perform bulk actions, and navigate large datasets with pagination.
Byteion provides both a low-level Table primitive for custom markup and a high-level DataTable component with built-in search, sorting, pagination, row selection, and toolbar integration. This pattern explains how to compose them into production-ready data pages.
Data Table Page is a pattern, not a single component. Use it as a blueprint for composing Byteion primitives — Table or DataTable, Input, Select, Button, Badge, Checkbox, Pagination, Dropdown, Alert, Skeleton, Empty state, Modal, and Toast — into complete data management screens.
What this pattern includes
- Page title and description
- Primary action button (e.g. "New customer")
- Search input — text-based record filtering
- Filters — structured field selectors (status, role, category, date range)
- Sort controls — sortable column headers with direction indicator
- Row selection — checkboxes per row with select-all in header
- Bulk action bar — appears when rows are selected, shows count and actions
- Data table — structured row/column display
- Status badges — colour-coded status indicators with text labels
- Row actions — view, edit, duplicate, delete per row
- Pagination — page navigation with rows-per-page selector
- Rows-per-page selector
- Result count — "Showing 1–10 of 32 customers"
- Loading state — skeleton placeholders
- Empty state — no records message
- Error state — error message with retry action
- Mobile responsive layout — horizontal scroll or card view
Common use cases
User / Customer Management
View, search, filter, and manage user accounts. Bulk enable/disable, change roles, send invites.
Order Management
Track orders by status, date, customer. Filter by fulfillment stage, bulk update status.
Product Catalog
Manage inventory, prices, categories. Search by SKU or name, filter by stock status.
Inventory List
Stock levels, warehouse locations, reorder thresholds. Bulk transfer or adjust stock.
Invoice / Payment Records
Search by invoice number, filter by status, sort by amount or date. Bulk resend or cancel.
Subscription List
View active/churned/trialling subscriptions. Filter by plan, sort by MRR.
Audit Logs
Chronological action log with user, action, resource, timestamp filters. Read-only usually.
Support Tickets
Filter by status, priority, assignee. Bulk assign, sort by created date.
Booking / Reservation List
View bookings by date range, status, resource. Bulk confirm or cancel.
POS Sales History
Point-of-sale transaction log with payment method, amount, cashier. Search by receipt number.
ERP Reports
Procurement, ledger, and operational reports with custom date ranges and filter dimensions.
Team / Member Management
Manage team members, roles, permissions. Bulk invite or remove.
When to use
- Large record lists that need scanning, searching, and filtering
- Admin CRUD screens
- Searchable and filterable datasets
- Records requiring row-level actions (view, edit, delete)
- Records requiring bulk actions (archive, delete, assign)
- Server-side paginated data
- Operational dashboards
When not to use
- Very small lists that fit in a few lines — use List instead
- Visual content grids (image galleries, card-based layouts)
- Feed or timeline content — use Timeline instead
- Step-by-step workflows — use Stepper instead
- Data that needs chart-first analysis — use KPI widgets and charts instead
- Complex comparison views where all items must be visible together on one screen
Full UI example
Customers page with title, search, status filter, new customer button, selected-row bulk action bar, data table with status badges, row actions, and pagination:
Customers
Manage your customer accounts.
| 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 |
HTML example
Complete data table page with accessible markup and semantic tokens:
<!-- Page header -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-4">
<div>
<h1 class="text-xl font-semibold text-foreground">Customers</h1>
<p class="text-sm text-muted-foreground">Manage your customer accounts.</p>
</div>
<div class="flex flex-col sm:flex-row gap-3">
<div class="relative">
<i class="ti ti-search absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm" aria-hidden="true"></i>
<input type="search" placeholder="Search customers…" aria-label="Search customers"
class="h-10 w-full min-w-[220px] rounded-lg border border-border bg-card pl-9 pr-3.5
text-sm text-foreground shadow-field outline-none
placeholder:text-muted-foreground
focus-visible:border-primary-400 focus-visible:shadow-field-focus">
</div>
<button class="inline-flex items-center justify-center gap-2 h-10 rounded-lg
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>
<!-- Filters -->
<div class="flex flex-wrap items-center gap-3 mb-4">
<select aria-label="Filter by status"
class="h-9 rounded-lg border border-border bg-card px-3 text-sm text-foreground
shadow-field outline-none
focus-visible:border-primary-400 focus-visible:shadow-field-focus">
<option>All statuses</option><option>Active</option><option>Inactive</option>
</select>
</div>
<!-- Bulk action bar -->
<div class="flex items-center justify-between px-4 py-3 rounded-lg border border-border bg-muted/30 mb-4">
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
<span>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 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">
</th>
<th class="px-4 py-3 text-left font-medium text-foreground" scope="col"
aria-sort="ascending">
Name <i class="ti ti-arrow-up text-xs" aria-hidden="true"></i>
</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/50 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>
<!-- Pagination -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mt-4">
<p class="text-sm text-muted-foreground">Showing 1–10 of 32 customers</p>
<nav class="flex items-center gap-2" aria-label="Pagination">
<button class="inline-flex items-center justify-center h-8 w-8 rounded-lg
border border-border bg-background text-foreground
hover:bg-muted transition disabled:opacity-40"
disabled aria-label="Previous page">
<i class="ti ti-chevron-left text-sm" aria-hidden="true"></i>
</button>
<button class="inline-flex items-center justify-center h-8 w-8 rounded-lg
bg-primary-600 text-white text-sm font-medium">1</button>
<button class="inline-flex items-center justify-center h-8 w-8 rounded-lg
border border-border bg-background text-foreground
hover:bg-muted transition text-sm">2</button>
<button class="inline-flex items-center justify-center h-8 w-8 rounded-lg
border border-border bg-background text-foreground
hover:bg-muted transition disabled:opacity-40"
aria-label="Next page">
<i class="ti ti-chevron-right text-sm" aria-hidden="true"></i>
</button>
</nav>
</div>
React example
Using DataTable with search, sorting, pagination, row selection, toolbar, and empty state. For custom table markup, use Table with its compound sub-components:
import { DataTable, Button, Badge, Dropdown, Modal, Toast, EmptyState } from '@byteion/react'
import { useState } from '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 onClick={() => handleEdit(r)}>Edit</Dropdown.Item>
<Dropdown.Item onClick={() => handleDelete(r)} tone="danger">Delete</Dropdown.Item>
</Dropdown>
),
},
]
function CustomersPage() {
const [selected, setSelected] = useState<string[]>([])
const [deleting, setDeleting] = 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([])
Toast.success(`${selected.length} customers deleted`)
} catch {
Toast.error('Failed to delete some customers')
} finally {
setDeleting(false)
}
}
return (
<div className="space-y-4">
<h1 className="text-xl font-semibold text-foreground">Customers</h1>
<DataTable
data={data}
columns={columns}
rowKey={(r) => r.id}
selectable
selectedRowIds={selected}
onSelectionChange={setSelected}
searchPlaceholder="Search customers…"
toolbarRight={
<Button onClick={() => /* open create modal */}>
New customer
</Button>
}
emptyState={
<EmptyState
title="No customers yet"
description="Get started by importing your customer data."
/>
}
/>
{selected.length > 0 && (
<div className="flex items-center justify-between rounded-lg border border-border bg-muted/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={handleBulkDelete} loading={deleting}>
Delete
</Button>
</div>
</div>
)}
<Modal open={confirmOpen} onClose={() => setConfirmOpen(false)}>
<Modal.Header>Delete {customer?.name}?</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}>Delete</Button>
</Modal.Footer>
</Modal>
</div>
)
}
Vue example
Using BiDataTable with v-model for search, sort, page, and selection. For custom table markup, use BiTable with BiTableHead, BiTableBody, BiTableRow, BiHeaderCell, and BiTableCell:
<template>
<div class="space-y-4">
<h1 class="text-xl font-semibold text-foreground">Orders</h1>
<BiDataTable
:data="orders"
:columns="columns"
row-key="id"
selectable
v-model:selected-row-ids="selected"
search-placeholder="Search orders…"
>
<template #toolbar-right>
<BiButton @click="openCreate">New order</BiButton>
</template>
<template #empty>
<BiEmptyState title="No orders yet"
description="Create your first order to get started." />
</template>
</BiDataTable>
<!-- Bulk action bar -->
<div v-if="selected.length > 0"
class="flex items-center justify-between rounded-lg border border-border bg-muted/30 px-4 py-3">
<span class="text-sm font-medium text-foreground">
{{ selected.length }} selected
</span>
<div class="flex gap-2">
<BiButton variant="outline" @click="bulkArchive">Archive</BiButton>
<BiButton tone="danger" :loading="deleting" @click="confirmDelete">Delete</BiButton>
</div>
</div>
<!-- Delete confirmation modal -->
<BiModal v-model:open="confirmOpen">
<h3 class="font-semibold text-foreground">Delete {{ selected.length }} order(s)?</h3>
<p class="text-sm text-muted-foreground mt-2">This action cannot be undone.</p>
<template #footer>
<div class="flex justify-end gap-3">
<BiButton variant="outline" @click="confirmOpen = false">Cancel</BiButton>
<BiButton tone="danger" :loading="deleting" @click="bulkDelete">Delete</BiButton>
</div>
</template>
</BiModal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { BiDataTable, BiButton, BiBadge, BiModal, BiEmptyState, BiToast } from '@byteion/vue'
import type { DataTableColumn } from '@byteion/vue'
interface Order { id: string; customer: string; total: number; status: string }
const columns: DataTableColumn<Order>[] = [
{ key: 'customer', label: 'Customer', sortAccessor: (r) => r.customer },
{ key: 'total', label: 'Total', sortAccessor: (r) => r.total,
render: (r) => `$${r.total.toFixed(2)}` },
{ key: 'status', label: 'Status',
render: (r) => <BiBadge :tone="r.status === 'completed' ? 'success' : 'warning'">{{ r.status }}</BiBadge>,
sortAccessor: (r) => r.status },
]
const orders = ref<Order[]>([])
const selected = ref<string[]>([])
const deleting = ref(false)
const confirmOpen = ref(false)
async function bulkDelete() {
deleting.value = true
try {
await fetch('/api/orders/bulk-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: selected.value }),
})
BiToast.success(`${selected.value.length} orders deleted`)
selected.value = []
confirmOpen.value = false
} catch {
BiToast.error('Failed to delete some orders')
} finally {
deleting.value = false
}
}
</script>
Table anatomy
- Page header — page title, description, primary action button.
- Toolbar — search input + filter controls + action buttons.
- Search input — broad text matching across records.
- Filters — structured field selectors (status, role, category, date range).
- Bulk action bar — appears when rows are selected; shows count and bulk action buttons.
- Table container —
overflow-x-autowrapper for horizontal scroll on small screens. - Header row —
<thead>with<th scope="col">columns. - Sortable column headers — clickable headers with
aria-sortand direction indicator. - Row selection checkboxes — checkbox per row + select-all in header.
- Data rows —
<tbody>with row data and hover state. - Status cells — colour-coded badges with text labels.
- Row action menu — dropdown or icon button for view, edit, delete per row.
- Pagination footer — page navigation with page numbers, prev/next buttons.
- Rows-per-page selector — dropdown to change page size.
- Result count — "Showing 1–10 of 32 customers."
Search and filtering
- Use search for broad text matching across multiple fields. Place it prominently above the table.
- Use filters for structured fields like status, role, category, and date range. Place filters adjacent to the search input or in a collapsible filter panel.
- Preserve search and filter state in URL query when deep linking or browser navigation matters (e.g.,
/customers?search=olivia&status=active). - Reset page to 1 when search or filters change — paginating into a filtered subset is confusing.
- Debounce search when it triggers backend requests — wait 300–400ms after the user stops typing.
- Show active filter chips if multiple filters are applied, with a "Clear all" action.
- Show a filtered empty state when no records match the current filters — differentiate from the fully empty state.
Sorting
- Make columns sortable only where sorting is meaningful — name, email, date, amount, status. Avoid sorting columns with mixed or ambiguous values.
- Show the current sort direction with an icon (up arrow for ascending, down arrow for descending).
- Use
aria-sort="ascending"oraria-sort="descending"on the active sortable header. - Preserve sort across pagination — changing the page should not reset the sort order.
- Server-side sorting is recommended for large datasets. Client-side sorting works only when all data is loaded.
Pagination and data loading
- Use server-side pagination for large datasets — never load all records into the browser just to paginate on the client.
- Preserve search, filters, sort, and page in the URL or application state for consistency.
- Show a rows-per-page selector when users need control over density (10, 25, 50, 100).
- Show the result count: "Showing 1–10 of 32 customers."
- Use loading state (skeleton placeholders) while fetching data.
- Use empty state when no records exist (before any filters are applied).
- Use error state with a retry action when data loading fails.
- Test page 1, middle page, last page, totalPages=1, and empty data responses.
- Use the Pagination component with
page,totalPages, andonPageChangeprops. Variants:numbered(default),prev-next,compact.
Bulk actions
- Show the bulk action bar only when rows are selected — hide it when no rows are selected.
- Show the selected row count in the bar: "3 selected".
- Keep selected rows visually clear with a highlighted background.
- Require confirmation for destructive actions — use a Modal to confirm before bulk delete, archive, or any irreversible operation.
- Disable bulk buttons during processing — show a loading indicator and prevent duplicate submissions.
- Clear selection after success — reset selected rows after a successful bulk operation.
- Handle partial failures — if some records succeed and some fail, show a clear message differentiating the results.
- Do not keep selections across filter or page changes unless intentionally designed as a multi-page selection flow.
- Never run destructive actions without confirmation — a single click should never delete or mutate records.
Row actions
- Use row actions for single-record operations: view, edit, duplicate, archive, delete.
- Keep the primary row action predictable — clicking the row or its primary identifier should navigate to the detail view.
- Put secondary actions in a Dropdown or
<button>menu to keep the table uncluttered. - Use accessible labels for row action buttons: "Edit Olivia Martin", "Delete order #1248".
- Confirm destructive row actions with a modal, just like bulk actions.
States
Default
Normal table display with data rows, pagination, and all controls visible.
Loading
Skeleton placeholders while data is being fetched. Toolbar remains visible.
Empty
No records exist. Show an illustration, message, and optional CTA.
Filtered empty
No records match the current filters. Differentiate from the fully empty state. Show "Clear filters" action.
Error
Data loading failed. Show error message with a Retry button.
Selected
One or more rows selected. Bulk action bar visible with count and actions.
Saving / Processing
Bulk or row action in progress. Buttons disabled with loading state.
Partial failure
Some items succeeded, some failed. Show a clear breakdown of results.
Accessibility
- Use semantic
<table>,<thead>,<tbody>,<tr>,<th scope="col">, and<td>elements. - Provide a heading or caption that identifies the table purpose.
- Use
aria-sorton sortable column headers:"ascending","descending", or"none". - Give row selection checkboxes accessible names using
aria-label— "Select Olivia Martin". - Keep keyboard focus visible — never remove
outlinewithout an alternative focus indicator. - Do not rely only on badge colour for status — use text labels inside badges (e.g. "Active" not just a green dot).
- Use descriptive
aria-labelfor row action buttons — "Actions for Olivia Martin" rather than just "Actions". - Wrap pagination in
<nav aria-label="Pagination">. Individual page buttons should be accessible by keyboard. - Announce loading and error states with accessible text where appropriate.
- Keep focus predictable after 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. Byteion'sTablecomponent does this by default (wrappedprop). - Consider a card view for mobile-first workflows where horizontal scroll is impractical — each row becomes a card on mobile.
- Keep the primary identifier column visible (first data column).
- Hide secondary columns on small screens if needed — use responsive utility classes (
hidden sm:table-cell). - Move row actions into a compact menu on mobile — an icon button is sufficient.
- Keep bulk actions accessible on small screens — the bulk action bar should be full-width.
- Avoid more than 5–6 visible columns on mobile — prioritise the most important data.
- Prevent horizontal page overflow — only the table container should scroll, not the entire page.
Performance guidance
- Prefer server-side pagination, filtering, and sorting for large datasets. The backend handles data processing; the UI only renders one page.
- Use virtualisation only when many rows must render in a single view (e.g. 1000+ rows visible at once). For normal paginated tables, server-side pagination is simpler and more performant.
- Debounce backend search — wait 300–400ms after the user stops typing before sending a search request.
- Avoid rendering expensive cell components (rich editors, charts) for every row — keep cells 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.
- Do not load all records into the browser just to paginate on the client for datasets over 500 rows.
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, but always validate server-side.
- Confirm destructive actions with a modal dialog — single clicks should never delete records.
- Audit important admin actions — bulk delete, role changes, data export should be logged server-side.
- 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.
Component breakdown
Table
Semantic <table> primitive with striped, bordered, and wrapped props. Compound sub-components: Table.Head, Table.Body, Table.Row, Table.HeaderCell, Table.Cell.
DataTable
Opinionated data grid with built-in search, sorting, pagination, row selection, and toolbar. Controlled via search, sort, page, selectedRowIds with on*Change callbacks.
Input
Search field with leading icon, type="search", accessible label, and shadow-field styling.
Select
Filter dropdowns for status, role, category, date range, and rows-per-page selector.
Button
Primary action button (New record), bulk action buttons (Archive, Delete), and pagination buttons. Supports loading and tone="danger".
Badge
Status indicators with semantic tone colours and text labels. Use tone prop (success, warning, danger, neutral).
Checkbox
Row selection and select-all with aria-label for accessible names.
Pagination
Page navigation with page, totalPages, onPageChange. Variants: numbered, prev-next, compact.
Dropdown / Menu
Row action menus with Edit, Duplicate, Delete options. Supports items with tone="danger" for destructive actions.
Alert
Error notifications and success confirmations within the table page.
Skeleton / Loading
Loading placeholders for table rows while data is being fetched.
Empty State
Empty dataset display with illustration, title, description, and optional CTA.
Modal / Dialog
Confirmation dialogs for destructive bulk and row actions.
Toast
Non-intrusive success/error feedback after bulk or row actions.
Layout variations
Standard table page
Default layout with toolbar, table, and pagination. Suitable for user management, subscription lists, and CRM records.
Filter-heavy table
Multiple filter controls in a collapsible panel above the table. Common in order management, audit logs, and ERP reports.
Bulk-action table
Prominent bulk action bar for batch operations. Common in ecommerce back offices, inventory, and team management.
Mobile card view
Data displayed as cards instead of table rows on small screens. Each card shows the most important fields for the record.
Expandable rows
Rows that expand inline to show additional details or related data. Common in order details, invoice items, and project tasks.
Inline edit table
Editable fields directly in the table without navigating to a separate form. Common in inventory management, spreadsheet-like tools, and quick-edit workflows.
Virtualised table
For thousands of rows rendered in a single view (not paginated). Uses virtual scrolling to render only visible rows. Use when normal server pagination is not appropriate.
Audit log table
Chronological, read-only table with timestamp, user, action, and resource columns. Often filter-heavy with date range and action type filters. No inline editing.
Best practices
Do
- Put search and filters above the table
- Show the result count
- Preserve search, filter, and sort during pagination
- Use semantic tokens for all surfaces, borders, and text
- Use status badges with text labels, not colour alone
- Confirm destructive actions with a modal
- Show empty, loading, and error states
- Keep row actions consistent across rows
Don't
- Load huge datasets into the browser unnecessarily
- Show too many columns on mobile
- Hide search for large datasets
- Run destructive bulk actions without confirmation
- Reset filters unexpectedly
- Rely only on colour for status indicators
- Use raw colours outside Byteion semantic tokens
- Use virtual scroll when normal server pagination is simpler
Developer tips
/customers?search=olivia&page=2&sort=name&order=asc).
• Coordinate table state with the API client, router, loading state, and toast system.
• Store user preferences for column visibility, rows per page, and sort order when helpful (localStorage or user settings API).
• In Laravel apps, use query params for search/filter/sort/page and return paginated results from the backend. Use request()->query() to read params and Model::paginate() for server-side pagination.
• In React/Vue apps, keep pagination and filters in state or route query. Use DataTable controlled props for full state management.
• Test with empty data, large data, long text, slow API responses, failed API responses, and permission-restricted users.Production checklist
Layout
Toolbar visible
Search and filters are above the table
Result count shown
"Showing 1–10 of 32" is visible
Bulk action bar
Appears only when rows are selected
Pagination works
Page navigation, prev/next, rows-per-page tested
Accessibility
Semantic table
<table>, <thead>, <th scope="col"> used
aria-sort used
Active sort column has aria-sort
Row checkboxes labelled
aria-label="Select {name}" on each checkbox
Pagination nav
<nav aria-label="Pagination"> wrapping controls
Status text visible
Badges have text labels, not colour alone
Focus predictable
Focus does not jump unexpectedly after actions
Data loading
Loading state
Skeleton shown while fetching
Empty state
Empty message shown when no records exist
Error state
Error message + retry on fetch failure
Server pagination
Backend paginates, not the browser, for large datasets
Search / Filter / Sort
Search works
Results update as user types (debounced)
Filters reset
Page resets to 1 when filters change
Sort preserved
Sort direction is maintained across pages
Filtered empty
Different message from fully empty state
Bulk actions
Bar visible on select
Bulk bar appears only when rows selected
Destructive confirmed
Modal confirmation before bulk delete/archive
Loading during action
Buttons disabled + spinner during processing
Selection cleared
Selection resets after successful bulk action
Partial failure
Clear feedback if some items fail
Permissions / Security
Backend enforcement
Server validates permission for every action
Sensitive data masked
PII and secrets are not exposed unnecessarily
Mobile
Horizontal scroll
Table scrolls inside container; page does not overflow
Few columns visible
5–6 columns max; secondary columns hidden
Bulk actions accessible
Bulk bar full-width and tappable
Theme testing
Dark mode
All surfaces, borders, badges work with .dark
Branded theme
Primary palette works across badges, buttons, and select states
Backend integration
Pagination params
Backend accepts page, perPage, search, sort, filter params
Audit logging
Bulk/row destructive actions logged server-side
Related
- Table — semantic table primitive with compound sub-components
- DataTable — opinionated data grid with built-in search, sort, pagination, selection
- Input — search field and form inputs
- Select — filter and rows-per-page dropdowns
- Button — action buttons with loading state
- Badge — status indicators with text labels
- Checkbox — row selection and select-all
- Pagination — page navigation controls
- 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
Unlock Premium Patterns
Get access to copy-paste ready Pro blocks, SaaS templates, and advanced component patterns.
Explore Pro Components