Skip to content

DataTable ​

Build a server-driven table with page-scoped selection and a visible busy state. The optional Inertia query helper keeps search, sorting, filters, and pagination usable across refresh, sharing, and Back/Forward.

The application still writes the caption, headers, rows, cells, links, actions, empty state, and every Tailwind class. There is no column schema, visual variant API, client-side data engine, or hidden link abstraction.

Preview Live · Vue
Usage
vue
<script setup>
import { ref } from 'vue'
import Checkbox from '@/components/ui/checkbox/Checkbox.vue'
import DataTable from '@/components/ui/data-table/DataTable.vue'

defineProps({ services: { type: Array, required: true } })

const selected = ref([])
</script>

<template>
  <DataTable
    v-model:selected="selected"
    :rows="services"
    class="rounded-lg border border-gray-200"
    table-class="min-w-160"
    v-slot="table"
  >
    <caption class="caption-top px-4 py-3 text-left font-semibold">
      Production services
    </caption>
    <thead class="border-y border-gray-200 bg-gray-50 text-xs text-gray-600">
      <tr>
        <th scope="col" class="w-12 px-4 py-3">
          <Checkbox v-bind="table.pageSelection()" />
        </th>
        <th scope="col" class="px-4 py-3 font-medium">Service</th>
        <th scope="col" class="px-4 py-3 font-medium">Status</th>
      </tr>
    </thead>
    <tbody class="divide-y divide-gray-100">
      <tr v-for="service in services" :key="service.id">
        <td class="px-4 py-3">
          <Checkbox
            v-bind="table.rowSelection(service, `Select ${service.name}`)"
          />
        </td>
        <th scope="row" class="px-4 py-3 font-medium">
          <a :href="`/services/${service.id}`">{{ service.name }}</a>
        </th>
        <td class="px-4 py-3">{{ service.status }}</td>
      </tr>
    </tbody>
  </DataTable>
</template>
jsx
import { useState } from 'react'
import Checkbox from '@/components/ui/checkbox/Checkbox.jsx'
import DataTable from '@/components/ui/data-table/DataTable.jsx'

export default function ServicesTable({ services }) {
  const [selected, setSelected] = useState([])

  return (
    <DataTable
      rows={services}
      selected={selected}
      onSelectedChange={setSelected}
      className="rounded-lg border border-gray-200"
      tableClassName="min-w-160"
    >
      {(table) => (
        <>
          <caption className="caption-top px-4 py-3 text-left font-semibold">
            Production services
          </caption>
          <thead className="border-y border-gray-200 bg-gray-50 text-xs text-gray-600">
            <tr>
              <th scope="col" className="w-12 px-4 py-3">
                <Checkbox {...table.pageSelection()} />
              </th>
              <th scope="col" className="px-4 py-3 font-medium">
                Service
              </th>
              <th scope="col" className="px-4 py-3 font-medium">
                Status
              </th>
            </tr>
          </thead>
          <tbody className="divide-y divide-gray-100">
            {services.map((service) => (
              <tr key={service.id}>
                <td className="px-4 py-3">
                  <Checkbox
                    {...table.rowSelection(service, `Select ${service.name}`)}
                  />
                </td>
                <th scope="row" className="px-4 py-3 font-medium">
                  <a href={`/services/${service.id}`}>{service.name}</a>
                </th>
                <td className="px-4 py-3">{service.status}</td>
              </tr>
            ))}
          </tbody>
        </>
      )}
    </DataTable>
  )
}
svelte
<script>
  import Checkbox from '$lib/components/ui/checkbox/Checkbox.svelte'
  import DataTable from '$lib/components/ui/data-table/DataTable.svelte'

  let { services } = $props()
  let selected = $state([])
</script>

