Navigation

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 Live Priya Shah
Billing migration In review Marcus Webb
Onboarding flow Draft Priya Shah
HTMLKotlin
<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 Live Priya Shah
Billing migration In review Marcus Webb
Onboarding flow Draft Priya Shah
HTMLKotlin
<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 Live
HTMLKotlin
<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 In review
Checkout redesign Priya Shah Live
Onboarding flow Priya Shah Draft
HTMLKotlin
<oblyx-table sort-key="name" sort-direction="ascending">
  <table>
    <thead>
      <tr>
        <th data-sort-key="name"><a href="?sort=name&amp;dir=desc">Name</a></th>
        <th data-sort-key="owner"><a href="?sort=owner&amp;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 redesignLivePriya ShahHighJan 4Jan 20PaymentsUS
Billing migrationIn reviewMarcus WebbHighJan 6Jan 19BillingEU
Onboarding flowDraftPriya ShahMediumJan 8Jan 18GrowthUS
Search relevanceLiveJordan LeeMediumJan 9Jan 17PlatformAPAC
Mobile push notificationsIn reviewAlex KimLowJan 10Jan 16GrowthUS
Refund automationBlockedMarcus WebbHighJan 11Jan 15BillingEU
Dark modeLiveJordan LeeLowJan 12Jan 14PlatformAPAC
API rate limitingDraftAlex KimMediumJan 13Jan 13PlatformUS
Team invitationsLivePriya ShahLowJan 14Jan 12GrowthUS
Audit log exportIn reviewJordan LeeMediumJan 15Jan 11PlatformEU
Bulk invoice downloadDraftMarcus WebbLowJan 16Jan 10BillingEU
Custom domainsBlockedAlex KimHighJan 17Jan 9PlatformAPAC
HTMLKotlin
<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
HTMLKotlin
<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>

AttributeTypeDefaultDescription
densitycompactbalancedspaciousbalancedRow and cell spacing.
dividersbooleanfalseAdds a vertical border between columns, in addition to the horizontal row separators every table already draws.
stripedbooleanfalseAlternates row background color.
hoverbooleanfalseHighlights a row under the pointer.
captionstring | undefinedAccessible 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-headerbooleanfalsePins the <thead> row to the top of the scroll wrapper while the body scrolls past it.
sticky-first-columnbooleanfalsePins every row's first cell to the left of the scroll wrapper while the row scrolls past it horizontally.
sort-keystring | undefinedMatches 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-directionascendingdescendingSort direction of the column named by sort-key. Ignored if sort-key is unset or matches no <th>.
empty-titlestring | undefinedTitle 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-descriptionstring | undefinedOptional supporting text for the empty-state row. Ignored if empty-title is unset.