Table
<oblyx-table> is a thin behavioral wrapper around a real, author-written <table>. It adds density, dividers, striping, hover, sticky header and column, a horizontal scroll wrapper, sort decoration, and an empty-row fallback, on top of markup you write yourself.
Basic table
| Name | Status | Owner |
|---|---|---|
| Checkout redesign | Priya Shah | |
| Billing migration | Marcus Webb | |
| Onboarding flow | Priya Shah |
<oblyx-table>
<table>
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Owner</th>
</tr>
</thead>
<tbody>
<tr>
<td>Checkout redesign</td>
<td><oblyx-badge variant="success">Live</oblyx-badge></td>
<td>Priya Shah</td>
</tr>
<tr>
<td>Billing migration</td>
<td><oblyx-badge variant="warning">In review</oblyx-badge></td>
<td>Marcus Webb</td>
</tr>
<tr>
<td>Onboarding flow</td>
<td><oblyx-badge variant="neutral">Draft</oblyx-badge></td>
<td>Priya Shah</td>
</tr>
</tbody>
</table>
</oblyx-table>oblyxTable {
table {
thead {
tr {
th { +"Name" }
th { +"Status" }
th { +"Owner" }
}
}
tbody {
tr {
td { +"Checkout redesign" }
td { oblyxBadge(variant = OblyxBadgeVariant.SUCCESS) { +"Live" } }
td { +"Priya Shah" }
}
tr {
td { +"Billing migration" }
td { oblyxBadge(variant = OblyxBadgeVariant.WARNING) { +"In review" } }
td { +"Marcus Webb" }
}
tr {
td { +"Onboarding flow" }
td { oblyxBadge(variant = OblyxBadgeVariant.NEUTRAL) { +"Draft" } }
td { +"Priya Shah" }
}
}
}
}Why there is no row or cell component
There is no oblyx-table-row or oblyx-table-cell. The author writes a real <table> with <thead>, <tbody>, <tr>, <th> and <td> inside <oblyx-table>, exactly as hand-written HTML already allows. This is a feature, not a gap: a badge in a cell is just a badge in a cell, with no cell-content injection API to learn, and every row is trivially fragment-safe. A <tr> has always been independently renderable, so a server can return a single row from an HTMX endpoint and swap it into an existing <tbody> with no re-initialization step for the table itself.
Row and cell styling
density steps padding and type scale together, matching <oblyx-list>'s own density. dividers adds a vertical border between columns; the horizontal row separators are already on by default and are not what dividers controls. striped alternates row background, and hover highlights the row under the pointer.
Dividers, striping, hover, and compact density
| Name | Status | Owner |
|---|---|---|
| Checkout redesign | Priya Shah | |
| Billing migration | Marcus Webb | |
| Onboarding flow | Priya Shah |
<oblyx-table dividers striped hover density="compact">
<table>
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Owner</th>
</tr>
</thead>
<tbody>
<tr>
<td>Checkout redesign</td>
<td><oblyx-badge variant="success">Live</oblyx-badge></td>
<td>Priya Shah</td>
</tr>
<tr>
<td>Billing migration</td>
<td><oblyx-badge variant="warning">In review</oblyx-badge></td>
<td>Marcus Webb</td>
</tr>
<tr>
<td>Onboarding flow</td>
<td><oblyx-badge variant="neutral">Draft</oblyx-badge></td>
<td>Priya Shah</td>
</tr>
</tbody>
</table>
</oblyx-table>oblyxTable(dividers = true, striped = true, hover = true, density = OblyxDensity.COMPACT) {
table {
thead {
tr {
th { +"Name" }
th { +"Status" }
th { +"Owner" }
}
}
tbody {
tr {
td { +"Checkout redesign" }
td { oblyxBadge(variant = OblyxBadgeVariant.SUCCESS) { +"Live" } }
td { +"Priya Shah" }
}
tr {
td { +"Billing migration" }
td { oblyxBadge(variant = OblyxBadgeVariant.WARNING) { +"In review" } }
td { +"Marcus Webb" }
}
tr {
td { +"Onboarding flow" }
td { oblyxBadge(variant = OblyxBadgeVariant.NEUTRAL) { +"Draft" } }
td { +"Priya Shah" }
}
}
}
}Caption
caption renders a real <caption> as the table's first child. An author-written <caption> already inside the <table> wins outright and is never overwritten.
Table with a caption
| Name | Status |
|---|---|
| Checkout redesign |
<oblyx-table caption="Recent projects">
<table>
<thead>
<tr>
<th>Name</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>Checkout redesign</td>
<td><oblyx-badge variant="success">Live</oblyx-badge></td>
</tr>
</tbody>
</table>
</oblyx-table>oblyxTable(caption = "Recent projects") {
table {
thead {
tr {
th { +"Name" }
th { +"Status" }
}
}
tbody {
tr {
td { +"Checkout redesign" }
td { oblyxBadge(variant = OblyxBadgeVariant.SUCCESS) { +"Live" } }
}
}
}
}Sorting
Sorting is decoration, not an engine. <oblyx-table> never sorts data and never owns the URL. A sortable column header is a real link the author writes, matching data-sort-key on its <th> to a value: <th data-sort-key="name"><a href="?sort=name&dir=desc">Name</a></th>. sort-key and sort-direction name the currently active column and direction; the component only sets aria-sort and swaps a directional glyph on the matching header, and a neutral glyph on every other one. Clicking the header navigates through the author's own link, exactly like <oblyx-pagination>'s "author supplies the URL, component supplies presentation sugar" contract. Do not expect client-side sorting: this table never reorders rows itself, regardless of what sort-key and sort-direction are set to.
Sortable columns
| Name | Owner | Status |
|---|---|---|
| Billing migration | Marcus Webb | |
| Checkout redesign | Priya Shah | |
| Onboarding flow | Priya Shah |
<oblyx-table sort-key="name" sort-direction="ascending">
<table>
<thead>
<tr>
<th data-sort-key="name"><a href="?sort=name&dir=desc">Name</a></th>
<th data-sort-key="owner"><a href="?sort=owner&dir=asc">Owner</a></th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>Billing migration</td>
<td>Marcus Webb</td>
<td><oblyx-badge variant="warning">In review</oblyx-badge></td>
</tr>
<tr>
<td>Checkout redesign</td>
<td>Priya Shah</td>
<td><oblyx-badge variant="success">Live</oblyx-badge></td>
</tr>
<tr>
<td>Onboarding flow</td>
<td>Priya Shah</td>
<td><oblyx-badge variant="neutral">Draft</oblyx-badge></td>
</tr>
</tbody>
</table>
</oblyx-table>oblyxTable(sortKey = "name", sortDirection = OblyxTableSortDirection.ASCENDING) {
table {
thead {
tr {
th { a(href = "?sort=name&dir=desc") { +"Name" } }
th { a(href = "?sort=owner&dir=asc") { +"Owner" } }
th { +"Status" }
}
}
tbody {
tr {
td { +"Billing migration" }
td { +"Marcus Webb" }
td { oblyxBadge(variant = OblyxBadgeVariant.WARNING) { +"In review" } }
}
tr {
td { +"Checkout redesign" }
td { +"Priya Shah" }
td { oblyxBadge(variant = OblyxBadgeVariant.SUCCESS) { +"Live" } }
}
tr {
td { +"Onboarding flow" }
td { +"Priya Shah" }
td { oblyxBadge(variant = OblyxBadgeVariant.NEUTRAL) { +"Draft" } }
}
}
}
}Sticky header and sticky first column
sticky-header pins the <thead> row to the top while the body scrolls past it. This needs more than position: sticky on the header cells: a sticky element needs a bounded, vertically scrolling ancestor to stick against, and the scroll wrapper's own overflow-x: auto alone gives it nothing, since a wrapper with only horizontal overflow set never overflows vertically. Setting sticky-header also gives the scroll wrapper a bounded height through --ox-table-max-block-size (default 32em) and switches its overflow-y to auto, which is what actually makes the header sticky. Without that token, the header silently does not stick, with no error to point at the cause. Override the custom property on <oblyx-table> for a specific desired height.
sticky-first-column pins every row's first cell to the inline start of the scroll wrapper. Both can be set together: the corner cell, where both axes cross, needs a higher stacking order than the rest of the sticky header, or the header row renders underneath the sticky first-column cell as they scroll past each other.
Sticky header and first column
Wide and tall enough to scroll in both directions. Scroll down to see the header stick, and right to see the first column stick.
| Name | Status | Owner | Priority | Created | Updated | Category | Region |
|---|---|---|---|---|---|---|---|
| Checkout redesign | Priya Shah | High | Jan 4 | Jan 20 | Payments | US | |
| Billing migration | Marcus Webb | High | Jan 6 | Jan 19 | Billing | EU | |
| Onboarding flow | Priya Shah | Medium | Jan 8 | Jan 18 | Growth | US | |
| Search relevance | Jordan Lee | Medium | Jan 9 | Jan 17 | Platform | APAC | |
| Mobile push notifications | Alex Kim | Low | Jan 10 | Jan 16 | Growth | US | |
| Refund automation | Marcus Webb | High | Jan 11 | Jan 15 | Billing | EU | |
| Dark mode | Jordan Lee | Low | Jan 12 | Jan 14 | Platform | APAC | |
| API rate limiting | Alex Kim | Medium | Jan 13 | Jan 13 | Platform | US | |
| Team invitations | Priya Shah | Low | Jan 14 | Jan 12 | Growth | US | |
| Audit log export | Jordan Lee | Medium | Jan 15 | Jan 11 | Platform | EU | |
| Bulk invoice download | Marcus Webb | Low | Jan 16 | Jan 10 | Billing | EU | |
| Custom domains | Alex Kim | High | Jan 17 | Jan 9 | Platform | APAC |
<div style="max-width: 32em;">
<oblyx-table sticky-header sticky-first-column>
<table>
<thead>
<tr>
<th style="min-inline-size: 9em;">Name</th>
<th style="min-inline-size: 7em;">Status</th>
<th style="min-inline-size: 7em;">Owner</th>
<th style="min-inline-size: 6em;">Priority</th>
<th style="min-inline-size: 6em;">Created</th>
<th style="min-inline-size: 6em;">Updated</th>
<th style="min-inline-size: 7em;">Category</th>
<th style="min-inline-size: 6em;">Region</th>
</tr>
</thead>
<tbody>
<tr><td>Checkout redesign</td><td><oblyx-badge variant="success">Live</oblyx-badge></td><td>Priya Shah</td><td>High</td><td>Jan 4</td><td>Jan 20</td><td>Payments</td><td>US</td></tr>
<tr><td>Billing migration</td><td><oblyx-badge variant="warning">In review</oblyx-badge></td><td>Marcus Webb</td><td>High</td><td>Jan 6</td><td>Jan 19</td><td>Billing</td><td>EU</td></tr>
<tr><td>Onboarding flow</td><td><oblyx-badge variant="neutral">Draft</oblyx-badge></td><td>Priya Shah</td><td>Medium</td><td>Jan 8</td><td>Jan 18</td><td>Growth</td><td>US</td></tr>
<tr><td>Search relevance</td><td><oblyx-badge variant="success">Live</oblyx-badge></td><td>Jordan Lee</td><td>Medium</td><td>Jan 9</td><td>Jan 17</td><td>Platform</td><td>APAC</td></tr>
<tr><td>Mobile push notifications</td><td><oblyx-badge variant="warning">In review</oblyx-badge></td><td>Alex Kim</td><td>Low</td><td>Jan 10</td><td>Jan 16</td><td>Growth</td><td>US</td></tr>
<tr><td>Refund automation</td><td><oblyx-badge variant="error">Blocked</oblyx-badge></td><td>Marcus Webb</td><td>High</td><td>Jan 11</td><td>Jan 15</td><td>Billing</td><td>EU</td></tr>
<tr><td>Dark mode</td><td><oblyx-badge variant="success">Live</oblyx-badge></td><td>Jordan Lee</td><td>Low</td><td>Jan 12</td><td>Jan 14</td><td>Platform</td><td>APAC</td></tr>
<tr><td>API rate limiting</td><td><oblyx-badge variant="neutral">Draft</oblyx-badge></td><td>Alex Kim</td><td>Medium</td><td>Jan 13</td><td>Jan 13</td><td>Platform</td><td>US</td></tr>
<tr><td>Team invitations</td><td><oblyx-badge variant="success">Live</oblyx-badge></td><td>Priya Shah</td><td>Low</td><td>Jan 14</td><td>Jan 12</td><td>Growth</td><td>US</td></tr>
<tr><td>Audit log export</td><td><oblyx-badge variant="warning">In review</oblyx-badge></td><td>Jordan Lee</td><td>Medium</td><td>Jan 15</td><td>Jan 11</td><td>Platform</td><td>EU</td></tr>
<tr><td>Bulk invoice download</td><td><oblyx-badge variant="neutral">Draft</oblyx-badge></td><td>Marcus Webb</td><td>Low</td><td>Jan 16</td><td>Jan 10</td><td>Billing</td><td>EU</td></tr>
<tr><td>Custom domains</td><td><oblyx-badge variant="error">Blocked</oblyx-badge></td><td>Alex Kim</td><td>High</td><td>Jan 17</td><td>Jan 9</td><td>Platform</td><td>APAC</td></tr>
</tbody>
</table>
</oblyx-table>
</div>data class Project(
val name: String,
val status: OblyxBadgeVariant,
val statusLabel: String,
val owner: String,
val priority: String,
val created: String,
val updated: String,
val category: String,
val region: String,
)
val projects = listOf(
Project("Checkout redesign", OblyxBadgeVariant.SUCCESS, "Live", "Priya Shah", "High", "Jan 4", "Jan 20", "Payments", "US"),
Project("Billing migration", OblyxBadgeVariant.WARNING, "In review", "Marcus Webb", "High", "Jan 6", "Jan 19", "Billing", "EU"),
Project("Onboarding flow", OblyxBadgeVariant.NEUTRAL, "Draft", "Priya Shah", "Medium", "Jan 8", "Jan 18", "Growth", "US"),
Project("Search relevance", OblyxBadgeVariant.SUCCESS, "Live", "Jordan Lee", "Medium", "Jan 9", "Jan 17", "Platform", "APAC"),
Project("Mobile push notifications", OblyxBadgeVariant.WARNING, "In review", "Alex Kim", "Low", "Jan 10", "Jan 16", "Growth", "US"),
Project("Refund automation", OblyxBadgeVariant.ERROR, "Blocked", "Marcus Webb", "High", "Jan 11", "Jan 15", "Billing", "EU"),
Project("Dark mode", OblyxBadgeVariant.SUCCESS, "Live", "Jordan Lee", "Low", "Jan 12", "Jan 14", "Platform", "APAC"),
Project("API rate limiting", OblyxBadgeVariant.NEUTRAL, "Draft", "Alex Kim", "Medium", "Jan 13", "Jan 13", "Platform", "US"),
Project("Team invitations", OblyxBadgeVariant.SUCCESS, "Live", "Priya Shah", "Low", "Jan 14", "Jan 12", "Growth", "US"),
Project("Audit log export", OblyxBadgeVariant.WARNING, "In review", "Jordan Lee", "Medium", "Jan 15", "Jan 11", "Platform", "EU"),
Project("Bulk invoice download", OblyxBadgeVariant.NEUTRAL, "Draft", "Marcus Webb", "Low", "Jan 16", "Jan 10", "Billing", "EU"),
Project("Custom domains", OblyxBadgeVariant.ERROR, "Blocked", "Alex Kim", "High", "Jan 17", "Jan 9", "Platform", "APAC"),
)
div {
style = "max-width: 32em;"
oblyxTable(stickyHeader = true, stickyFirstColumn = true) {
table {
thead {
tr {
th { style = "min-inline-size: 9em;"; +"Name" }
th { style = "min-inline-size: 7em;"; +"Status" }
th { style = "min-inline-size: 7em;"; +"Owner" }
th { style = "min-inline-size: 6em;"; +"Priority" }
th { style = "min-inline-size: 6em;"; +"Created" }
th { style = "min-inline-size: 6em;"; +"Updated" }
th { style = "min-inline-size: 7em;"; +"Category" }
th { style = "min-inline-size: 6em;"; +"Region" }
}
}
tbody {
for (project in projects) {
tr {
td { +project.name }
td { oblyxBadge(variant = project.status) { +project.statusLabel } }
td { +project.owner }
td { +project.priority }
td { +project.created }
td { +project.updated }
td { +project.category }
td { +project.region }
}
}
}
}
}
}A wide table only needs horizontal scroll if its columns cannot shrink to fit: cell text wraps by default, so a table with short cell content can absorb a narrow container by wrapping instead of overflowing, and sticky-first-column then has nothing to demonstrate. The example above gives each <th> a min-inline-size and wraps the table in a narrower container to force the overflow reliably; a real table with enough columns of real content usually does this on its own.
Empty state
There is no data property to check for zero length, because there is no data property at all: rows are markup, not data. Instead, the component looks at the author's own <tbody>: zero <tr> children with empty-title set gets one synthetic full-width row nesting a real <oblyx-empty-state>, built from empty-title and empty-description. Leaving empty-title unset leaves the empty <tbody> exactly as rendered, rather than inventing placeholder copy.
Empty table
| Name | Status | Owner |
|---|
<oblyx-table empty-title="No projects yet" empty-description="Projects you create will show up here.">
<table>
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Owner</th>
</tr>
</thead>
<tbody></tbody>
</table>
</oblyx-table>oblyxTable(emptyTitle = "No projects yet", emptyDescription = "Projects you create will show up here.") {
table {
thead {
tr {
th { +"Name" }
th { +"Status" }
th { +"Owner" }
}
}
tbody { }
}
}Reference
<oblyx-table>
| Attribute | Type | Default | Description |
|---|---|---|---|
density | compactbalancedspacious | balanced | Row and cell spacing. |
dividers | boolean | false | Adds a vertical border between columns, in addition to the horizontal row separators every table already draws. |
striped | boolean | false | Alternates row background color. |
hover | boolean | false | Highlights a row under the pointer. |
caption | string | undefined | – | Accessible table title, rendered as a real <caption> inserted as the table's first child. Left alone entirely if the author already wrote their own <caption>. |
sticky-header | boolean | false | Pins the <thead> row to the top of the scroll wrapper while the body scrolls past it. |
sticky-first-column | boolean | false | Pins every row's first cell to the left of the scroll wrapper while the row scrolls past it horizontally. |
sort-key | string | undefined | – | Matches a data-sort-key value on one of the table's <th> elements, marking it as the currently sorted column. Unset means no column is currently sorted. |
sort-direction | ascendingdescending | – | Sort direction of the column named by sort-key. Ignored if sort-key is unset or matches no <th>. |
empty-title | string | undefined | – | Title for the synthetic empty-state row shown when the <tbody> has zero <tr> children. Unset means the empty <tbody> is left exactly as the author rendered it. No placeholder copy is invented. |
empty-description | string | undefined | – | Optional supporting text for the empty-state row. Ignored if empty-title is unset. |