🔥 Oxide UI v0.1.0 — Dark mode only, copy-paste ready. Get started →
Skip to content

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
Thumbnail1,240Active
S
Sam Rivera
Channel880Active
J
Jordan Lee
Thumbnail640Inactive
T
Taylor Kim
Channel512Active
M
Morgan Chen
Thumbnail400Active

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-label describing 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

Released under the MIT License.