Skip to content

Table ​

Table is a thin native <table> with a neutral typographic baseline. The application writes the caption, column and row headers, sections, cells, actions, responsive wrapper, and every product-specific Tailwind class directly.

That is the complete API. Klean does not replace the browser's table model with an item schema, render callbacks, anatomy components, or visual variants.

Preview Live · Vue
Usage
vue
<script setup>
import Table from '@/components/ui/table/Table.vue'

const services = [
  { name: 'api', status: 'Healthy', memory: '384 MB' },
  { name: 'worker', status: 'Deploying', memory: '192 MB' },
  { name: 'web', status: 'Healthy', memory: '256 MB' }
]
</script>

<template>
  <div class="overflow-x-auto rounded-lg border border-gray-200">
    <Table class="min-w-128">
      <caption class="caption-top px-4 py-3 text-left font-semibold">
        Production services
      </caption>
      <thead class="bg-gray-50 text-xs uppercase tracking-wider text-gray-600">
        <tr>
          <th scope="col" class="px-4 py-3 font-medium">Service</th>
          <th scope="col" class="px-4 py-3 font-medium">Status</th>
          <th scope="col" class="px-4 py-3 text-right font-medium">Memory</th>
        </tr>
      </thead>
      <tbody class="divide-y divide-gray-100">
        <tr v-for="service in services" :key="service.name">
          <th scope="row" class="px-4 py-3 font-mono font-medium">
            {{ service.name }}
          </th>
          <td class="px-4 py-3">{{ service.status }}</td>
          <td class="px-4 py-3 text-right tabular-nums">
            {{ service.memory }}
          </td>
        </tr>
      </tbody>
    </Table>
  </div>
</template>
jsx
import Table from '@/components/ui/table/Table.jsx'

const services = [
  { name: 'api', status: 'Healthy', memory: '384 MB' },
  { name: 'worker', status: 'Deploying', memory: '192 MB' },
  { name: 'web', status: 'Healthy', memory: '256 MB' }
]

export default function ServicesTable() {
  return (
    <div className="overflow-x-auto rounded-lg border border-gray-200">
      <Table className="min-w-128">
        <caption className="caption-top px-4 py-3 text-left font-semibold">
          Production services
        </caption>
        <thead className="bg-gray-50 text-xs uppercase tracking-wider text-gray-600">
          <tr>
            <th scope="col" className="px-4 py-3 font-medium">
              Service
            </th>
            <th scope="col" className="px-4 py-3 font-medium">
              Status
            </th>
            <th scope="col" className="px-4 py-3 text-right font-medium">
              Memory
            </th>
          </tr>
        </thead>
        <tbody className="divide-y divide-gray-100">
          {services.map((service) => (
            <tr key={service.name}>
              <th scope="row" className="px-4 py-3 font-mono font-medium">
                {service.name}
              </th>
              <td className="px-4 py-3">{service.status}</td>
              <td className="px-4 py-3 text-right tabular-nums">
                {service.memory}
              </td>
            </tr>
          ))}
        </tbody>
      </Table>
    </div>
  )
}
svelte
<script>
  import Table from '$lib/components/ui/table/Table.svelte'

  const services = [
    { name: 'api', status: 'Healthy', memory: '384 MB' },
    { name: 'worker', status: 'Deploying', memory: '192 MB' },
    { name: 'web', status: 'Healthy', memory: '256 MB' }
  ]
</script>

