@toniel/laravel-tanstack-datatable
Laravel TanStack DataTable
Vue 3 DataTable components for Laravel pagination with TanStack Query and TanStack Table.
Companion Package: This package works together with
@toniel/laravel-tanstack-pagination- the core composables library.
Installation
npm install @toniel/laravel-tanstack-datatable @toniel/laravel-tanstack-pagination
# or
yarn add @toniel/laravel-tanstack-datatable @toniel/laravel-tanstack-pagination
# or
pnpm add @toniel/laravel-tanstack-datatable @toniel/laravel-tanstack-pagination
# or
bun add @toniel/laravel-tanstack-datatable @toniel/laravel-tanstack-pagination
Peer Dependencies
This package requires the following peer dependencies:
npm install vue @tanstack/vue-table @toniel/laravel-tanstack-pagination \
clsx tailwind-merge @lucide/vue
| Peer | Range |
|---|---|
vue |
^3.0.0 |
@tanstack/vue-table |
^9.0.0 |
@toniel/laravel-tanstack-pagination |
^0.1.0 |
clsx |
^2.0.0 |
tailwind-merge |
^2.0.0 || ^3.0.0 |
@lucide/vue |
^1.0.0 |
Icons moved packages in 0.3.0.
lucide-vue-nextis deprecated upstream in favour of@lucide/vue. If you are upgrading from0.2.x, swap the package — the icon names are unchanged. See Migrating from lucide-vue-next.
Requires TanStack Table v9. This package uses the v9
useTableAPI. If you are still on@tanstack/vue-tablev8, stay on@toniel/laravel-tanstack-datatable@0.1.x. See Migrating to v9 below.
Tailwind Setup (required)
This package ships no stylesheet — there is no style.css to import. The
components are styled with Tailwind utility classes that live inside the shipped
JavaScript, so Tailwind in your app has to scan those files or every class is
purged and the table renders unstyled.
Add the package's dist to your content globs:
// tailwind.config.js
export default {
content: [
'./resources/**/*.{vue,js,ts}',
'./node_modules/@toniel/laravel-tanstack-datatable/dist/**/*.{js,mjs,cjs}',
],
darkMode: 'class',
// ...
}
Tailwind v4 users declare the same thing in CSS:
@import 'tailwindcss';
@source '../../node_modules/@toniel/laravel-tanstack-datatable/dist';
shadcn-vue CSS variables are also required. The components use semantic
classes (bg-background, text-muted-foreground, border-input,
focus-visible:ring-ring, bg-accent, text-primary) that resolve through the
shadcn-vue theme variables. If your project was not set up with
shadcn-vue, define
--background, --foreground, --muted, --muted-foreground, --border,
--input, --ring, --accent, --accent-foreground, and --primary in your
base layer — otherwise those elements fall back to transparent or inherit.
Quick Start
Basic Usage
<script setup lang="ts">
import { usePagination } from '@toniel/laravel-tanstack-pagination'
import { DataTable, type DataTableFeatures } from '@toniel/laravel-tanstack-datatable'
import { createColumnHelper } from '@tanstack/vue-table'
import axios from 'axios'
// Define your data type
interface User {
id: number
name: string
email: string
}
// Create columns — v9 takes the feature set as the first generic
const columnHelper = createColumnHelper<DataTableFeatures, User>()
const columns = [
columnHelper.accessor('id', {
header: 'ID',
}),
columnHelper.accessor('name', {
header: 'Name',
}),
columnHelper.accessor('email', {
header: 'Email',
}),
]
// Use pagination composable
const {
tableData,
pagination,
search,
currentPerPage,
sortBy,
sortDirection,
isLoading,
error,
handlePageChange,
handlePerPageChange,
handleSearchChange,
handleSortChange,
refetch,
} = usePagination(
(filters) => axios.get('/api/users', { params: filters }),
{ queryKey: 'users', defaultPerPage: 10 }
)
</script>
<template>
<DataTable
:data="tableData"
:columns="columns"
:pagination="pagination"
:is-loading="isLoading"
:error="error"
:search="search"
:current-per-page="currentPerPage"
:sort-by="sortBy"
:sort-direction="sortDirection"
title="Users"
item-name="users"
@page-change="handlePageChange"
@per-page-change="handlePerPageChange"
@search-change="handleSearchChange"
@sort-change="handleSortChange"
@retry="refetch"
/>
</template>
Features
Core Features
- Search with debounce
- Pagination with Laravel meta
- Sorting (client & server-side)
- Custom Filters with state persistence across pages
- Row selection with bulk actions
- Row styling with conditional class support
- Dark mode support
- Loading states & error handling
- Fully customizable via slots
Component Props
DataTable Props
| Prop | Type | Default | Description |
|---|---|---|---|
data |
Array |
[] |
Table data array |
columns |
ColumnDef<DataTableFeatures, T>[] |
required | TanStack Table v9 column definitions |
pagination |
LaravelPaginationResponse |
null |
Laravel pagination response |
isLoading |
boolean |
false |
Loading state |
error |
Error |
null |
Error object |
search |
string |
'' |
Search query |
filters |
Record<string, any> |
{} |
Custom filter state |
currentPerPage |
number |
10 |
Current items per page |
perPageOptions |
number[] |
[10,15,25,50,100] |
Per page options |
sortBy |
string |
null |
Current sort column |
sortDirection |
'asc'|'desc' |
'asc' |
Sort direction |
enableRowSelection |
boolean |
false |
Enable row selection |
rowSelection |
RowSelectionState |
{} |
Selected rows state |
getRowId |
(row) => string | number |
(row) => row.id |
Get unique row ID (stringified internally) |
showSelectionInfo |
boolean |
true |
Show selection info bar when rows are selected |
showSearch |
boolean |
true |
Show search input |
showPerPageSelector |
boolean |
true |
Show per page selector |
rowClassName |
string | ((row: any) => string) |
'' |
Custom CSS class(es) for rows |
title |
string |
'Items' |
Table title |
itemName |
string |
'items' |
Item name for pluralization |
loadingText |
string |
'Loading...' |
Loading text |
errorTitle |
string |
'Error loading data' |
Error title |
emptyStateText |
string |
'No items found' |
Empty state text |
searchPlaceholder |
string |
'Search...' |
Search input placeholder |
searchLabel |
string |
'Search' |
Accessible name for the search input |
DataTable Events
| Event | Payload | Description |
|---|---|---|
pageChange |
number |
Emitted when page changes |
perPageChange |
number |
Emitted when per page changes |
searchChange |
string |
Emitted on every keystroke — not debounced. Debounce in the parent before requesting. |
sortChange |
string |
Emitted when sort column changes |
filterChange |
Record<string, any> |
Emitted when filters change |
retry |
- | Emitted when retry button clicked |
update:rowSelection |
RowSelectionState |
Emitted when row selection changes |
Slots
filters Slot
Add custom filters next to search input. The slot now provides the current filter state:
<DataTable :filters="myFilters" @filter-change="handleFilterChange" ...>
<template #filters="{ filters }">
<select v-model="filters.status" class="...">
<option value="">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
<select v-model="filters.category" class="...">
<option value="">All Categories</option>
<option value="A">Category A</option>
<option value="B">Category B</option>
</select>
</template>
</DataTable>
Slot Props:
filters- Current filter state object (use for v-model bindings inside the slot)
header Slot
Add action buttons (e.g., Create button):
<DataTable ...>
<template #header>
<button @click="openCreateModal" class="...">
Add User
</button>
</template>
</DataTable>
bulk-actions Slot
Add bulk action buttons when rows are selected:
<DataTable
:enable-row-selection="true"
v-model:row-selection="selectedRows"
...
>
<template #bulk-actions="{ selectedIds, clearSelection }">
<button @click="bulkDelete(selectedIds)" class="...">
Delete Selected
</button>
<button @click="clearSelection" class="...">
Clear
</button>
</template>
</DataTable>
Receives the same slot props as selection-info.
selection-info Slot
Fully customize the selection info bar (replaces the default blue bar):
<DataTable
:enable-row-selection="true"
v-model:row-selection="selectedRows"
...
>
<template #selection-info="{ selectedIds, selectedCount, clearSelection }">
<div class="your-custom-class">
{{ selectedCount }} items selected
<button @click="handleBulkAction(selectedIds)">Action</button>
<button @click="clearSelection">Clear</button>
</div>
</template>
</DataTable>
Slot props available:
selectedIds— IDs of every selected row, across all pagesselectedCount— number of selected rows, across all pagescurrentPageSelectedData— row objects for the selected rows on the current page onlyhasOffPageSelection—truewhen the selection extends beyond the loaded page, i.e. whencurrentPageSelectedDatais an incomplete viewclearSelection— clear all selectionsselectAllCurrentPage— select all rows on the current pagedeselectAllCurrentPage— deselect all rows on the current page
Selection spans pages, row data does not. With server-side pagination the component only ever receives the current page's rows, so it cannot hand you objects for rows selected on other pages. Drive bulk actions from
selectedIds(send them to the server); reach forcurrentPageSelectedDataonly when you specifically want the visible subset, and checkhasOffPageSelectionif acting on a partial set would be wrong.
<template #bulk-actions="{ selectedIds, hasOffPageSelection }">
<span v-if="hasOffPageSelection" class="text-xs">
Includes rows on other pages
</span>
<!-- correct: the server resolves the ids -->
<button @click="axios.post('/users/bulk-delete', { ids: selectedIds })">
Delete Selected
</button>
</template>
Custom Filters
This package supports custom filters that persist correctly when changing pages.
Basic Usage with usePagination
The usePagination composable provides built-in filter management:
<script setup lang="ts">
import { ref } from 'vue'
import { usePagination } from '@toniel/laravel-tanstack-pagination'
import { DataTable } from '@toniel/laravel-tanstack-datatable'
// Use pagination composable with filter support
const {
tableData,
pagination,
isLoading,
search,
currentPerPage,
sortBy,
sortDirection,
customFilters,
handlePageChange,
handlePerPageChange,
handleSearchChange,
handleSortChange,
setFilter,
removeFilter,
refetch,
} = usePagination(
(filters) => axios.get('/api/users', { params: filters }),
{ queryKey: 'users', defaultPerPage: 10 }
)
// Set custom filters
const updateStatusFilter = (status: string) => {
if (status) {
setFilter('status', status)
} else {
removeFilter('status')
}
refetch()
}
const updateCategoryFilter = (category: string) => {
if (category) {
setFilter('category', category)
} else {
removeFilter('category')
}
refetch()
}
</script>
<template>
<DataTable
:data="tableData"
:columns="columns"
:pagination="pagination"
:is-loading="isLoading"
:search="search"
:current-per-page="currentPerPage"
:sort-by="sortBy"
:sort-direction="sortDirection"
:filters="customFilters"
@page-change="handlePageChange"
@per-page-change="handlePerPageChange"
@search-change="handleSearchChange"
@sort-change="handleSortChange"
@filter-change="handleFilterChange"
>
<template #filters="{ filters }">
<select
:value="filters.status || ''"
@change="updateStatusFilter(($event.target as HTMLSelectElement).value)"
>
<option value="">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</template>
</DataTable>
</template>
Using handleFilterChange
For bulk filter updates, use the handleFilterChange function:
<script setup lang="ts">
const {
customFilters,
handleFilterChange,
handlePageChange,
// ...
} = usePagination(fetchFn, { queryKey: 'users' })
// Bulk update filters
const applyFilters = (newFilters: Record<string, any>) => {
handleFilterChange(newFilters)
}
</script>
<template>
<DataTable
:filters="customFilters"
@filter-change="handleFilterChange"
...
/>
</template>
Using Filters Slot with v-model
For cleaner v-model binding inside the filters slot:
<script setup lang="ts">
import { computed } from 'vue'
import { usePagination } from '@toniel/laravel-tanstack-pagination'
const {
customFilters,
handleFilterChange,
// ...
} = usePagination(fetchFn, { queryKey: 'users' })
// Create a writable computed for two-way binding
const filtersModel = computed({
get: () => customFilters.value,
set: (val) => handleFilterChange(val)
})
</script>
<template>
<DataTable
:filters="customFilters"
@filter-change="handleFilterChange"
>
<template #filters="{ filters }">
<!-- Direct v-model binding using filters from slot -->
<select v-model="filters.status">
<option value="">All Status</option>
<option value="active">Active</option>
</select>
</template>
</DataTable>
</template>
Filter Methods Reference
| Method | Description |
|---|---|
setFilter(key, value) |
Set a single filter key-value pair |
removeFilter(key) |
Remove a filter by key |
handleFilterChange(filters) |
Bulk update all filters at once |
resetFilters() |
Reset all filters to defaults |
customFilters |
Reactive ref containing current filter state |
Advanced Examples
With Row Selection
<script setup lang="ts">
import { ref } from 'vue'
import type { RowSelectionState } from '@tanstack/vue-table'
const selectedRows = ref<RowSelectionState>({})
const bulkDelete = async (ids: string[]) => {
await axios.delete('/api/users/bulk', { data: { ids } })
selectedRows.value = {}
}
</script>
<template>
<DataTable
:enable-row-selection="true"
v-model:row-selection="selectedRows"
...
>
<template #bulk-actions="{ selectedIds, clearSelection }">
<button @click="bulkDelete(selectedIds)">Delete</button>
<button @click="clearSelection">Clear</button>
</template>
</DataTable>
</template>
With Custom Columns (Actions)
<script setup lang="ts">
const columns = [
columnHelper.accessor('id', { header: 'ID' }),
columnHelper.accessor('name', { header: 'Name' }),
columnHelper.display({
id: 'actions',
header: 'Actions',
cell: ({ row }) => h('div', { class: 'flex gap-2' }, [
h('button', { onClick: () => edit(row.original) }, 'Edit'),
h('button', { onClick: () => remove(row.original) }, 'Delete'),
]),
}),
]
</script>
With Checkbox Column
<script setup lang="ts">
import { Checkbox } from 'your-ui-library' // or native input
const columns = [
columnHelper.display({
id: 'select',
header: ({ table }) => h(Checkbox, {
checked: table.getIsAllPageRowsSelected(),
onUpdate: (val) => table.toggleAllPageRowsSelected(!!val),
}),
cell: ({ row }) => h(Checkbox, {
checked: row.getIsSelected(),
onUpdate: (val) => row.toggleSelected(!!val),
}),
}),
// ... other columns
]
</script>
Using useRowSelection Composable
For more control over row selection, use the useRowSelection composable:
<script setup lang="ts">
import { ref } from 'vue'
import { useRowSelection, type DataTableFeatures } from '@toniel/laravel-tanstack-datatable'
import { createColumnHelper } from '@tanstack/vue-table'
interface User {
id: number
name: string
email: string
}
const tableData = ref<User[]>([])
const {
rowSelection,
selectedRowIds,
selectedRowData,
isAllCurrentPageSelected,
clearSelection,
toggleAllCurrentPage,
toggleRowSelection,
getSelectionColumn,
} = useRowSelection<User>({
data: tableData,
getRowId: (row) => String(row.id),
})
const columnHelper = createColumnHelper<DataTableFeatures, User>()
const columns = [
getSelectionColumn({ size: 50 }), // Automatic checkbox column
columnHelper.accessor('name', { header: 'Name' }),
columnHelper.accessor('email', { header: 'Email' }),
]
const handleBulkDelete = async () => {
await axios.delete('/api/users/bulk', { data: { ids: selectedRowIds.value } })
clearSelection()
// refetch data...
}
</script>
<template>
<DataTable
:data="tableData"
:columns="columns"
:enable-row-selection="true"
v-model:row-selection="rowSelection"
:show-selection-info="false"
>
<template #selection-info="{ selectedIds, selectedCount, clearSelection }">
<div class="flex items-center gap-4 p-4 bg-red-50 rounded-lg">
<span>{{ selectedCount }} users selected</span>
<button @click="handleBulkDelete" class="btn-danger">Delete</button>
<button @click="clearSelection">Clear</button>
</div>
</template>
</DataTable>
</template>
useRowSelection API
| Property/Method | Type | Description |
|---|---|---|
rowSelection |
Ref<RowSelectionState> |
Reactive selection state |
selectedRowIds |
Ref<string[]> |
Array of selected row IDs |
selectedRowData |
Ref<T[]> |
Array of selected row data |
isAllCurrentPageSelected |
Ref<boolean> |
Whether all current page rows are selected |
isSomeCurrentPageSelected |
Ref<boolean> |
Whether some (but not all) rows are selected |
clearSelection |
() => void |
Clear all selections |
toggleAllCurrentPage |
() => void |
Toggle select all on current page |
toggleRowSelection |
(id: string) => void |
Toggle single row selection |
selectRows |
(ids: string[]) => void |
Select multiple rows by IDs |
deselectRows |
(ids: string[]) => void |
Deselect multiple rows by IDs |
getSelectionColumn |
(options?) => ColumnDef<DataTableFeatures, T> |
Get a checkbox column definition |
getSelectionColumn Options
getSelectionColumn({
headerClass: 'flex items-center justify-center',
cellClass: 'flex items-center justify-center',
checkboxClass: 'w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded',
size: 50,
})
Row Styling
Style rows conditionally using the rowClassName prop. Accept a static string for all rows, or a function for per-row styling.
Static Class
Apply the same class to all rows:
<DataTable
:columns="columns"
:data="tableData"
row-class-name="bg-green-50 dark:bg-green-900/20"
/>
Conditional Per-Row Styling
Use a function to style rows based on their data:
<DataTable
:columns="columns"
:data="tableData"
:row-class-name="(row) => {
if (row.status === 'active') return 'bg-green-50 dark:bg-green-900/20';
if (row.status === 'inactive') return 'bg-red-50 dark:bg-red-900/20';
if (row.status === 'pending') return 'bg-yellow-50 dark:bg-yellow-900/20';
if (row.priority === 'high') return 'border-l-4 border-l-orange-500';
if (row.amount > 1000000) return 'bg-purple-50 dark:bg-purple-900/20 font-semibold';
return '';
}"
/>
The function receives row.original — the raw data object from your backend — so you can access any field.
Styling
This package uses Tailwind CSS utility classes. Make sure Tailwind is configured in your project.
Dark Mode
Dark mode is automatically supported via Tailwind's dark: classes. Configure dark mode in your tailwind.config.js:
module.exports = {
darkMode: 'class', // or 'media'
// ...
}
Custom Styling
You can override styles using Tailwind classes or custom CSS:
/* Custom table styles */
.data-table {
/* Your custom styles */
}
Theming
The components use Tailwind CSS and support dark mode out of the box. You can customize colors by:
- Using Tailwind Config:
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
// Customize colors here
}
}
}
}
- CSS Variables:
:root {
--color-primary: ...;
--color-border: ...;
}
Migrating to TanStack Table v9
Version 0.2.0 of this package moves from TanStack Table v8 to v9. The
component internals are handled for you — the changes below are what you need
to make in your own column definitions.
1. Column generics take the feature set first
v9 gates its APIs behind explicitly registered features, so column types now
carry a TFeatures generic. This package exports its feature set as
DataTableFeatures:
- import { createColumnHelper } from '@tanstack/vue-table'
+ import { createColumnHelper } from '@tanstack/vue-table'
+ import type { DataTableFeatures } from '@toniel/laravel-tanstack-datatable'
- const columnHelper = createColumnHelper<User>()
+ const columnHelper = createColumnHelper<DataTableFeatures, User>()
The same applies to bare ColumnDef annotations:
- const columns: ColumnDef<User>[] = [...]
+ const columns: ColumnDef<DataTableFeatures, User>[] = [...]
2. Row types must be objects or arrays
v9 restricts RowData to Record<string, any> | Array<any>. useRowSelection<T>
now carries that constraint, so a primitive row type will no longer compile.
Use an explicit object type for your rows.
3. Upgrade the peer dependency
npm install @tanstack/vue-table@^9
If you cannot upgrade yet, pin @toniel/laravel-tanstack-datatable@0.1.x,
which stays on v8.
What did not change
The DataTable props, events, and slots are unchanged, as are all
useRowSelection helpers. If you only ever passed columns built from
createColumnHelper and did not call TanStack APIs directly, adding the
generic is the whole migration.
Migrating from lucide-vue-next
0.3.0 moves the icon peer dependency from lucide-vue-next to @lucide/vue.
Lucide deprecated lucide-vue-next upstream — its last release, 1.0.0, carries
an npm deprecation notice pointing at @lucide/vue — so the old package no
longer receives new icons or fixes.
npm uninstall lucide-vue-next
npm install @lucide/vue
If your own code imports icons, update the specifier. The exported icon names and their props are identical, so nothing else changes:
- import { ChevronDown } from 'lucide-vue-next'
+ import { ChevronDown } from '@lucide/vue'
tailwind-merge also widened to ^2.0.0 || ^3.0.0 in the same release, so the
package no longer forces a downgrade on apps already on tailwind-merge v3.
Install the major that matches your Tailwind CSS version: v2 for Tailwind CSS
v3, v3 for Tailwind CSS v4.
Related Packages
@toniel/laravel-tanstack-pagination- Core composables (required)@tanstack/vue-query- Data fetching & caching@tanstack/vue-table- Table state management
Show Your Support
If this package helped you, please consider:
📄 License
🤝 Contributing
Contributions, issues and feature requests are welcome!
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
See CONTRIBUTING.md for more details.
🐛 Known Issues
Check the GitHub Issues page for known issues and feature requests.
📮 Contact
🙏 Acknowledgments
Built with these amazing libraries:
Related Packages
Full-featured DataTable component for Inertia.js + Vue 3 with Spatie Laravel Que...
A lightweight, dependency-light DataTable component for Vue 3 with Tailwind CSS
A self-contained Vue 3 data table (Tailwind 4-styled, no Tailwind install requir...
A thin Vue 3 table for server-side data: it renders rows and emits sort, filter,...
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.3.0 | ^3.0.0 | MIT | |
| 0.2.0 | ^3.0.0 | MIT | |
| 0.1.11 | ^3.0.0 | MIT | |
| 0.1.10 | ^3.0.0 | MIT | |
| 0.1.9 | ^3.0.0 | MIT | |
| 0.1.8 | ^3.0.0 | MIT | |
| 0.1.7 | ^3.0.0 | MIT | |
| 0.1.6 | ^3.0.0 | MIT | |
| 0.1.5 | ^3.0.0 | MIT | |
| 0.1.4 | ^3.0.0 | MIT | |
| 0.1.3 | ^3.0.0 | MIT | |
| 0.1.2 | ^3.0.0 | MIT | |
| 0.1.1 | ^3.0.0 | MIT | |
| 0.1.0 | ^3.0.0 | MIT |