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.
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.
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
<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
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>
API
| Input | Default | Purpose |
|---|---|---|
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-sorton 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:
<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.
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
<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
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
<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>
Related components
- 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.