Table
A real HTML table rendered on the server: sorting is a link per header, the first column can stick, and it ships no JavaScript.
Table
split - size not measured yetA real HTML table rendered on the server: sorting is a link per header, the first column can stick, and it ships no JavaScript.
Try it
The code
import { Table, type TableDesignProps } from 'topclassui/table'
<Table
caption="Quarterly revenue by region"
items={regions}
getKey={(region) => region.name}
columns={[
{ key: 'name', header: 'Region', rowHeader: true, cell: (region) => region.name },
{ key: 'q1', header: 'Q1', align: 'end', cell: (region) => region.q1 },
{ key: 'q2', header: 'Q2', align: 'end', cell: (region) => region.q2 },
{ key: 'q3', header: 'Q3', align: 'end', cell: (region) => region.q3 },
{ key: 'q4', header: 'Q4', align: 'end', cell: (region) => region.q4 },
{ key: 'total', header: 'Full year', align: 'end', cell: (region) => region.total },
]}
/>Sorting is a link the server answers with the rows in their new order, so a sorted table costs no JavaScript.
Every state
7 statesImport from the package
import { Table } from 'topclassui/table'Install once:npm i topclassui@beta zod
Examples
captionis required, in real words: it is the only name of the table. It is read out but not drawn;captionVisibledraws it.- A column with
sortHrefmakes its header a link. Sort the rows on the server and passsortto mark the column; in Next.js passlink={<Link href="" />}. - Table renders the
itemsit is given, already sorted and paged; Pagination moves between pages. - Cells stay on one line, so a narrow screen scrolls the table sideways instead of stacking words. A column of prose opts out with its own
className. stickyFirstColumnkeeps the first column in view, cut with an ellipsis past--tc-table-sticky-width.columnsandgetKeyhold functions, so build the columns where you render the table, not in a Server Component that passes them to a Client Component.
import { Button } from 'topclassui/button'
import { Table } from 'topclassui/table'
interface Invoice { id: string; number: string; customer: string; status: string; amount: string }
const invoices: Invoice[] = [
{ id: 'inv_1042', number: 'INV-1042', customer: 'Northwind Traders', status: 'Paid', amount: '$1,250.00' },
{ id: 'inv_1043', number: 'INV-1043', customer: 'Contoso Ltd', status: 'Due in 3 days', amount: '$480.00' },
{ id: 'inv_1044', number: 'INV-1044', customer: 'Fabrikam Inc', status: 'Overdue', amount: '$9,120.50' },
{ id: 'inv_1045', number: 'INV-1045', customer: 'Adventure Works', status: 'Paid', amount: '$76.00' },
]
/** The drawn table: a caption read out but not drawn, the invoice as the header of its row, and a link per row named by its invoice. */
export default function Demo() {
return (
<Table
caption="Invoices"
items={invoices}
getKey={(invoice) => invoice.id}
columns={[
{ key: 'number', header: 'Invoice', rowHeader: true, cell: (invoice) => invoice.number },
{ key: 'customer', header: 'Customer', cell: (invoice) => invoice.customer },
{ key: 'status', header: 'Status', cell: (invoice) => invoice.status },
{ key: 'amount', header: 'Amount', align: 'end', cell: (invoice) => invoice.amount },
{
key: 'open',
header: <span className="sr-only">Open</span>,
align: 'end',
cell: (invoice) => (
<Button
size="sm"
variant="outline"
aria-label={`View invoice ${invoice.number}`}
render={<a href={`/invoices/${invoice.id}`} />}
>
View
</Button>
),
},
]}
/>
)
}
import { Table } from 'topclassui/table'
interface Customer { id: number; name: string; plan: string; seats: number }
const byName: Customer[] = [
{ id: 3, name: 'Adventure Works', plan: 'Team', seats: 12 },
{ id: 1, name: 'Contoso Ltd', plan: 'Enterprise', seats: 240 },
{ id: 4, name: 'Fabrikam Inc', plan: 'Free', seats: 1 },
{ id: 2, name: 'Northwind Traders', plan: 'Team', seats: 38 },
]
const bySeats = [...byName].sort((a, b) => b.seats - a.seats)
/** Each header is a link the server answers; the sorted one carries aria-sort and a caret. */
export default function Demo() {
return (
<div className="grid gap-6">
<Table
caption="Customers, by name"
captionVisible
items={byName}
getKey={(customer) => String(customer.id)}
sort={{ key: 'name', direction: 'ascending' }}
columns={[
{ key: 'name', header: 'Customer', sortHref: '?sort=name&dir=desc', cell: (customer) => customer.name },
{ key: 'plan', header: 'Plan', cell: (customer) => customer.plan },
{ key: 'seats', header: 'Seats', align: 'end', sortHref: '?sort=seats&dir=desc', cell: (customer) => customer.seats },
]}
/>
<Table
caption="Customers, by seats"
captionVisible
items={bySeats}
getKey={(customer) => String(customer.id)}
sort={{ key: 'seats', direction: 'descending' }}
columns={[
{ key: 'name', header: 'Customer', sortHref: '?sort=name&dir=asc', cell: (customer) => customer.name },
{ key: 'plan', header: 'Plan', cell: (customer) => customer.plan },
{ key: 'seats', header: 'Seats', align: 'end', sortHref: '?sort=seats&dir=asc', cell: (customer) => customer.seats },
]}
/>
</div>
)
}
import { Button } from 'topclassui/button'
import { Table, type TableColumn } from 'topclassui/table'
interface Region { name: string; closed: boolean; q1: string; q2: string; q3: string; q4: string; total: string }
const regions: Region[] = [
{ name: 'Middle East', closed: true, q1: '$412,000', q2: '$455,300', q3: '$498,120', q4: '$530,900', total: '$1,896,320' },
{ name: 'North Africa', closed: false, q1: '$208,400', q2: '$214,950', q3: '$240,010', q4: '$262,700', total: '$926,060' },
{ name: 'Western Europe', closed: true, q1: '$890,150', q2: '$874,600', q3: '$910,480', q4: '$955,230', total: '$3,630,460' },
{ name: 'North America', closed: false, q1: '$1,204,800', q2: '$1,260,340', q3: '$1,315,900', q4: '$1,402,650', total: '$5,183,690' },
]
const columns: TableColumn<Region>[] = [
{ key: 'name', header: 'Region', rowHeader: true, cell: (region) => region.name },
{
key: 'report',
header: 'Report',
cell: (region) => (
<Button size="sm" variant="outline" disabled={!region.closed} aria-label={`Export ${region.name}`}>Export</Button>
),
},
{ key: 'q1', header: 'Q1', align: 'end', sortHref: '?sort=q1', cell: (region) => region.q1 },
{ key: 'q2', header: 'Q2', align: 'end', sortHref: '?sort=q2', cell: (region) => region.q2 },
{ key: 'q3', header: 'Q3', align: 'end', sortHref: '?sort=q3', cell: (region) => region.q3 },
{ key: 'q4', header: 'Q4', align: 'end', sortHref: '?sort=q4', cell: (region) => region.q4 },
{ key: 'total', header: 'Full year', align: 'end', sortHref: '?sort=total', cell: (region) => region.total },
]
/** A box narrower than its table: the region names stay in view, over tinted rows and a dimmed button alike. */
export default function Demo() {
return (
<div className="grid max-w-sm gap-6">
<Table
stickyFirstColumn
caption="Revenue by region"
items={regions}
getKey={(region) => region.name}
columns={columns}
/>
<Table
stickyFirstColumn
striped
caption="Revenue by region, striped"
items={regions}
getKey={(region) => region.name}
columns={columns}
/>
</div>
)
}
import { Button } from 'topclassui/button'
import { Table, type TableColumn } from 'topclassui/table'
interface Order { number: number; customer: string; status: string; placed: string; total: string }
const customers = ['Northwind Traders', 'Contoso Ltd', 'Fabrikam Inc', 'Adventure Works', 'Tailspin Toys']
const statuses = ['Shipped', 'Awaiting payment', 'Delivered']
/** One page of 25, as a server would send it: the newest order first. */
const orders: Order[] = Array.from({ length: 25 }, (_, index) => ({
number: 1042 - index,
customer: customers[index % customers.length] ?? '',
status: statuses[index % statuses.length] ?? '',
placed: `${String(28 - index)} Mar 2026`,
total: `$${String((index + 1) * 120)}.00`,
}))
const columns: TableColumn<Order>[] = [
{
key: 'number',
header: 'Order',
rowHeader: true,
sortHref: '?sort=number&dir=asc',
cell: (order) => (
<Button size="sm" variant="link" render={<a href={`/orders/${String(order.number)}`} />}>
{`#${String(order.number)}`}
</Button>
),
},
{ key: 'customer', header: 'Customer', sortHref: '?sort=customer&dir=asc', cell: (order) => order.customer },
{ key: 'status', header: 'Status', cell: (order) => order.status },
{ key: 'placed', header: 'Placed', sortHref: '?sort=placed&dir=asc', cell: (order) => order.placed },
{ key: 'total', header: 'Total', align: 'end', sortHref: '?sort=total&dir=asc', cell: (order) => order.total },
{
key: 'refund',
header: <span className="sr-only">Actions</span>,
align: 'end',
// The name says which row: a list of buttons would otherwise read "Refund" 25 times.
cell: (order) => (
<Button size="sm" variant="outline" aria-label={`Refund order ${String(order.number)}`}>Refund</Button>
),
},
]
/** A page of orders in a narrow box: a link in the stuck column, and an action per row named by its row. */
export default function Demo() {
return (
<div className="max-w-sm">
<Table
stickyFirstColumn
caption="Orders"
items={orders}
getKey={(order) => String(order.number)}
sort={{ key: 'number', direction: 'descending' }}
columns={columns}
/>
</div>
)
}
import { Table } from 'topclassui/table'
interface Deploy { id: string; branch: string; author: string; duration: string }
const deploys: Deploy[] = [
{ id: 'dpl_7f3a', branch: 'main', author: 'Layla Haddad', duration: '48 s' },
{ id: 'dpl_7f39', branch: 'fix/checkout-total', author: 'Omar Abed', duration: '51 s' },
{ id: 'dpl_7f38', branch: 'main', author: 'Sara Nasser', duration: '47 s' },
{ id: 'dpl_7f37', branch: 'feat/table', author: 'Omar Abed', duration: '1 min 12 s' },
{ id: 'dpl_7f36', branch: 'main', author: 'Layla Haddad', duration: '46 s' },
{ id: 'dpl_7f35', branch: 'chore/deps', author: 'Yousef Karim', duration: '2 min 3 s' },
]
/** Every second row is tinted, so the eye can follow a row across. */
export default function Demo() {
return (
<Table
striped
caption="Recent deploys"
items={deploys}
getKey={(deploy) => deploy.id}
columns={[
{ key: 'id', header: 'Deploy', cell: (deploy) => deploy.id },
{ key: 'branch', header: 'Branch', cell: (deploy) => deploy.branch },
{ key: 'author', header: 'Author', cell: (deploy) => deploy.author },
{ key: 'duration', header: 'Duration', align: 'end', cell: (deploy) => deploy.duration },
]}
/>
)
}
import { Table } from 'topclassui/table'
interface ApiKey { id: string; label: string; created: string; lastUsed: string }
const apiKeys: ApiKey[] = [
{ id: 'key_live_a81f', label: 'Production', created: '12 Mar 2026', lastUsed: '2 minutes ago' },
{ id: 'key_test_33c0', label: 'Staging', created: '4 Jan 2026', lastUsed: 'Yesterday' },
{ id: 'key_test_9d2e', label: 'Local development', created: '19 Nov 2025', lastUsed: 'Never' },
]
/** captionVisible draws the caption above the header row; it names the table either way. */
export default function Demo() {
return (
<Table
captionVisible
caption="API keys"
items={apiKeys}
getKey={(apiKey) => apiKey.id}
columns={[
{ key: 'label', header: 'Name', cell: (apiKey) => apiKey.label },
{ key: 'id', header: 'Key', cell: (apiKey) => apiKey.id },
{ key: 'created', header: 'Created', cell: (apiKey) => apiKey.created },
{ key: 'lastUsed', header: 'Last used', align: 'end', cell: (apiKey) => apiKey.lastUsed },
]}
/>
)
}
import { Table } from 'topclassui/table'
interface Ticket { id: string; subject: string; note: string; opened: string; assignee: string; replies: number }
const tickets: Ticket[] = [
{
id: 'T-2041',
subject: 'Invoice total is wrong',
note: 'The customer was charged twice for the March renewal. Finance has refunded one payment and is waiting for the bank.',
opened: '12 Mar 2026',
assignee: 'Layla Haddad',
replies: 14,
},
{
id: 'T-2038',
subject: 'Cannot invite a member',
note: 'The invitation email never arrives at addresses on the company domain.',
opened: '9 Mar 2026',
assignee: 'Sara Nasser',
replies: 3,
},
{
id: 'T-2030',
subject: 'Export is empty',
note: 'Fixed in the last release. Waiting for the customer to confirm.',
opened: '2 Mar 2026',
assignee: 'Yousef Karim',
replies: 120,
},
]
/** Whoever is signed in: an agent sees no Assignee column. */
const viewer = { name: 'Omar Abed', canAssign: false }
/** A class per column: prose that wraps at a width of its own, a date hidden on a phone, and a column left out by a flag. */
export default function Demo() {
return (
<Table
caption="Open tickets"
items={tickets}
getKey={(ticket) => ticket.id}
columns={[
{ key: 'id', header: 'Ticket', rowHeader: true, cell: (ticket) => ticket.id },
{ key: 'subject', header: 'Subject', cell: (ticket) => ticket.subject },
{ key: 'note', header: 'Last note', className: 'min-w-64 whitespace-normal', cell: (ticket) => ticket.note },
{ key: 'opened', header: 'Opened', className: 'hidden sm:table-cell', cell: (ticket) => ticket.opened },
viewer.canAssign && { key: 'assignee', header: 'Assignee', cell: (ticket) => ticket.assignee },
{ key: 'replies', header: 'Replies', align: 'end', cell: (ticket) => ticket.replies },
]}
/>
)
}
import { Button } from 'topclassui/button'
import { Table, type TableColumn } from 'topclassui/table'
interface Member { id: number; name: string; role: string }
const nobody: Member[] = []
const columns: TableColumn<Member>[] = [
{ key: 'name', header: 'Name', cell: (member) => member.name },
{ key: 'role', header: 'Role', cell: (member) => member.role },
{ key: 'id', header: 'Member number', align: 'end', cell: (member) => member.id },
]
/** No items: one row across every column, with the built-in words or the caller's own. */
export default function Demo() {
return (
<div className="grid gap-6">
<Table caption="Pending invitations" items={nobody} getKey={(member) => String(member.id)} columns={columns} />
<Table
caption="Team members"
captionVisible
items={nobody}
getKey={(member) => String(member.id)}
columns={columns}
emptyFallback={(
<div className="grid justify-items-center gap-3 py-4">
<p>Nobody has joined this team yet.</p>
<Button size="sm" render={<a href="/team/invite" />}>Invite a member</Button>
</div>
)}
/>
</div>
)
}
import { Table, TableSkeleton } from 'topclassui/table'
interface Deploy { id: string; branch: string; author: string; duration: string }
const deploys: Deploy[] = [
{ id: 'dpl_7f3a', branch: 'main', author: 'Layla Haddad', duration: '48 s' },
{ id: 'dpl_7f39', branch: 'fix/checkout-total', author: 'Omar Abed', duration: '51 s' },
{ id: 'dpl_7f38', branch: 'main', author: 'Sara Nasser', duration: '47 s' },
{ id: 'dpl_7f37', branch: 'feat/table', author: 'Omar Abed', duration: '1 min 12 s' },
]
/** The placeholder a Suspense boundary shows, above the table it stands in for: the same box and the same rows. */
export default function Demo() {
return (
<div className="grid gap-6">
<TableSkeleton rows={4} columns={4} />
<Table
caption="Latest deploys"
items={deploys}
getKey={(deploy) => deploy.id}
columns={[
{ key: 'id', header: 'Deploy', rowHeader: true, cell: (deploy) => deploy.id },
{ key: 'branch', header: 'Branch', cell: (deploy) => deploy.branch },
{ key: 'author', header: 'Author', cell: (deploy) => deploy.author },
{ key: 'duration', header: 'Duration', align: 'end', cell: (deploy) => deploy.duration },
]}
/>
</div>
)
}
Recent orders
This week
128 orders and 4 refunds.
Orders that still wait for a payment.
import { Table, type TableColumn } from 'topclassui/table'
interface Order { id: string; customer: string; placed: string; status: string; items: number; total: string }
const orders: Order[] = [
{ id: 'ORD-5521', customer: 'Northwind Traders', placed: '12 Mar 2026', status: 'Awaiting payment', items: 14, total: '$1,250.00' },
{ id: 'ORD-5520', customer: 'Contoso Ltd', placed: '11 Mar 2026', status: 'Shipped', items: 3, total: '$480.00' },
{ id: 'ORD-5519', customer: 'Fabrikam Inc', placed: '11 Mar 2026', status: 'Awaiting payment', items: 120, total: '$9,120.50' },
{ id: 'ORD-5518', customer: 'Adventure Works', placed: '9 Mar 2026', status: 'Delivered', items: 1, total: '$76.00' },
]
const columns: TableColumn<Order>[] = [
{ key: 'id', header: 'Order', rowHeader: true, cell: (order) => order.id },
{ key: 'customer', header: 'Customer', cell: (order) => order.customer },
{ key: 'placed', header: 'Placed', cell: (order) => order.placed },
{ key: 'status', header: 'Status', cell: (order) => order.status },
{ key: 'items', header: 'Items', align: 'end', cell: (order) => order.items },
{ key: 'total', header: 'Total', align: 'end', cell: (order) => order.total },
]
/** The two layouts a dashboard is made of, a card in a grid and a column beside a sidebar: on a phone the table scrolls inside each, and the page does not. */
export default function Demo() {
return (
<div className="flex flex-col gap-8">
<div className="grid gap-6 lg:grid-cols-3">
<section className="rounded-box bg-surface p-4 lg:col-span-2">
<h2 className="pb-3 text-sm font-semibold">Recent orders</h2>
<Table caption="Recent orders" items={orders} getKey={(order) => order.id} columns={columns} />
</section>
<section className="rounded-box bg-surface p-4">
<h2 className="pb-3 text-sm font-semibold">This week</h2>
<p className="text-sm text-muted">128 orders and 4 refunds.</p>
</section>
</div>
<div className="flex gap-6">
<p className="hidden w-40 shrink-0 text-sm text-muted sm:block">Orders that still wait for a payment.</p>
<div className="flex-1">
<Table
striped
caption="Orders awaiting payment"
items={orders.filter((order) => order.status === 'Awaiting payment')}
getKey={(order) => order.id}
columns={columns}
/>
</div>
</div>
</div>
)
}
import { ar } from 'topclassui/labels/ar'
import { Table, type TableColumn } from 'topclassui/table'
interface Invoice { id: string; customer: string; status: string; due: string; amount: string }
const invoices: Invoice[] = [
{ id: 'فاتورة-١٠٤٢', customer: 'شركة الشمال للتجارة', status: 'مدفوعة', due: '١٢ مارس ٢٠٢٦', amount: '١٬٢٥٠٫٠٠ د.أ' },
{ id: 'فاتورة-١٠٤٣', customer: 'مؤسسة النخيل', status: 'تستحق بعد ٣ أيام', due: '١٨ مارس ٢٠٢٦', amount: '٤٨٠٫٠٠ د.أ' },
{ id: 'فاتورة-١٠٤٤', customer: 'دار الياسمين للنشر', status: 'متأخرة', due: '١ مارس ٢٠٢٦', amount: '٩٬١٢٠٫٥٠ د.أ' },
{ id: 'فاتورة-١٠٤٥', customer: 'مخبز الأمل', status: 'مدفوعة', due: '٢٥ فبراير ٢٠٢٦', amount: '٧٦٫٠٠ د.أ' },
]
const nobody: Invoice[] = []
const columns: TableColumn<Invoice>[] = [
{ key: 'id', header: 'الفاتورة', sortHref: '?sort=id&dir=asc', rowHeader: true, cell: (invoice) => invoice.id },
{ key: 'customer', header: 'العميل', sortHref: '?sort=customer&dir=asc', cell: (invoice) => invoice.customer },
{ key: 'status', header: 'الحالة', cell: (invoice) => invoice.status },
{ key: 'due', header: 'تاريخ الاستحقاق', cell: (invoice) => invoice.due },
{ key: 'amount', header: 'المبلغ', align: 'end', sortHref: '?sort=amount&dir=asc', cell: (invoice) => invoice.amount },
]
/** Arabic: the first column sticks to the right edge, the amounts end on the left, and the built-in words are Arabic. */
export default function Demo() {
return (
<div dir="rtl" lang="ar" className="grid gap-6">
<Table
labels={ar}
captionVisible
caption="الفواتير"
items={invoices}
getKey={(invoice) => invoice.id}
sort={{ key: 'amount', direction: 'descending' }}
columns={columns}
/>
<div className="max-w-sm">
<Table
labels={ar}
stickyFirstColumn
striped
caption="الفواتير في مساحة ضيقة"
items={invoices}
getKey={(invoice) => invoice.id}
sort={{ key: 'id', direction: 'ascending' }}
columns={columns}
/>
</div>
<Table labels={ar} caption="فواتير الشهر القادم" items={nobody} getKey={(invoice) => invoice.id} columns={columns} />
</div>
)
}
Props
Keyboard and accessibility
- A real
<table>: a screen reader moves through it by row and by column. Mark the column that identifies a row withrowHeader: true. - The sorted column carries
aria-sort, and its caret says the direction in words, becausearia-sortis not read out everywhere. - Name a row action by its row, such as "Refund order 1042", never a bare "Refund" repeated on every row.
Right to left
The first column sticks to the right edge, an end-aligned amount sits on the left, and the built-in words come from labels.
Server and client
Renders on the server; the interactive part is a client component that ships only when it is used.
Files: index.tsx, client.tsx.
On the chip: split - size not measured yet.