Skip to content

DataTable

DataTable coordinates a real native table for server-driven application work. It keeps selection honest across the current page, exposes a truthful busy state, and offers an optional Inertia query helper so search, sort, filters, and pagination survive 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.

BridgeServices.vue
A Slipway-style 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 one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.

Terminal
npx klean-ui add data-table

  • No initializer or configuration file
  • No framework, alias, or theme questions
  • No Klean runtime dependency

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

Vue

ServicesTable.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>

React

ServicesTable.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

ServicesTable.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}
/>

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 / classNameTailwind classes for the responsive scroll container.
tableClass / tableClassNameTailwind 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 product wrapper when several pages share one treatment. Slipway can preserve Bridge's dense dark operational surface; Hagfish can use its editorial borders and typography from the same component contract.

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

DataTable.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>

useDataTableQuery.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
  }
}

React

DataTable.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

useDataTableQuery.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
  }
}

Svelte

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>

dataTableQuery.svelte.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.