{#snippet content(table)}
  <caption class="caption-top px-4 py-3 text-left font-semibold">
    Production services
  </caption>
  <thead class="border-y border-gray-200 bg-gray-50 text-xs text-gray-600">
    <tr>
      <th scope="col" class="w-12 px-4 py-3">
        <Checkbox {...table.pageSelection()} />
      </th>
      <th scope="col" class="px-4 py-3 font-medium">Service</th>
      <th scope="col" class="px-4 py-3 font-medium">Status</th>
    </tr>
  </thead>
  <tbody class="divide-y divide-gray-100">
    {#each services as service (service.id)}
      <tr>
        <td class="px-4 py-3">
          <Checkbox
            {...table.rowSelection(service, `Select ${service.name}`)}
          />
        </td>
        <th scope="row" class="px-4 py-3 font-medium">
          <a href={`/services/${service.id}`}>{service.name}</a>
        </th>
        <td class="px-4 py-3">{service.status}</td>
      </tr>
    {/each}
  </tbody>
{/snippet}

<DataTable
  rows={services}
  bind:selected
  class="rounded-lg border border-gray-200"
  tableClass="min-w-160"
  children={content}
/>
View source BridgeServices.vue
BridgeServices.vue
<script setup>
import { computed, ref } from 'vue'
import Checkbox from '@/components/ui/checkbox/Checkbox.vue'
import DataTable from '@/components/ui/data-table/DataTable.vue'
import Input from '@/components/ui/input/Input.vue'
import Select from '@/components/ui/select/Select.vue'

const props = defineProps({ services: { type: Array, required: true } })
const selected = ref([])
const search = ref('')
const view = ref('all')
const attentionOnly = ref(false)
const sort = ref('name ASC')

const rows = computed(() => {
  const query = search.value.trim().toLowerCase()
  const result = props.services.filter((service) => {
    if (view.value === 'platform' && service.owner !== 'Platform') return false
    if (attentionOnly.value && service.status !== 'Attention') return false

    return (
      !query ||
      [service.name, service.owner, service.status, service.region]
        .join(' ')
        .toLowerCase()
        .includes(query)
    )
  })
  const [field, direction] = sort.value.split(' ')

  return result.sort((left, right) => {
    const order = String(left[field]).localeCompare(String(right[field]))
    return direction === 'ASC' ? order : -order
  })
})

function ariaSort(field) {
  const [active, direction] = sort.value.split(' ')
  if (active !== field) return undefined
  return direction === 'ASC' ? 'ascending' : 'descending'
}

function sortButton(field, label) {
  const [active, direction] = sort.value.split(' ')
  const next = active === field && direction === 'ASC' ? 'DESC' : 'ASC'

  return {
    type: 'button',
    'aria-label': `Sort by ${label} ${next === 'ASC' ? 'ascending' : 'descending'}`,
    onClick: () => {
      sort.value = `${field} ${next}`
    }
  }
}

function sortState(field) {
  const [active, direction] = sort.value.split(' ')
  return active === field ? direction : undefined
}

function statusClasses(status) {
  return {
    Healthy: 'bg-emerald-400/10 text-emerald-300 ring-emerald-400/20',
    Deploying: 'bg-sky-400/10 text-sky-300 ring-sky-400/20',
    Attention: 'bg-amber-400/10 text-amber-300 ring-amber-400/20'
  }[status]
}
</script>

<template>
  <section
    class="bg-gray-950 p-6 text-white"
    aria-labelledby="bridge-services-title"
  >
    <header
      class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"
    >
      <div>
        <h2
          id="bridge-services-title"
          class="text-2xl font-semibold tracking-tight"
        >
          Bridge services
        </h2>
        <p class="mt-2 text-sm leading-6 text-gray-400">
          Production services connected to this environment.
        </p>
      </div>
      <a
        href="/services/new"
        class="inline-flex min-h-11 items-center justify-center gap-2 self-start rounded-lg bg-white px-4 text-sm font-medium text-gray-950 no-underline transition-colors hover:bg-gray-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white motion-reduce:transition-none"
      >
        <svg
          viewBox="0 0 20 20"
          fill="none"
          stroke="currentColor"
          class="size-4"
          aria-hidden="true"
        >
          <path
            d="M10 4v12M4 10h12"
            stroke-linecap="round"
            stroke-width="1.75"
          />
        </svg>
        New service
      </a>
    </header>

    <div
      class="mt-6 flex flex-col gap-3 rounded-xl bg-gray-900/60 p-3 ring-1 ring-white/10 lg:flex-row lg:items-center lg:justify-between"
    >
      <div class="flex flex-1 flex-col gap-3 sm:flex-row sm:flex-wrap">
        <Input
          v-model="search"
          type="search"
          aria-label="Search services"
          placeholder="Search services..."
          class="border-gray-700 bg-gray-950 text-white sm:w-64"
        />
        <div class="sm:w-52">
          <Select
            v-model="view"
            aria-label="Saved view"
            :options="[
              { value: 'all', label: 'All services' },
              { value: 'platform', label: 'Platform services' }
            ]"
            class="border-gray-700 bg-gray-950 text-white"
          />
        </div>
        <button
          type="button"
          :aria-pressed="attentionOnly"
          class="inline-flex min-h-11 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-gray-700 px-4 text-sm text-gray-300 transition-colors hover:border-gray-600 hover:bg-white/5 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white aria-pressed:border-amber-400/50 aria-pressed:bg-amber-400/10 aria-pressed:text-amber-200 motion-reduce:transition-none sm:w-auto"
          @click="attentionOnly = !attentionOnly"
        >
          <svg
            viewBox="0 0 20 20"
            fill="none"
            stroke="currentColor"
            class="size-4"
            aria-hidden="true"
          >
            <path
              d="M3 4h14l-5.5 6.2v4.3l-3 1.5v-5.8L3 4Z"
              stroke-linejoin="round"
              stroke-width="1.5"
            />
          </svg>
          Needs attention
        </button>
      </div>
      <div
        class="flex min-h-11 items-center justify-between gap-3 px-1 text-sm text-gray-400 lg:justify-end"
      >
        <span>{{ rows.length }} records</span>
        <span
          v-if="selected.length"
          class="rounded-full bg-white/10 px-2.5 py-1 font-medium text-white"
        >
          {{ selected.length }} selected
        </span>
        <button
          v-if="selected.length"
          type="button"
          class="cursor-pointer rounded-md px-2 py-1 font-medium text-white transition-colors hover:bg-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white motion-reduce:transition-none"
        >
          Actions
        </button>
      </div>
    </div>

    <div
      class="mt-6 overflow-hidden rounded-xl bg-gray-950 ring-1 ring-white/10"
    >
      <DataTable
        v-model:selected="selected"
        :rows="rows"
        class="overscroll-x-contain"
        table-class="min-w-200 text-gray-100 dark:text-gray-100"
        v-slot="table"
      >
        <caption class="sr-only">
          Bridge service records
        </caption>
        <thead
          class="border-b border-gray-800 bg-gray-900 text-xs uppercase tracking-wider text-gray-400"
        >
          <tr>
            <th
              scope="col"
              class="sticky left-0 z-20 w-12 bg-gray-900 px-4 py-3"
            >
              <Checkbox
                v-bind="table.pageSelection('Select all services on this page')"
                class="text-white focus-visible:outline-white"
              />
            </th>
            <th
              scope="col"
              :aria-sort="ariaSort('name')"
              class="sticky left-12 z-20 min-w-36 border-r border-gray-800 bg-gray-900 px-4 py-3 text-left font-medium"
            >
              <button
                v-bind="sortButton('name', 'service')"
                class="inline-flex cursor-pointer items-center gap-2 rounded-sm hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
              >
                Service
                <svg
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  class="size-3.5"
                  aria-hidden="true"
                >
                  <path
                    v-if="sortState('name') === 'ASC'"
                    d="m7 14 5-5 5 5"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                    stroke-width="2"
                  />
                  <path
                    v-else-if="sortState('name') === 'DESC'"
                    d="m7 10 5 5 5-5"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                    stroke-width="2"
                  />
                  <path
                    v-else
                    d="m8 9 4-4 4 4m0 6-4 4-4-4"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                    stroke-width="1.75"
                  />
                </svg>
              </button>
            </th>
            <th scope="col" class="px-4 py-3 text-left font-medium">Owner</th>
            <th scope="col" class="px-4 py-3 text-left font-medium">Status</th>
            <th scope="col" class="px-4 py-3 text-left font-medium">Region</th>
            <th scope="col" class="w-16 px-4 py-3">
              <span class="sr-only">Actions</span>
            </th>
          </tr>
        </thead>
        <tbody v-if="rows.length" class="divide-y divide-gray-900 bg-gray-950">
          <tr
            v-for="row in rows"
            :key="row.id"
            class="group hover:bg-white/5 focus-within:bg-white/5"
          >
            <td
              :class="[
                'sticky left-0 z-10 px-4 py-3',
                table.isSelected(row)
                  ? 'bg-gray-900'
                  : 'bg-gray-950 group-hover:bg-gray-900 group-focus-within:bg-gray-900'
              ]"
            >
              <Checkbox
                v-bind="table.rowSelection(row, `Select ${row.name}`)"
                class="text-white focus-visible:outline-white"
              />
            </td>
            <th
              scope="row"
              :class="[
                'sticky left-12 z-10 border-r border-gray-900 px-4 py-3 text-left font-medium',
                table.isSelected(row)
                  ? 'bg-gray-900'
                  : 'bg-gray-950 group-hover:bg-gray-900 group-focus-within:bg-gray-900'
              ]"
            >
              <a
                :href="`/services/${row.id}`"
                class="rounded-sm text-white no-underline hover:underline hover:underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
              >
                {{ row.name }}
              </a>
            </th>
            <td class="px-4 py-3 text-gray-300">{{ row.owner }}</td>
            <td class="px-4 py-3">
              <span
                :class="[
                  'inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset',
                  statusClasses(row.status)
                ]"
              >
                <span
                  class="size-1.5 rounded-full bg-current"
                  aria-hidden="true"
                ></span>
                {{ row.status }}
              </span>
            </td>
            <td class="px-4 py-3 font-mono text-xs text-gray-400">
              {{ row.region }}
            </td>
            <td class="px-4 py-3 text-right">
              <a
                :href="`/services/${row.id}/actions`"
                :aria-label="`Actions for ${row.name}`"
                class="inline-grid size-10 place-items-center rounded-lg text-gray-400 no-underline transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white motion-reduce:transition-none"
              >
                <svg
                  viewBox="0 0 20 20"
                  fill="currentColor"
                  class="size-4"
                  aria-hidden="true"
                >
                  <circle cx="4" cy="10" r="1.5" />
                  <circle cx="10" cy="10" r="1.5" />
                  <circle cx="16" cy="10" r="1.5" />
                </svg>
              </a>
            </td>
          </tr>
        </tbody>
        <tbody v-else class="bg-gray-950">
          <tr>
            <td
              colspan="6"
              class="px-4 py-16 text-center text-sm text-gray-400"
            >
              No matching services.
            </td>
          </tr>
        </tbody>
      </DataTable>

      <footer
        class="flex items-center justify-between border-t border-gray-800 bg-gray-900/40 px-4 py-3 text-sm text-gray-400"
      >
        <span class="tabular-nums">Page 1 of 8</span>
        <nav aria-label="Service pages" class="flex gap-2">
          <span
            aria-disabled="true"
            class="inline-flex min-h-10 items-center px-3 text-gray-600"
          >
            Previous
          </span>
          <a
            href="?page=2"
            class="inline-flex min-h-10 items-center gap-1 rounded-lg px-3 font-medium text-white no-underline transition-colors hover:bg-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white motion-reduce:transition-none"
          >
            Next
            <svg
              viewBox="0 0 20 20"
              fill="none"
              stroke="currentColor"
              class="size-4"
              aria-hidden="true"
            >
              <path
                d="m7 4 6 6-6 6"
                stroke-linecap="round"
                stroke-linejoin="round"
                stroke-width="1.75"
              />
            </svg>
          </a>
        </nav>
      </footer>
    </div>
  </section>
