Data Grid
<table> + Vue computed
A full-featured data table with client-side filtering, multi-column sorting, and pagination — all driven by Vue computed. For server-side or very large datasets, pair with TanStack Table.
Overview
Preview
8 of 8 rows
| User | Last tool | Total uses | Status |
|---|---|---|---|
A Alex Morgan | Thumbnail | 1,240 | Active |
S Sam Rivera | Channel | 880 | Active |
J Jordan Lee | Thumbnail | 640 | Inactive |
T Taylor Kim | Channel | 512 | Active |
M Morgan Chen | Thumbnail | 400 | Active |
Page 1 of 2
Toolbar (search + row count)
html
<div class="flex flex-wrap items-center justify-between gap-2">
<!-- Filter input -->
<div class="relative">
<svg class="pointer-events-none absolute inset-y-0 left-3 my-auto h-4 w-4 text-zinc-500" .../>
<input
v-model="filter"
type="search"
placeholder="Filter rows..."
class="h-8 w-48 rounded-md border border-zinc-700 bg-zinc-900 pl-9 pr-3
text-xs text-zinc-100 placeholder-zinc-600 outline-none
focus:border-[#D40C37] focus:ring-1 focus:ring-[#D40C37]/15"
/>
</div>
<p class="text-xs text-zinc-600 tabular-nums">{{ filtered.length }} of {{ rows.length }} rows</p>
</div>Sortable column headers
html
<th
v-for="col in columns"
:key="col.key"
@click="toggleSort(col.key)"
class="cursor-pointer px-4 py-3 text-left text-[10px] font-semibold uppercase
tracking-widest text-zinc-500 hover:text-zinc-300 select-none transition-colors"
>
<span class="inline-flex items-center gap-1">
{{ col.label }}
<!-- Active sort indicator -->
<svg v-if="sort.key === col.key && sort.dir === 'asc'" class="h-3 w-3 text-[#D40C37]" ...>↑</svg>
<svg v-if="sort.key === col.key && sort.dir === 'desc'" class="h-3 w-3 text-[#D40C37]" ...>↓</svg>
</span>
</th>Vue computed pipeline
vue
<script setup>
import { ref, computed, watch } from 'vue'
const filter = ref('')
const page = ref(1)
const perPage = 10
const sort = ref({ key: 'name', dir: 'asc' })
const rows = [ /* your data */ ]
// 1. Filter
const filtered = computed(() => {
const q = filter.value.toLowerCase()
if (!q) return rows
return rows.filter(r =>
Object.values(r).some(v => String(v).toLowerCase().includes(q))
)
})
// 2. Sort
const sorted = computed(() => {
return [...filtered.value].sort((a, b) => {
const va = a[sort.value.key]
const vb = b[sort.value.key]
const dir = sort.value.dir === 'asc' ? 1 : -1
return (va < vb ? -1 : va > vb ? 1 : 0) * dir
})
})
// 3. Paginate
const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / perPage)))
const paginated = computed(() => sorted.value.slice((page.value - 1) * perPage, page.value * perPage))
// Reset to page 1 on filter change
watch(filter, () => { page.value = 1 })
// Toggle sort
function toggleSort(key) {
if (sort.value.key === key) {
sort.value.dir = sort.value.dir === 'asc' ? 'desc' : 'asc'
} else {
sort.value.key = key
sort.value.dir = 'asc'
}
}
</script>Row and cell patterns
html
<!-- Standard row -->
<tr class="hover:bg-zinc-900/40 transition-colors border-b border-zinc-800/50">
<td class="px-4 py-3 text-sm text-zinc-300">{{ row.name }}</td>
<td class="px-4 py-3 text-xs text-zinc-500">{{ row.email }}</td>
<td class="px-4 py-3 text-right tabular-nums text-xs text-zinc-400">{{ row.count }}</td>
<td class="px-4 py-3">
<span class="inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ring-1 ring-inset ..."
:class="row.active ? 'bg-emerald-950/50 text-emerald-400 ring-emerald-800/50' : 'bg-zinc-800/50 text-zinc-500 ring-zinc-700/50'"
>{{ row.active ? 'Active' : 'Inactive' }}</span>
</td>
</tr>
<!-- Avatar cell -->
<td class="px-4 py-3">
<div class="flex items-center gap-2.5">
<div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-zinc-800 text-xs font-semibold text-zinc-400">
{{ row.name[0] }}
</div>
<span class="text-sm text-zinc-200">{{ row.name }}</span>
</div>
</td>
<!-- Actions cell -->
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-1">
<button class="rounded-md p-1.5 text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300">Edit</button>
<button class="rounded-md p-1.5 text-zinc-500 hover:bg-red-950/40 hover:text-red-400">Delete</button>
</div>
</td>Server-side with Nuxt
vue
<script setup>
const filter = ref('')
const page = ref(1)
const sort = ref({ key: 'name', dir: 'asc' })
const { data, pending } = await useFetch('/api/users', {
query: computed(() => ({
q: filter.value,
page: page.value,
sort: sort.value.key,
dir: sort.value.dir,
})),
watch: [filter, page, sort],
})
</script>Accessibility notes
- Use
<table>,<thead>,<tbody>,<th scope="col">— not<div>grids - Sortable headers should have
aria-sort="ascending"/aria-sort="descending"/aria-sort="none" - Give the table a caption or
aria-labeldescribing the data - The filter input should use
role="search"on its wrapper - Row counts and page info should be in a live region or clearly associated with the table