Table
ui-table renders a native <table> from two properties: columns, which lists the keys and header labels, and
rows, a list of plain objects. Each cell shows the row's value for the column key as text. The table fills the
width of its container and scrolls horizontally when its content is wider.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Table demo</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
</head>
<body>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/table.html"></import-element>
<ui-table
caption="A list of your recent invoices."
columns='[{"key":"invoice","label":"Invoice"},{"key":"status","label":"Status"},{"key":"method","label":"Method"},{"key":"amount","label":"Amount"}]'
rows='[{"id":"INV001","invoice":"INV001","status":"Paid","method":"Credit card","amount":"$250.00"},{"id":"INV002","invoice":"INV002","status":"Pending","method":"PayPal","amount":"$150.00"},{"id":"INV003","invoice":"INV003","status":"Unpaid","method":"Bank transfer","amount":"$350.00"}]'>
</ui-table>
</body>
</html>Examples
Data from component state
In a component, bind columns and rows with _columns and _rows. Arrays and objects are passed as properties,
so the table re-renders whenever the state changes. Here the rows are a getter that filters and sorts the data.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Table demo</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
</head>
<body>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/table.html"></import-element>
<import-element from="./table-invoices-demo.html"></import-element>
<table-invoices-demo></table-invoices-demo>
</body>
</html><custom-element name="table-invoices-demo">
<script>
const COLUMNS = [
{ key: "id", label: "Invoice" },
{ key: "customer", label: "Customer" },
{ key: "status", label: "Status" },
{ key: "total", label: "Amount" },
];
const INVOICES = [
{ id: "INV001", customer: "Olivia Martin", status: "Paid", amount: 250 },
{ id: "INV002", customer: "Jackson Lee", status: "Pending", amount: 150 },
{ id: "INV003", customer: "Isabella Nguyen", status: "Unpaid", amount: 350 },
{ id: "INV004", customer: "William Kim", status: "Paid", amount: 450 },
{ id: "INV005", customer: "Sofia Davis", status: "Paid", amount: 550 },
];
const currency = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const invoices = useState({
columns: COLUMNS,
paidOnly: false,
get rows() {
return INVOICES.filter((invoice) => !this.paidOnly || invoice.status === "Paid")
.toSorted((a, b) => b.amount - a.amount)
.map((invoice) => ({ ...invoice, total: currency.format(invoice.amount) }));
},
});
function filter(event) {
invoices.paidOnly = event.target.checked;
}
</script>
<style>
:host {
display: grid;
gap: calc(var(--spacing) * 3);
width: 100%;
}
label {
display: flex;
align-items: center;
gap: calc(var(--spacing) * 2);
font-size: var(--text-sm);
}
</style>
<label><input type="checkbox" onchange="filter(event)"> Paid only</label>
<ui-table _columns="#invoices.columns" _rows="#invoices.rows" caption="Sorted by amount, highest first."></ui-table>
</custom-element>Column labels
label is optional. Without it, the header shows the column key.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Table demo</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
</head>
<body>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/table.html"></import-element>
<ui-table
columns='[{"key":"name"},{"key":"role"},{"key":"email","label":"Email address"}]'
rows='[{"id":1,"name":"Ada","role":"Owner","email":"ada@example.com"},{"id":2,"name":"Linus","role":"Member","email":"linus@example.com"}]'>
</ui-table>
</body>
</html>Custom cells with a native table
ui-table cells hold text only. When cells need badges, buttons or links, write a native <table> in your
component and repeat the rows with the repeat attribute on <tr> (inside <table>, <ul>, <ol> and
<select>, use repeat on the item rather than <repeat-list>, so the HTML parser keeps the structure).
| Member | Role | Actions |
|---|---|---|
|
|
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Table demo</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
</head>
<body>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/avatar.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/badge.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/button.html"></import-element>
<import-element from="./table-members-demo.html"></import-element>
<table-members-demo></table-members-demo>
</body>
</html><custom-element name="table-members-demo">
<script>
const ROLE_VARIANTS = { Owner: "default", Admin: "secondary", Member: "outline" };
const team = useState({
members: [
{ id: 1, name: "Olivia Martin", email: "olivia@example.com", role: "Owner" },
{ id: 2, name: "Jackson Lee", email: "jackson@example.com", role: "Admin" },
{ id: 3, name: "Sofia Davis", email: "sofia@example.com", role: "Member" },
],
get rows() {
return this.members.map((member) => ({
...member,
variant: ROLE_VARIANTS[member.role],
initials: member.name.split(" ").map((part) => part[0]).join(""),
}));
},
});
function remove(member) {
team.members = team.members.filter((item) => item.id !== member.id);
}
</script>
<style>
:host {
display: block;
width: 100%;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
th {
height: calc(var(--spacing) * 10);
padding: 0 calc(var(--spacing) * 2);
text-align: left;
font-weight: var(--font-weight-medium);
}
td {
padding: calc(var(--spacing) * 2);
}
tr {
border-bottom: 1px solid var(--border);
}
tbody tr:last-child {
border-bottom: 0;
}
.person {
display: flex;
align-items: center;
gap: calc(var(--spacing) * 3);
}
small {
display: block;
color: var(--muted-foreground);
}
.actions {
text-align: right;
}
</style>
<table>
<thead>
<tr><th>Member</th><th>Role</th><th class="actions">Actions</th></tr>
</thead>
<tbody>
<tr repeat="#team.rows" as="member" key="id">
<td>
<div class="person">
<ui-avatar><ui-avatar-fallback><slot name="#member.initials"></slot></ui-avatar-fallback></ui-avatar>
<div><slot name="#member.name"></slot><small><slot name="#member.email"></slot></small></div>
</div>
</td>
<td><ui-badge _variant="#member.variant"><slot name="#member.role"></slot></ui-badge></td>
<td class="actions"><ui-button size="sm" variant="ghost" onclick="remove(member)">Remove</ui-button></td>
</tr>
</tbody>
</table>
</custom-element>API
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
columns |
array of { key: string, label?: string } |
[] |
The columns, in order. key is required and selects the value from each row. label is the header text and falls back to key. Accepts JSON in the attribute, or an array through the columns property or a _columns binding. |
rows |
array of objects | [] |
The rows. Each object's values are looked up by column key. Missing values render as empty cells. A row's id, when present, keys the row so updates reuse its DOM. Otherwise the index is used. Accepts JSON or an array, like columns. |
caption |
string | none | Text for the table's <caption>, shown under the table. The caption is hidden when this is not set. |
Properties
columns and rows are also properties. Assigning a new array re-renders the table:
<ui-table id="people"></ui-table>
<script>
const table = document.getElementById("people");
table.columns = [{ key: "name", label: "Name" }, { key: "email", label: "Email" }];
table.rows = [{ id: 1, name: "Ada", email: "ada@example.com" }];
</script>Parts
ui-table has no child parts and no slots. It renders everything from columns, rows and caption. For cells
with markup, use a native table as shown above.
CSS parts
| Part | Description |
|---|---|
table |
The native <table>. |
head |
Each header cell (<th>). |
row |
Each body row (<tr>). |
cell |
Each body cell (<td>). |
Rows get a bottom border and a muted background on hover. Cells do not wrap. Long tables scroll horizontally inside the element.
Accessibility
The output is a native <table> with <thead>, <th> header cells and an optional <caption>, so screen
readers announce row and column headers. Set caption to give the table a name.