<div class="overflow-x-auto rounded-lg border border-gray-200">
  <Table class="min-w-128">
    <caption class="caption-top px-4 py-3 text-left font-semibold">
      Production services
    </caption>
    <thead class="bg-gray-50 text-xs uppercase tracking-wider text-gray-600">
      <tr>
        <th scope="col" class="px-4 py-3 font-medium">Service</th>
        <th scope="col" class="px-4 py-3 font-medium">Status</th>
        <th scope="col" class="px-4 py-3 text-right font-medium">Memory</th>
      </tr>
    </thead>
    <tbody class="divide-y divide-gray-100">
      {#each services as service (service.name)}
        <tr>
          <th scope="row" class="px-4 py-3 font-mono font-medium">
            {service.name}
          </th>
          <td class="px-4 py-3">{service.status}</td>
          <td class="px-4 py-3 text-right tabular-nums">
            {service.memory}
          </td>
        </tr>
      {/each}
    </tbody>
  </Table>
</div>
View source Table.vue
Table.vue
vue
<script setup>
import { computed, ref, useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'

defineOptions({ inheritAttrs: false })

const attrs = useAttrs()
const element = ref()

const forwardedAttrs = computed(() => {
  const { class: _class, 'data-slot': _dataSlot, ...rest } = attrs
  return rest
})

defineExpose({ element })
</script>

<template>
  <table
    ref="element"
    v-bind="forwardedAttrs"
    data-slot="table"
    :class="
      twMerge(
        'w-full border-collapse text-left text-sm text-gray-950 dark:text-white',
        attrs.class
      )
    "
  >
    <slot />
  </table>
</template>

The wrapper owns overflow. The browser still exposes one native captioned table.

Installation ​

One command detects Vue, React, or Svelte and writes one framework-native source file into the conventional component directory:

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

Terminal
npx klean-ui add table

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

Usage ​

Use Table for the root and write ordinary HTML beneath it. This keeps semantics visible in reviews and puts Tailwind exactly where the visual decision belongs.

Copy the example above for your framework.

API ​

InputDefaultPurpose
class / className—Ordinary Tailwind classes merged after the neutral table baseline.
native attributes—IDs, test hooks, ARIA attributes, and other native table attributes.
default content—Native caption, sections, rows, headers, cells, links, buttons, and text.

Table forwards a framework-native element reference. It owns no rows, sorting, filtering, selection, pagination, loading, or empty state.

Write the table you mean ​

Native elements already express the relationships a data grid needs:

  • give the table an accessible name with a visible <caption> or a visually hidden one when nearby visible context already names it;
  • use <thead>, <tbody>, and <tfoot> to group rows when those sections exist;
  • use <th scope="col"> for column headers and <th scope="row"> for row headers;
  • put buttons around sortable header labels only when sorting exists, then update aria-sort on the sorted header;
  • give repeated row actions a specific accessible name, such as “Inspect api”, even if the visible label is only “Inspect”.

Do not add ARIA table roles to native table elements. Do not use Table for layout.

Responsive tables ​

Tables describe two-dimensional relationships. Preserve that structure at narrow widths and let an explicit wrapper scroll:

html
<p id="services-scroll-help" class="text-sm text-gray-600">
  Scroll horizontally to see every service field.
</p>
<div
  class="overflow-x-auto"
  tabindex="0"
  aria-describedby="services-scroll-help"
>
  <table class="min-w-160">
    <!-- native table content -->
  </table>
</div>

The focusable wrapper makes keyboard scrolling available where the browser does not already expose it. Do not turn rows or cells into display: block; that can obscure the relationships that made a table appropriate.

Report and results recipes ​

Use compact rows for operational results or stronger typography and borders for a report. Both are ordinary Tailwind treatments.

Preview Live · Vue
View source ProductTables.vue
ProductTables.vue
<script setup>
import Table from '@/components/ui/table/Table.vue'

const queryRows = [
  { id: 4012, email: '[email protected]', plan: 'Pro', total: '$48.00' },
  { id: 4011, email: '[email protected]', plan: 'Team', total: '$120.00' },
  { id: 4010, email: '[email protected]', plan: 'Pro', total: '$48.00' }
]

const invoiceRows = [
  { item: 'Brand direction', quantity: 1, amount: '$1,600.00' },
  { item: 'Landing page', quantity: 2, amount: '$2,400.00' },
  { item: 'Launch support', quantity: 4, amount: '$800.00' }
]
</script>

<template>
  <div class="grid gap-8 xl:grid-cols-2">
    <article
      class="overflow-hidden rounded-lg border border-gray-800 bg-gray-950 text-white"
    >
      <header class="px-5 py-4">
        <p class="font-mono text-xs uppercase tracking-wider text-gray-500">
          Slipway / SQL result
        </p>
        <h2 class="mt-1 text-lg font-semibold">customers · 3 rows</h2>
      </header>
      <div class="overflow-x-auto">
        <Table class="min-w-128 text-gray-100 dark:text-gray-100">
          <caption class="sr-only">
            Latest customer query results
          </caption>
          <thead
            class="border-y border-gray-800 bg-gray-900 font-mono text-xs uppercase tracking-wider text-gray-400"
          >
            <tr>
              <th scope="col" class="px-5 py-3 font-medium">id</th>
              <th scope="col" class="px-5 py-3 font-medium">email</th>
              <th scope="col" class="px-5 py-3 font-medium">plan</th>
              <th scope="col" class="px-5 py-3 text-right font-medium">
                total
              </th>
            </tr>
          </thead>
          <tbody class="divide-y divide-gray-900 font-mono text-xs">
            <tr v-for="row in queryRows" :key="row.id" class="hover:bg-white/5">
              <td class="px-5 py-3 text-gray-400 tabular-nums">{{ row.id }}</td>
              <td class="px-5 py-3">{{ row.email }}</td>
              <td class="px-5 py-3">{{ row.plan }}</td>
              <td class="px-5 py-3 text-right tabular-nums">{{ row.total }}</td>
            </tr>
          </tbody>
        </Table>
      </div>
    </article>

    <article
      class="overflow-hidden border-2 border-black bg-[#f7f3eb] p-6 text-black shadow-[6px_6px_0_0_#000]"
    >
      <p class="font-mono text-xs uppercase tracking-wider text-black/55">
        Hagfish / report ledger
      </p>
      <h2 class="mt-2 text-2xl font-semibold tracking-tight">
        Invoice INV-1042
      </h2>
      <div class="mt-6 overflow-x-auto">
        <Table
          class="min-w-112 border-separate border-spacing-0 text-black dark:text-black"
        >
          <caption class="sr-only">
            Invoice INV-1042 line item report
          </caption>
          <thead
            class="font-mono text-xs uppercase tracking-wider text-black/55"
          >
            <tr>
              <th
                scope="col"
                class="border-b-2 border-black py-3 pr-6 font-medium"
              >
                Work
              </th>
              <th
                scope="col"
                class="border-b-2 border-black px-6 py-3 text-right font-medium"
              >
                Qty
              </th>
              <th
                scope="col"
                class="border-b-2 border-black py-3 pl-6 text-right font-medium"
              >
                Amount
              </th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="row in invoiceRows" :key="row.item">
              <th
                scope="row"
                class="border-b border-black/20 py-4 pr-6 font-medium"
              >
                {{ row.item }}
              </th>
              <td
                class="border-b border-black/20 px-6 py-4 text-right tabular-nums"
              >
                {{ row.quantity }}
              </td>
              <td
                class="border-b border-black/20 py-4 pl-6 text-right font-semibold tabular-nums"
              >
                {{ row.amount }}
              </td>
            </tr>
          </tbody>
        </Table>
      </div>
    </article>
  </div>
</template>

Use a table when rows and columns help people compare data. A form or list may suit editable invoice items better than a report layout.

Table or DataTable? ​

Use Table when the application already has rows to render and native markup expresses the experience. It is enough for reports, invoices, query results, audit history, comparison matrices, and small operational lists.

DataTable composes Table when users need a coordinated server-driven system: search, filtering, sorting, selection, pagination, and pending navigation. Its optional query helper keeps that visible state durable in clean URLs so reload, sharing, Back/Forward, and server rendering preserve the same view.

Keeping the layers separate avoids making every small table configure features it does not use. Moving from Table to Data Table should preserve the native rows and cells rather than require a new visual language.

When to use ​

Use Table when rows and columns have relationships that users need to compare or scan: database results, deployments, invoices, audit events, billing records, permissions, and compact reports.

When not to use ​

  • Use a list when each item stands alone and column alignment adds no meaning.
  • Use cards when each item has a different content shape or a strong independent action hierarchy.
  • Keep editable invoice line items as a responsive form/list when controls must reflow naturally on small screens.
  • Use DataTable when the primary problem is coordinated search, filters, sorting, selection, pagination, and server state rather than markup.

Complete framework source ​

Copy, inspect, and change the complete one-file source for your framework.

vue
<script setup>
import { computed, ref, useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'

defineOptions({ inheritAttrs: false })

const attrs = useAttrs()
const element = ref()

const forwardedAttrs = computed(() => {
  const { class: _class, 'data-slot': _dataSlot, ...rest } = attrs
  return rest
})

defineExpose({ element })
</script>

<template>
  <table
    ref="element"
    v-bind="forwardedAttrs"
    data-slot="table"
    :class="
      twMerge(
        'w-full border-collapse text-left text-sm text-gray-950 dark:text-white',
        attrs.class
      )
    "
  >
    <slot />
  </table>
</template>
jsx
import { forwardRef } from 'react'
import { twMerge } from 'tailwind-merge'

const BASE_CLASSES =
  'w-full border-collapse text-left text-sm text-gray-950 dark:text-white'

const Table = forwardRef(function Table(
  { className, 'data-slot': _dataSlot, ...props },
  ref
) {
  return (
    <table
      {...props}
      ref={ref}
      data-slot="table"
      className={twMerge(BASE_CLASSES, className)}
    />
  )
})

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

  let {
    children,
    class: className,
    "data-slot": _dataSlot,
    ...props
  } = $props();

  let element;

  export function getElement() {
    return element;
  }
</script>

<table
  {...props}
  bind:this={element}
  data-slot="table"
  class={twMerge(
    "w-full border-collapse text-left text-sm text-gray-950 dark:text-white",
    className,
  )}
>
  {@render children?.()}
</table>
  • Button — supplies truthful row and header actions.
  • Checkbox — supports explicit row selection when the application owns that state.
  • Combobox — handles searchable filters outside the table.
  • Tabs — separates related result views without changing table semantics.
  • Pagination — navigates server-owned result pages while preserving the list's URL state.
  • DataTable — the durable server-driven layer for search, filters, sorting, selection, and pagination.

All open source projects are released under the MIT License.