</template>

A compact operational surface built from one native table. Search, selection, sorting, destinations, and row actions remain ordinary application markup.

Installation ​

The command detects Vue, React, or Svelte, installs the framework's Inertia adapter and tailwind-merge, and writes DataTable, its query helper, and the Table registry dependency into the conventional UI directories.

Run from your application’s directory. Klean detects your framework and copies the matching source.

Terminal
npx klean-ui add data-table

Source you own. Edit the installed files to make them yours.

The query helper is included because DataTable is intended for Boring Stack applications. Ignore it when the page already owns an equivalent server-query contract; the component itself does not require the helper at render time.

Table or DataTable? ​

Use Table when the page already has rows and only needs native tabular markup. Use DataTable when several server-owned list concerns must behave as one experience: search, filters, sorting, pagination, selection, and pending navigation.

DataTable composes Table rather than replacing it. Migrating a Table keeps the same <caption>, <thead>, <tbody>, <th>, <td>, links, buttons, and Tailwind classes.

Usage ​

Copy the example above for your framework.

Component API ​

InputDefaultPurpose
rows[]The rows rendered on the current server page.
rowKeyidA property name or function that returns each stable row key.
selectableevery rowReturns false for rows the current user cannot select.
selected[]Framework-native controlled or bindable selected keys.
busyfalseMarks the native table busy and prevents duplicate selection while keeping current rows readable.
class / className—Tailwind classes for the responsive scroll container.
tableClass / tableClassName—Tailwind classes for the native Table.
default contentrequiredNative caption, sections, rows, headers, cells, links, buttons, and empty states.

The content function or slot receives:

ValuePurpose
rowsThe same current-page rows.
selected, selectedCountCurrent selected keys and count.
allSelected, someSelectedTruthful current-page selection state.
rowSelection(row, label?)Props for one Klean Checkbox, including an automatic accessible label.
pageSelection(label?)Props for the current-page Checkbox, including its mixed state.
isSelected(row)Tests one visible row key.
clearSelection()Clears the current page selection.
removeSelection(keys)Removes completed or failed keys after a bulk action.

Selection is deliberately page-scoped. When navigation, filtering, or permissions remove a row from the current result, its key leaves the selection. Cross-page selection requires a separate application-owned model because “all matching records” has server consequences that a component must not guess.

Durable server queries ​

useDataTableQuery for Vue and React, and createDataTableQuery for Svelte, keep the visible server query recoverable without making DataTable own application routes.

