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.

Table.vue
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 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 table

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

There is no initializer, configuration file, provider, TableHeader, TableRow, TableCell, barrel file, or runtime package.

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.

Vue

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

React

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

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

API

InputDefaultPurpose
class / classNameOrdinary Tailwind classes merged after the neutral table baseline.
native attributesIDs, test hooks, ARIA attributes, and other native table attributes.
default contentNative 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.

Slipway and Hagfish recipes

The same Table can carry Slipway's dense operational results and Hagfish's editorial reporting voice because neither treatment is hidden behind a product variant.

ProductTables.vue
Hagfish's editable invoice items remain a responsive form/list. A report ledger is tabular; a collection of editable controls is not.

Table or Data Table?

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.

A future Data Table will compose Table when users need a coordinated stateful system: sorting, filtering, column visibility, selection, pagination, or server-backed loading. That state should be durable in the URL when it changes what the user is looking at, 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.
  • Wait for Data Table when the primary problem is coordinated sort, filter, selection, pagination, and server state rather than markup.

Complete framework source

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

Vue source

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

React source

Table.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 source

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.
  • Data Table — the planned stateful layer for durable sorting, filtering, selection, and pagination.

All open source projects are released under the MIT License.