BridgeServices.vue
import { computed } from 'vue'
import { useDataTableQuery } from '@/components/ui/data-table/useDataTableQuery.js'

const props = defineProps({
  query: { type: Object, required: true }
})

const dataTable = useDataTableQuery({
  url: '/bridge/services',
  query: computed(() => props.query),
  defaults: {
    page: 1,
    search: '',
    filters: {},
    sort: 'createdAt DESC'
  },
  only: ['services', 'total', 'query']
})

The helper follows these conventions:

  • search waits briefly while the user types, replaces the current history entry, and returns to page 1;
  • committed filters, sorts, and page changes create normal visits;
  • current rows stay visible during navigation;
  • scroll and local page state are preserved;
  • optional only values request the smallest useful Inertia prop set;
  • server props remain the source of truth when Back or Forward changes the URL;
  • default page=1, empty search, and empty filters disappear from the URL;
  • unrelated query parameters and the URL hash remain intact;
  • sorting uses the familiar field ASC / field DESC grammar;
  • focus returns to the initiating control after the server response.

Use the returned helpers directly in native controls:

BridgeServices.vue
<Input
  v-model="dataTable.search.value"
  type="search"
  aria-label="Search services"
/>

<th scope="col" :aria-sort="dataTable.ariaSort('name')">
  <button v-bind="dataTable.sortButton('name', 'service name')">
    Service
  </button>
</th>

<button
  type="button"
  @click="dataTable.visit({ filters: { status: 'failed' }, page: 1 })"
>
  Failed services
</button>

The server still validates search, filter, sort, and page values. It returns the current rows, total, canonical query, authorization decisions, and any recoverable error. DataTable does not duplicate Waterline queries in the browser.

A service name that opens a destination is an <a> or the Boring Stack <Link>. Sorting, filtering, bulk actions, and row menus are buttons. DataTable does not hide those decisions behind onRowClick, a route prop, or a cell configuration object.

This keeps Cmd/Ctrl-click, open-in-new-tab, copied URLs, Inertia navigation, and assistive technology behavior intact.

Loading, empty, and error states ​

  • Keep the current rows mounted when busy is true. Disable duplicate selection and show a nearby Spinner only when it adds useful feedback.
  • Render “No records yet” when the collection is genuinely empty.
  • Render “No matching records” when filters or search produced zero rows, with a real control to clear them.
  • Render a nearby Alert for a recoverable server failure. Do not turn the table into an error role.
  • After a bulk mutation, call removeSelection(succeededKeys) and leave failed rows selected when retrying them is useful.

Accessibility and responsive behavior ​

  • Give every table a visible <caption> or a visually hidden caption when nearby visible context already provides the same name.
  • Use <th scope="col"> for column headers and <th scope="row"> for the row's primary identity.
  • Put a real button inside a sortable header and update aria-sort on that <th>.
  • Give repeated links and actions specific names such as “Actions for api”.
  • DataTable announces the selection count and supplies a real mixed current-page checkbox.
  • Keep every current row in one native table. The outer DataTable container scrolls horizontally when caller-owned min-w-* classes need more room.
  • When wide operational tables need more context on small screens, use caller-owned sticky classes to keep selection and the primary row identity visible while the remaining columns scroll.
  • Do not collapse a data relationship into cards merely to avoid horizontal scrolling. Use a list when the content was not tabular in the first place.

Styling with Tailwind ​

The wrapper, table, caption, headers, cells, statuses, links, actions, empty state, and pagination are styled where they are written. There is no variant, density prop, column style object, Klean color, or global DataTable theme.

Build a small application wrapper when several pages share one treatment. Use Tailwind for compact rows, stronger borders, different typography, or a dark surface.

When not to use ​

  • Use Table for a static report or a small result that does not coordinate list state.
  • Use a semantic list for independent records without meaningful columns.
  • Use Combobox when the task is finding and choosing one value, not inspecting a result set.
  • Use Command for a searchable collection of actions and destinations.
  • Do not use DataTable for spreadsheet-style cell editing; editable invoice lines are usually a responsive form/list with explicit controls.

Complete framework source ​

The installer writes both the component and the framework-native query helper. Table is installed as a registry dependency.

vue
<script setup>
import { computed, ref, useAttrs, watch } from 'vue'
import { twMerge } from 'tailwind-merge'
import Table from '../table/Table.vue'

defineOptions({ inheritAttrs: false })

const props = defineProps({
  rows: { type: Array, default: () => [] },
  rowKey: { type: [String, Function], default: 'id' },
  selectable: { type: Function, default: () => true },
  busy: { type: Boolean, default: false },
  tableClass: { type: [String, Array, Object], default: undefined }
})

const selected = defineModel('selected', { default: () => [] })
const attrs = useAttrs()
const root = ref()

const rootAttrs = computed(() => {
  const {
    class: _class,
    'data-slot': _dataSlot,
    'data-busy': _dataBusy,
    'data-empty': _dataEmpty,
    ...rest
  } = attrs
  return rest
})

function keyFor(row) {
  return typeof props.rowKey === 'function'
    ? props.rowKey(row)
    : row?.[props.rowKey]
}

function canSelect(row) {
  return props.selectable(row) !== false
}

const selectableKeys = computed(() => props.rows.filter(canSelect).map(keyFor))
const selectableKeySet = computed(() => new Set(selectableKeys.value))
const selectedKeySet = computed(() => new Set(selected.value))
const selectedCount = computed(() => selectedKeySet.value.size)
const allSelected = computed(
  () =>
    selectableKeys.value.length > 0 &&
    selectableKeys.value.every((key) => selectedKeySet.value.has(key))
)
const someSelected = computed(
  () => selectedCount.value > 0 && !allSelected.value
)
const status = computed(() => {
  if (selectedCount.value === 0) return 'No rows selected.'
  return `${selectedCount.value} row${selectedCount.value === 1 ? '' : 's'} selected.`
})

function setSelected(next) {
  selected.value = [...new Set(next)]
}

function isSelected(row) {
  return selectedKeySet.value.has(keyFor(row))
}

function setRowSelected(row, checked) {
  if (props.busy || !canSelect(row)) return
  const key = keyFor(row)
  const next = new Set(selected.value)
  if (checked) next.add(key)
  else next.delete(key)
  setSelected(next)
}

function setPageSelected(checked) {
  if (props.busy) return
  setSelected(checked ? selectableKeys.value : [])
}

function clearSelection() {
  setSelected([])
}

function removeSelection(keys) {
  const removed = new Set(Array.isArray(keys) ? keys : [keys])
  setSelected(selected.value.filter((key) => !removed.has(key)))
}

function rowSelection(row, label) {
  const key = keyFor(row)
  return {
    modelValue: selectedKeySet.value.has(key),
    disabled: props.busy || !canSelect(row),
    'aria-label': label || `Select row ${String(key)}`,
    'onUpdate:modelValue': (checked) => setRowSelected(row, checked)
  }
}

function pageSelection(label = 'Select all rows on this page') {
  return {
    modelValue: allSelected.value,
    indeterminate: someSelected.value,
    disabled: props.busy || selectableKeys.value.length === 0,
    'aria-label': label,
    'onUpdate:modelValue': setPageSelected
  }
}

watch(
  selectableKeySet,
  (keys) => {
    const next = selected.value.filter((key) => keys.has(key))
    if (
      next.length !== selected.value.length ||
      next.some((key, index) => !Object.is(key, selected.value[index]))
    ) {
      setSelected(next)
    }
  },
  { immediate: true, flush: 'sync' }
)

defineExpose({
  root,
  clearSelection,
  removeSelection
})
</script>

<template>
  <div
    ref="root"
    v-bind="rootAttrs"
    data-slot="data-table"
    :data-busy="busy ? '' : undefined"
    :data-empty="rows.length === 0 ? '' : undefined"
    :class="twMerge('relative overflow-x-auto', attrs.class)"
  >
    <Table
      :aria-busy="busy ? 'true' : undefined"
      :class="twMerge('min-w-full', tableClass)"
    >
      <slot
        :rows="rows"
        :selected="selected"
        :selected-count="selectedCount"
        :all-selected="allSelected"
        :some-selected="someSelected"
        :is-selected="isSelected"
        :row-selection="rowSelection"
        :page-selection="pageSelection"
        :clear-selection="clearSelection"
        :remove-selection="removeSelection"
      />
    </Table>
    <span class="sr-only" aria-live="polite" aria-atomic="true">{{
      status
    }}</span>
  </div>
</template>
jsx
import {
  forwardRef,
  useEffect,
  useImperativeHandle,
  useMemo,
  useRef,
  useState
} from 'react'
import { twMerge } from 'tailwind-merge'
import Table from '../table/Table.jsx'

const DataTable = forwardRef(function DataTable(
  {
    rows = [],
    rowKey = 'id',
    selectable = () => true,
    busy = false,
    selected,
    defaultSelected = [],
    onSelectedChange,
    tableClassName,
    className,
    children,
    'data-slot': _dataSlot,
    'data-busy': _dataBusy,
    'data-empty': _dataEmpty,
    ...props
  },
  forwardedRef
) {
  const rootRef = useRef(null)
  const controlled = selected !== undefined
  const [localSelected, setLocalSelected] = useState(defaultSelected)
  const selectedKeys = controlled ? selected : localSelected

  function keyFor(row) {
    return typeof rowKey === 'function' ? rowKey(row) : row?.[rowKey]
  }

  const selectableKeys = useMemo(
    () => rows.filter((row) => selectable(row) !== false).map(keyFor),
    [rows, rowKey, selectable]
  )
  const selectableKeySet = useMemo(
    () => new Set(selectableKeys),
    [selectableKeys]
  )
  const selectedKeySet = useMemo(() => new Set(selectedKeys), [selectedKeys])
  const allSelected =
    selectableKeys.length > 0 &&
    selectableKeys.every((key) => selectedKeySet.has(key))
  const someSelected = selectedKeySet.size > 0 && !allSelected

  function setSelected(next) {
    const value = [...new Set(next)]
    if (!controlled) setLocalSelected(value)
    onSelectedChange?.(value)
  }

  function isSelected(row) {
    return selectedKeySet.has(keyFor(row))
  }

  function setRowSelected(row, checked) {
    if (busy || selectable(row) === false) return
    const key = keyFor(row)
    const next = new Set(selectedKeys)
    if (checked) next.add(key)
    else next.delete(key)
    setSelected(next)
  }

  function setPageSelected(checked) {
    if (busy) return
    setSelected(checked ? selectableKeys : [])
  }

  function clearSelection() {
    setSelected([])
  }

  function removeSelection(keys) {
    const removed = new Set(Array.isArray(keys) ? keys : [keys])
    setSelected(selectedKeys.filter((key) => !removed.has(key)))
  }

  function rowSelection(row, label) {
    const key = keyFor(row)
    return {
      checked: selectedKeySet.has(key),
      disabled: busy || selectable(row) === false,
      'aria-label': label || `Select row ${String(key)}`,
      onChange: (event) => setRowSelected(row, event.currentTarget.checked)
    }
  }

  function pageSelection(label = 'Select all rows on this page') {
    return {
      checked: allSelected,
      indeterminate: someSelected,
      disabled: busy || selectableKeys.length === 0,
      'aria-label': label,
      onChange: (event) => setPageSelected(event.currentTarget.checked)
    }
  }

  useEffect(() => {
    const next = selectedKeys.filter((key) => selectableKeySet.has(key))
    if (
      next.length !== selectedKeys.length ||
      next.some((key, index) => !Object.is(key, selectedKeys[index]))
    ) {
      setSelected(next)
    }
  }, [selectableKeySet, selectedKeys])

  useImperativeHandle(forwardedRef, () => ({
    root: rootRef.current,
    clearSelection,
    removeSelection
  }))

  const api = {
    rows,
    selected: selectedKeys,
    selectedCount: selectedKeySet.size,
    allSelected,
    someSelected,
    isSelected,
    rowSelection,
    pageSelection,
    clearSelection,
    removeSelection
  }
  const status =
    selectedKeySet.size === 0
      ? 'No rows selected.'
      : `${selectedKeySet.size} row${selectedKeySet.size === 1 ? '' : 's'} selected.`

  return (
    <div
      {...props}
      ref={rootRef}
      data-slot="data-table"
      data-busy={busy ? '' : undefined}
      data-empty={rows.length === 0 ? '' : undefined}
      className={twMerge('relative overflow-x-auto', className)}
    >
      <Table
        aria-busy={busy ? 'true' : undefined}
        className={twMerge('min-w-full', tableClassName)}
      >
        {typeof children === 'function' ? children(api) : children}
      </Table>
      <span className="sr-only" aria-live="polite" aria-atomic="true">
        {status}
      </span>
    </div>
  )
})

export default DataTable
svelte
<script>
  import { twMerge } from "tailwind-merge";
  import Table from "../table/Table.svelte";

  let {
    rows = [],
    rowKey = "id",
    selectable = () => true,
    busy = false,
    selected = $bindable([]),
    tableClass,
    class: className,
    children,
    "data-slot": _dataSlot,
    "data-busy": _dataBusy,
    "data-empty": _dataEmpty,
    ...props
  } = $props();

  let root = $state();

  function keyFor(row) {
    return typeof rowKey === "function" ? rowKey(row) : row?.[rowKey];
  }

  let selectableKeys = $derived(
    rows.filter((row) => selectable(row) !== false).map(keyFor),
  );
  let selectableKeySet = $derived(new Set(selectableKeys));
  let selectedKeySet = $derived(new Set(selected));
  let selectedCount = $derived(selectedKeySet.size);
  let allSelected = $derived(
    selectableKeys.length > 0 &&
      selectableKeys.every((key) => selectedKeySet.has(key)),
  );
  let someSelected = $derived(selectedCount > 0 && !allSelected);
  let status = $derived(
    selectedCount === 0
      ? "No rows selected."
      : `${selectedCount} row${selectedCount === 1 ? "" : "s"} selected.`,
  );

  function setSelected(next) {
    selected = [...new Set(next)];
  }

  function isSelected(row) {
    return selectedKeySet.has(keyFor(row));
  }

  function setRowSelected(row, checked) {
    if (busy || selectable(row) === false) return;
    const key = keyFor(row);
    const next = new Set(selected);
    if (checked) next.add(key);
    else next.delete(key);
    setSelected(next);
  }

  function setPageSelected(checked) {
    if (busy) return;
    setSelected(checked ? selectableKeys : []);
  }

  export function clearSelection() {
    setSelected([]);
  }

  export function removeSelection(keys) {
    const removed = new Set(Array.isArray(keys) ? keys : [keys]);
    setSelected(selected.filter((key) => !removed.has(key)));
  }

  export function getRoot() {
    return root;
  }

  function rowSelection(row, label) {
    const key = keyFor(row);
    return {
      checked: selectedKeySet.has(key),
      disabled: busy || selectable(row) === false,
      "aria-label": label || `Select row ${String(key)}`,
      onchange: (event) => setRowSelected(row, event.currentTarget.checked),
    };
  }

  function pageSelection(label = "Select all rows on this page") {
    return {
      checked: allSelected,
      indeterminate: someSelected,
      disabled: busy || selectableKeys.length === 0,
      "aria-label": label,
      onchange: (event) => setPageSelected(event.currentTarget.checked),
    };
  }

  let api = {
    get rows() {
      return rows;
    },
    get selected() {
      return selected;
    },
    get selectedCount() {
      return selectedCount;
    },
    get allSelected() {
      return allSelected;
    },
    get someSelected() {
      return someSelected;
    },
    isSelected,
    rowSelection,
    pageSelection,
    clearSelection,
    removeSelection,
  };

  $effect(() => {
    const next = selected.filter((key) => selectableKeySet.has(key));
    if (
      next.length !== selected.length ||
      next.some((key, index) => !Object.is(key, selected[index]))
    ) {
      setSelected(next);
    }
  });
</script>

<div
  {...props}
  bind:this={root}
  data-slot="data-table"
  data-busy={busy ? "" : undefined}
  data-empty={rows.length === 0 ? "" : undefined}
  class={twMerge("relative overflow-x-auto", className)}
>
  <Table
    aria-busy={busy ? "true" : undefined}
    class={twMerge("min-w-full", tableClass)}
  >
    {@render children?.(api)}
  </Table>
  <span class="sr-only" aria-live="polite" aria-atomic="true">{status}</span>
</div>
js
import { computed, nextTick, onBeforeUnmount, ref, toValue, watch } from 'vue'
import { router } from '@inertiajs/vue3'

function sameValue(left, right) {
  if (Object.is(left, right)) return true
  if (left && right && typeof left === 'object' && typeof right === 'object') {
    return JSON.stringify(left) === JSON.stringify(right)
  }
  return false
}

function queryValue(value) {
  if (value === undefined || value === null || value === '') return undefined
  if (typeof value === 'object') return JSON.stringify(value)
  return String(value)
}

export function dataTableUrl(source, query, defaults = {}) {
  const raw = source || '/'
  const absolute = /^[a-z][a-z\d+.-]*:/i.test(raw)
  const url = new URL(raw, 'http://klean.invalid')
  const cleanDefaults = { page: 1, search: '', filters: {}, ...defaults }

  for (const [key, value] of Object.entries(query || {})) {
    if (sameValue(value, cleanDefaults[key])) {
      url.searchParams.delete(key)
      continue
    }

    const encoded = queryValue(value)
    if (encoded === undefined) url.searchParams.delete(key)
    else url.searchParams.set(key, encoded)
  }

  return absolute ? url.href : `${url.pathname}${url.search}${url.hash}`
}

function directionFor(sort, field) {
  const [activeField, direction = 'ASC'] = String(sort || '').split(/\s+/)
  return activeField === field ? direction.toUpperCase() : undefined
}

function restoreFocus(intent) {
  if (!intent || typeof document === 'undefined') return
  requestAnimationFrame(async () => {
    await nextTick()
    if (intent.element?.isConnected) {
      intent.element.focus()
      return
    }
    const candidate = [...document.querySelectorAll('[data-table-focus]')].find(
      (element) => element.dataset.tableFocus === intent.key
    )
    candidate?.focus()
  })
}

export function useDataTableQuery(options) {
  const busy = ref(false)
  const search = ref('')
  const currentQuery = computed(() => toValue(options.query) || {})
  const currentDefaults = computed(() => toValue(options.defaults) || {})
  let searchTimer
  let syncingSearch = false
  let focusIntent

  function cancelSearch() {
    clearTimeout(searchTimer)
    searchTimer = undefined
  }

  function visit(updates = {}, visitOptions = {}) {
    cancelSearch()
    const {
      replace = false,
      trigger,
      onStart,
      onFinish,
      ...forwardedOptions
    } = visitOptions
    const next = {
      ...currentQuery.value,
      search: search.value,
      ...updates
    }
    const href = dataTableUrl(toValue(options.url), next, currentDefaults.value)
    const only = toValue(options.only) || []
    const key = trigger?.dataset?.tableFocus
    focusIntent = trigger ? { element: trigger, key } : undefined

    router.visit(href, {
      preserveState: true,
      preserveScroll: true,
      ...(only.length ? { only } : {}),
      ...forwardedOptions,
      replace,
      onStart(event) {
        busy.value = true
        onStart?.(event)
      },
      onFinish(event) {
        busy.value = false
        const intent = focusIntent
        focusIntent = undefined
        restoreFocus(intent)
        onFinish?.(event)
      }
    })

    return href
  }

  function sort(field, trigger) {
    if (busy.value) return
    const direction = directionFor(currentQuery.value.sort, field)
    const nextDirection = direction === 'ASC' ? 'DESC' : 'ASC'
    return visit({ sort: `${field} ${nextDirection}`, page: 1 }, { trigger })
  }

  function ariaSort(field) {
    const direction = directionFor(currentQuery.value.sort, field)
    if (direction === 'ASC') return 'ascending'
    if (direction === 'DESC') return 'descending'
    return undefined
  }

  function sortButton(field, label = field) {
    const direction = directionFor(currentQuery.value.sort, field)
    const nextDirection = direction === 'ASC' ? 'descending' : 'ascending'
    return {
      type: 'button',
      disabled: busy.value,
      'data-table-focus': `sort:${field}`,
      'aria-label': `Sort by ${label} ${nextDirection}`,
      onClick: (event) => sort(field, event.currentTarget)
    }
  }

  watch(
    () => String(currentQuery.value.search ?? ''),
    (value) => {
      cancelSearch()
      syncingSearch = true
      search.value = value
      syncingSearch = false
    },
    { immediate: true, flush: 'sync' }
  )

  watch(
    search,
    (value) => {
      if (
        syncingSearch ||
        String(currentQuery.value.search ?? '') === String(value)
      ) {
        return
      }
      cancelSearch()
      searchTimer = setTimeout(() => {
        visit({ search: value, page: 1 }, { replace: true })
      }, 300)
    },
    { flush: 'sync' }
  )

  onBeforeUnmount(cancelSearch)

  return {
    search,
    busy,
    visit,
    sort,
    ariaSort,
    sortButton,
    cancelSearch
  }
}
js
import { useCallback, useEffect, useRef, useState } from 'react'
import { router } from '@inertiajs/react'

function sameValue(left, right) {
  if (Object.is(left, right)) return true
  if (left && right && typeof left === 'object' && typeof right === 'object') {
    return JSON.stringify(left) === JSON.stringify(right)
  }
  return false
}

function queryValue(value) {
  if (value === undefined || value === null || value === '') return undefined
  if (typeof value === 'object') return JSON.stringify(value)
  return String(value)
}

export function dataTableUrl(source, query, defaults = {}) {
  const raw = source || '/'
  const absolute = /^[a-z][a-z\d+.-]*:/i.test(raw)
  const url = new URL(raw, 'http://klean.invalid')
  const cleanDefaults = { page: 1, search: '', filters: {}, ...defaults }

  for (const [key, value] of Object.entries(query || {})) {
    if (sameValue(value, cleanDefaults[key])) {
      url.searchParams.delete(key)
      continue
    }
    const encoded = queryValue(value)
    if (encoded === undefined) url.searchParams.delete(key)
    else url.searchParams.set(key, encoded)
  }

  return absolute ? url.href : `${url.pathname}${url.search}${url.hash}`
}

function directionFor(sort, field) {
  const [activeField, direction = 'ASC'] = String(sort || '').split(/\s+/)
  return activeField === field ? direction.toUpperCase() : undefined
}

function restoreFocus(intent) {
  if (!intent || typeof document === 'undefined') return
  requestAnimationFrame(() => {
    if (intent.element?.isConnected) {
      intent.element.focus()
      return
    }
    const candidate = [...document.querySelectorAll('[data-table-focus]')].find(
      (element) => element.dataset.tableFocus === intent.key
    )
    candidate?.focus()
  })
}

export function useDataTableQuery(options) {
  const optionsRef = useRef(options)
  optionsRef.current = options
  const serverSearch = String(options.query?.search ?? '')
  const [search, setSearch] = useState(serverSearch)
  const [busy, setBusy] = useState(false)
  const searchRef = useRef(search)
  const focusIntent = useRef()
  searchRef.current = search

  useEffect(() => {
    setSearch(serverSearch)
  }, [serverSearch])

  const visit = useCallback((updates = {}, visitOptions = {}) => {
    const current = optionsRef.current
    const {
      replace = false,
      trigger,
      onStart,
      onFinish,
      ...forwardedOptions
    } = visitOptions
    const next = {
      ...(current.query || {}),
      search: searchRef.current,
      ...updates
    }
    const href = dataTableUrl(current.url, next, current.defaults || {})
    const only = current.only || []
    focusIntent.current = trigger
      ? { element: trigger, key: trigger.dataset?.tableFocus }
      : undefined

    router.visit(href, {
      preserveState: true,
      preserveScroll: true,
      ...(only.length ? { only } : {}),
      ...forwardedOptions,
      replace,
      onStart(event) {
        setBusy(true)
        onStart?.(event)
      },
      onFinish(event) {
        setBusy(false)
        const intent = focusIntent.current
        focusIntent.current = undefined
        restoreFocus(intent)
        onFinish?.(event)
      }
    })

    return href
  }, [])

  useEffect(() => {
    if (search === serverSearch) return
    const timer = setTimeout(() => {
      visit({ search, page: 1 }, { replace: true })
    }, 300)
    return () => clearTimeout(timer)
  }, [search, serverSearch, visit])

  function sort(field, trigger) {
    if (busy) return
    const direction = directionFor(options.query?.sort, field)
    const nextDirection = direction === 'ASC' ? 'DESC' : 'ASC'
    return visit({ sort: `${field} ${nextDirection}`, page: 1 }, { trigger })
  }

  function ariaSort(field) {
    const direction = directionFor(options.query?.sort, field)
    if (direction === 'ASC') return 'ascending'
    if (direction === 'DESC') return 'descending'
    return undefined
  }

  function sortButton(field, label = field) {
    const direction = directionFor(options.query?.sort, field)
    const nextDirection = direction === 'ASC' ? 'descending' : 'ascending'
    return {
      type: 'button',
      disabled: busy,
      'data-table-focus': `sort:${field}`,
      'aria-label': `Sort by ${label} ${nextDirection}`,
      onClick: (event) => sort(field, event.currentTarget)
    }
  }

  return {
    search,
    setSearch,
    busy,
    visit,
    sort,
    ariaSort,
    sortButton
  }
}
js
import { router } from '@inertiajs/svelte'

function valueOf(value) {
  return typeof value === 'function' ? value() : value
}

function sameValue(left, right) {
  if (Object.is(left, right)) return true
  if (left && right && typeof left === 'object' && typeof right === 'object') {
    return JSON.stringify(left) === JSON.stringify(right)
  }
  return false
}

function queryValue(value) {
  if (value === undefined || value === null || value === '') return undefined
  if (typeof value === 'object') return JSON.stringify(value)
  return String(value)
}

export function dataTableUrl(source, query, defaults = {}) {
  const raw = source || '/'
  const absolute = /^[a-z][a-z\d+.-]*:/i.test(raw)
  const url = new URL(raw, 'http://klean.invalid')
  const cleanDefaults = { page: 1, search: '', filters: {}, ...defaults }

  for (const [key, value] of Object.entries(query || {})) {
    if (sameValue(value, cleanDefaults[key])) {
      url.searchParams.delete(key)
      continue
    }
    const encoded = queryValue(value)
    if (encoded === undefined) url.searchParams.delete(key)
    else url.searchParams.set(key, encoded)
  }

  return absolute ? url.href : `${url.pathname}${url.search}${url.hash}`
}

function directionFor(sort, field) {
  const [activeField, direction = 'ASC'] = String(sort || '').split(/\s+/)
  return activeField === field ? direction.toUpperCase() : undefined
}

function restoreFocus(intent) {
  if (!intent || typeof document === 'undefined') return
  requestAnimationFrame(() => {
    if (intent.element?.isConnected) {
      intent.element.focus()
      return
    }
    const candidate = [...document.querySelectorAll('[data-table-focus]')].find(
      (element) => element.dataset.tableFocus === intent.key
    )
    candidate?.focus()
  })
}

export function createDataTableQuery(options) {
  const currentQuery = () => valueOf(options.query) || {}
  let search = $state(String(currentQuery().search ?? ''))
  let busy = $state(false)
  let previousServerSearch = String(currentQuery().search ?? '')
  let focusIntent

  function visit(updates = {}, visitOptions = {}) {
    const {
      replace = false,
      trigger,
      onStart,
      onFinish,
      ...forwardedOptions
    } = visitOptions
    const next = { ...currentQuery(), search, ...updates }
    const href = dataTableUrl(
      valueOf(options.url),
      next,
      valueOf(options.defaults) || {}
    )
    const only = valueOf(options.only) || []
    focusIntent = trigger
      ? { element: trigger, key: trigger.dataset?.tableFocus }
      : undefined

    router.visit(href, {
      preserveState: true,
      preserveScroll: true,
      ...(only.length ? { only } : {}),
      ...forwardedOptions,
      replace,
      onStart(event) {
        busy = true
        onStart?.(event)
      },
      onFinish(event) {
        busy = false
        const intent = focusIntent
        focusIntent = undefined
        restoreFocus(intent)
        onFinish?.(event)
      }
    })

    return href
  }

  function sort(field, trigger) {
    if (busy) return
    const direction = directionFor(currentQuery().sort, field)
    const nextDirection = direction === 'ASC' ? 'DESC' : 'ASC'
    return visit({ sort: `${field} ${nextDirection}`, page: 1 }, { trigger })
  }

  function ariaSort(field) {
    const direction = directionFor(currentQuery().sort, field)
    if (direction === 'ASC') return 'ascending'
    if (direction === 'DESC') return 'descending'
    return undefined
  }

  function sortButton(field, label = field) {
    const direction = directionFor(currentQuery().sort, field)
    const nextDirection = direction === 'ASC' ? 'descending' : 'ascending'
    return {
      type: 'button',
      disabled: busy,
      'data-table-focus': `sort:${field}`,
      'aria-label': `Sort by ${label} ${nextDirection}`,
      onclick: (event) => sort(field, event.currentTarget)
    }
  }

  $effect(() => {
    const next = String(currentQuery().search ?? '')
    if (next === previousServerSearch) return
    previousServerSearch = next
    search = next
  })

  $effect(() => {
    const value = search
    const serverValue = String(currentQuery().search ?? '')
    if (value === serverValue) return
    const timer = setTimeout(() => {
      visit({ search: value, page: 1 }, { replace: true })
    }, 300)
    return () => clearTimeout(timer)
  })

  return {
    get search() {
      return search
    },
    set search(value) {
      search = String(value ?? '')
    },
    get busy() {
      return busy
    },
    visit,
    sort,
    ariaSort,
    sortButton
  }
}
  • Table — the native semantic primitive DataTable composes.
  • Checkbox — page and row selection with a real mixed state.
  • Input — searchable server queries.
  • Select and Combobox — fixed and searchable filters.
  • Menu — truthful row and bulk actions.
  • Pagination — durable server-page links.
  • Alert — recoverable query and mutation failures.
  • Spinner — supplementary pending feedback.

All open source projects are released under the MIT License.