Renders structured data automatically from @columns and @items, with sticky headers, sorting, column visibility, and scrollable containers.
Key Features:
For manual composition and custom layouts, use SimpleTable instead.
import { Table, type ColumnConfig } from 'frontile';
Define columns and items to render a table automatically:
| ID | Name | Role | |
|---|---|---|---|
| 1 | John Doe | john@example.com | admin |
| 2 | Jane Smith | jane@example.com | user |
| 3 | Bob Johnson | bob@example.com | editor |
import Component from '@glimmer/component';
import { Table, type ColumnConfig } from 'frontile';
import { users, type User } from 'site/components/table-demo-data';
export default class DemoComponent extends Component {
columns = [
{ key: 'id', name: 'ID' },
{ key: 'name', name: 'Name' },
{ key: 'email', name: 'Email' },
{ key: 'role', name: 'Role' }
] as const satisfies ColumnConfig<User>[];
<template>
<div class='demo-stack demo-stack--wide'>
<Table @columns={{this.columns}} @items={{users}} />
</div>
</template>
}
Table combines a scroll container, header, body, rows, columns, and cells from @columns
and @items. Custom header and cell components receive the same column and row context used
by the default renderers.
Transform or compute values dynamically:
| Name | Contact | Admin |
|---|---|---|
| John Doe | JOHN@EXAMPLE.COM | Yes |
| Jane Smith | JANE@EXAMPLE.COM | No |
| Bob Johnson | BOB@EXAMPLE.COM | No |
import Component from '@glimmer/component';
import { Table, type ColumnConfig } from 'frontile';
import { users, type User } from 'site/components/table-demo-data';
export default class DemoComponent extends Component {
columns = [
{ key: 'name', name: 'Name' },
{
key: 'email',
name: 'Contact',
value: (ctx) => ctx.row.data.email.toUpperCase()
},
{
key: 'status',
name: 'Admin',
value: (ctx) => (ctx.row.data.role === 'admin' ? 'Yes' : 'No')
}
] as const satisfies ColumnConfig<User>[];
<template>
<div class='demo-stack demo-stack--wide'>
<Table @columns={{this.columns}} @items={{users}} />
</div>
</template>
}
Define reusable Cell components in your column configuration:
| Name | Status | |
|---|---|---|
| John Doe | john@example.com |
active
|
| Jane Smith | jane@example.com |
active
|
| Bob Johnson | bob@example.com |
inactive
|
import Component from '@glimmer/component';
import { Table, type ColumnConfig, type CellSignature } from 'frontile';
import { Chip } from 'frontile';
import { users, type User } from 'site/components/table-demo-data';
import type { TOC } from '@ember/component/template-only';
const StatusCell: TOC<CellSignature<User>> = <template>
<Chip
@size='sm'
@variant='outline'
@color='{{if (eq @row.data.status "active") "success" "danger"}}'
@withDot={{true}}
>
{{@row.data.status}}
</Chip>
</template>;
export default class DemoComponent extends Component {
columns = [
{ key: 'name', name: 'Name' },
{ key: 'email', name: 'Email' },
{ key: 'status', name: 'Status', Cell: StatusCell }
] as const satisfies ColumnConfig<User>[];
<template>
<div class='demo-stack demo-stack--wide'>
<Table @columns={{this.columns}} @items={{users}} />
</div>
</template>
}
function eq(a: string | undefined, b: string) {
return a === b;
}
Control table appearance with size, striping, and layout options:
| Name | Role | |
|---|---|---|
| John Doe | john@example.com | admin |
| Jane Smith | jane@example.com | user |
| Bob Johnson | bob@example.com | editor |
import Component from '@glimmer/component';
import { Table, type ColumnConfig } from 'frontile';
import { users, type User } from 'site/components/table-demo-data';
import { hash } from '@ember/helper';
export default class DemoComponent extends Component {
columns = [
{ key: 'name', name: 'Name' },
{ key: 'email', name: 'Email' },
{ key: 'role', name: 'Role' }
] as const satisfies ColumnConfig<User>[];
<template>
<div class='demo-stack demo-stack--wide'>
<Table
@columns={{this.columns}}
@items={{users}}
@size='sm'
@isStriped={{true}}
@classes={{hash wrapper='shadow-lg rounded-xl'}}
/>
</div>
</template>
}
Options:
@size - sm, md (default), lg@isStriped - Alternating row colors@layout - auto (default), fixed@classes - Custom CSS classesEnable scrolling with fixed heights or widths:
| ID | Name | Department | Role | |
|---|---|---|---|---|
| 001 | John Doe | john.doe@company.com | Engineering | Senior Developer |
| 002 | Jane Smith | jane.smith@company.com | Design | UI/UX Designer |
| 003 | Bob Johnson | bob.johnson@company.com | Product | Product Manager |
import Component from '@glimmer/component';
import { Table, type ColumnConfig } from 'frontile';
import { employees, type Employee } from 'site/components/table-demo-data';
import { hash } from '@ember/helper';
export default class DemoComponent extends Component {
columns = [
{ key: 'id', name: 'ID' },
{ key: 'name', name: 'Name' },
{ key: 'email', name: 'Email' },
{ key: 'department', name: 'Department' },
{ key: 'role', name: 'Role' }
] as const satisfies ColumnConfig<Employee>[];
<template>
<div class='demo-stack demo-stack--wide'>
<Table
@columns={{this.columns}}
@items={{employees}}
@isScrollable={{true}}
@classes={{hash wrapper='h-48'}}
/>
</div>
</template>
}
Sticky parts of a table — a frozen header or footer, sticky rows, pinned columns
— are painted with surface-table, the same opaque surface as the table itself,
so they cover the rows and columns that scroll under them. Pinned columns keep
their row's selection, hover, and striping.
Tables assume they sit on a canvas-backed page. On any other background, point
--color-surface-table at it — in CSS, or through @classes:
<Table
@columns={{this.columns}}
@items={{this.items}}
@isScrollable={{true}}
@classes={{hash wrapper='[--color-surface-table:var(--color-surface-app)]'}}
/>
Whatever you point it at has to be opaque. See Surfaces for the role itself.
Keep the header visible while scrolling:
| Name | Role | |
|---|---|---|
| John Doe | john@example.com | admin |
| Jane Smith | jane@example.com | user |
| Bob Johnson | bob@example.com | editor |
| John Doe | john@example.com | admin |
| Jane Smith | jane@example.com | user |
| Bob Johnson | bob@example.com | editor |
| John Doe | john@example.com | admin |
| Jane Smith | jane@example.com | user |
| Bob Johnson | bob@example.com | editor |
import Component from '@glimmer/component';
import { Table, type ColumnConfig } from 'frontile';
import { users, type User } from 'site/components/table-demo-data';
import { hash } from '@ember/helper';
export default class DemoComponent extends Component {
columns = [
{ key: 'name', name: 'Name' },
{ key: 'email', name: 'Email' },
{ key: 'role', name: 'Role' }
] as const satisfies ColumnConfig<User>[];
moreUsers = [
...users,
...users.map((u, i) => ({ ...u, id: `${parseInt(u.id) + 3 + i}` })),
...users.map((u, i) => ({ ...u, id: `${parseInt(u.id) + 6 + i}` }))
];
<template>
<div class='demo-stack demo-stack--wide'>
<Table
@columns={{this.columns}}
@items={{this.moreUsers}}
@isStickyHeader={{true}}
@isScrollable={{true}}
@classes={{hash wrapper='h-48'}}
/>
</div>
</template>
}
Pin columns to the left or right during horizontal scrolling:
| ID | Name | Phone | Department | Role | Location | Actions | |
|---|---|---|---|---|---|---|---|
| 001 | John Doe | john.doe@company.com | +1-555-0123 | Engineering | Senior Developer | San Francisco, CA | Edit |
| 002 | Jane Smith | jane.smith@company.com | +1-555-0124 | Design | UI/UX Designer | New York, NY | Edit |
| 003 | Bob Johnson | bob.johnson@company.com | +1-555-0125 | Product | Product Manager | Austin, TX | Edit |
import Component from '@glimmer/component';
import { Table, type ColumnConfig } from 'frontile';
import { employees, type Employee } from 'site/components/table-demo-data';
import { hash } from '@ember/helper';
export default class DemoComponent extends Component {
columns = [
{
key: 'id',
name: 'ID',
isSticky: true,
stickyPosition: 'left'
},
{ key: 'name', name: 'Name' },
{ key: 'email', name: 'Email' },
{ key: 'phone', name: 'Phone' },
{ key: 'department', name: 'Department' },
{ key: 'role', name: 'Role' },
{ key: 'location', name: 'Location' },
{
key: 'actions',
name: 'Actions',
isSticky: true,
stickyPosition: 'right',
value: () => 'Edit'
}
] as const satisfies ColumnConfig<Employee>[];
<template>
<div class='demo-stack demo-stack--wide'>
<Table
@columns={{this.columns}}
@items={{employees}}
@isScrollable={{true}}
@classes={{hash wrapper='max-w-2xl'}}
/>
</div>
</template>
}
Freeze specific rows by their keys:
| ID | Name | Role | |
|---|---|---|---|
| admin | Admin User | admin@example.com | Administrator |
| 1 | John Doe | john@example.com | Developer |
| 2 | Jane Smith | jane@example.com | Designer |
| 3 | Bob Johnson | bob@example.com | Manager |
| guest | Guest User | guest@example.com | Read-only |
import Component from '@glimmer/component';
import { Table, type ColumnConfig } from 'frontile';
import { type User } from 'site/components/table-demo-data';
import { array, hash } from '@ember/helper';
export default class DemoComponent extends Component {
columns = [
{ key: 'id', name: 'ID' },
{ key: 'name', name: 'Name' },
{ key: 'email', name: 'Email' },
{ key: 'role', name: 'Role' }
] as const satisfies ColumnConfig<User>[];
items: User[] = [
{
id: 'admin',
name: 'Admin User',
email: 'admin@example.com',
role: 'Administrator'
},
{ id: '1', name: 'John Doe', email: 'john@example.com', role: 'Developer' },
{
id: '2',
name: 'Jane Smith',
email: 'jane@example.com',
role: 'Designer'
},
{ id: '3', name: 'Bob Johnson', email: 'bob@example.com', role: 'Manager' },
{
id: 'guest',
name: 'Guest User',
email: 'guest@example.com',
role: 'Read-only'
}
];
<template>
<div class='demo-stack demo-stack--wide'>
<Table
@columns={{this.columns}}
@items={{this.items}}
@stickyKeys={{array 'admin' 'guest'}}
@isScrollable={{true}}
@classes={{hash wrapper='h-48'}}
/>
</div>
</template>
}
Display summary information with @footerColumns:
| Product | Price | Category |
|---|---|---|
| Laptop | $999.99 | Electronics |
| Mouse | $29.99 | Accessories |
| Keyboard | $79.99 | Accessories |
| Total Items | $1,109.97 | 2 Categories |
import Component from '@glimmer/component';
import { Table, type ColumnConfig } from 'frontile';
import { products, type Product } from 'site/components/table-demo-data';
export default class DemoComponent extends Component {
columns = [
{ key: 'name', name: 'Product' },
{ key: 'price', name: 'Price', value: (ctx) => `$${ctx.row.data.price}` },
{ key: 'category', name: 'Category' }
] as const satisfies ColumnConfig<Product>[];
footerColumns = [
{ key: 'label', name: 'Total Items' },
{ key: 'total', name: '$1,109.97' },
{ key: 'categories', name: '2 Categories' }
] as const satisfies ColumnConfig[];
<template>
<div class='demo-stack demo-stack--wide'>
<Table
@columns={{this.columns}}
@items={{products}}
@footerColumns={{this.footerColumns}}
/>
</div>
</template>
}
Sticky Footer: Add @isStickyFooter={{true}} to keep the footer visible.
Show loading indicators while data is being fetched:
| Product | Price | Category |
|---|---|---|
| Laptop | $999.99 | Electronics |
| Mouse | $29.99 | Accessories |
| Keyboard | $79.99 | Accessories |
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Table, type ColumnConfig } from 'frontile';
import { products, type Product } from 'site/components/table-demo-data';
import { Button } from 'frontile';
import { Select } from 'frontile';
export default class DemoComponent extends Component {
@tracked isLoading = true;
@tracked loadingColor = 'primary';
columns = [
{ key: 'name', name: 'Product' },
{ key: 'price', name: 'Price', value: (ctx) => `$${ctx.row.data.price}` },
{ key: 'category', name: 'Category' }
] as const satisfies ColumnConfig<Product>[];
colorOptions = [
{ key: 'default', name: 'Default' },
{ key: 'primary', name: 'Primary' },
{ key: 'success', name: 'Success' },
{ key: 'warning', name: 'Warning' },
{ key: 'danger', name: 'Danger' }
];
@action toggleLoading() {
this.isLoading = !this.isLoading;
}
@action updateLoadingColor(color: string) {
this.loadingColor = color;
}
<template>
<div class='demo-stack demo-stack--wide'>
<div class='space-y-4'>
<div class='flex items-end space-x-4 justify-center'>
<Button
@onPress={{this.toggleLoading}}
@size='sm'
@variant='outline'
@color={{if this.isLoading 'danger' 'primary'}}
>
{{if this.isLoading 'Stop Loading' 'Start Loading'}}
</Button>
<Select
@inputSize='sm'
@label='Color'
@items={{this.colorOptions}}
@selectedKey={{this.loadingColor}}
@onSelectionChange={{this.updateLoadingColor}}
class='w-32'
/>
</div>
<Table
@columns={{this.columns}}
@items={{products}}
@isLoading={{this.isLoading}}
@loadingColor={{this.loadingColor}}
/>
</div>
</div>
</template>
}
The hairline under the header is a subtle indicator, for tables that already have content and are merely refreshing it. When a table is loading with nothing on screen yet, prefer the built-in skeleton rows below instead.
@skeletonRows renders placeholder rows while the table is loading and has no
items. It is opt-in, and the row count is required — there is no default.
Load the data below to watch the placeholders hand off to real rows.
| Name | Role | |
|---|---|---|
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import { Table, Button, type ColumnConfig } from 'frontile';
import { users, type User } from 'site/components/table-demo-data';
export default class DemoComponent extends Component {
columns = [
{ key: 'name', name: 'Name' },
{ key: 'email', name: 'Email' },
{ key: 'role', name: 'Role' }
] as const satisfies ColumnConfig<User>[];
@tracked isLoading = true;
@tracked items: User[] = [];
timer?: ReturnType<typeof setTimeout>;
load = () => {
this.reset();
this.timer = setTimeout(() => {
this.items = users;
this.isLoading = false;
}, 1600);
};
reset = () => {
clearTimeout(this.timer);
this.isLoading = true;
this.items = [];
};
willDestroy() {
super.willDestroy();
clearTimeout(this.timer);
}
<template>
<div class='demo-stack demo-stack--wide'>
<div class='w-full space-y-3'>
<div class='flex gap-2'>
<Button @size='sm' @color='primary' {{on 'click' this.load}}>
Load data
</Button>
<Button @size='sm' @variant='outline' {{on 'click' this.reset}}>
Back to loading
</Button>
</div>
<Table
@columns={{this.columns}}
@items={{this.items}}
@isLoading={{this.isLoading}}
@skeletonRows={{5}}
/>
</div>
</div>
</template>
}
Placeholder rows fade in one after another, 60ms apart, so they arrive the way
the real rows will rather than appearing as one block. The stagger stops
growing after ten rows, and prefers-reduced-motion turns it off entirely.
Skeleton rows render only when @isLoading is true and there are no items,
so a refresh, a filter requery, or loading page two never throws away rows the
user is already reading. @emptyContent and the empty block stay suppressed
while they render, and a loading block takes precedence when present.
Each placeholder cell is a
Skeleton
component, sized from the table's @size so the bars match the row height.
A text bar is wrong for a column that holds an avatar. Set skeleton on the
column to pick a shape — it accepts the same values as Skeleton's @shape,
and columns that omit it stay text bars.
| Name | Preview | Role | |
|---|---|---|---|
import { array } from '@ember/helper';
import { Table } from 'frontile';
const columns = [
{ key: 'avatar', name: '', skeleton: 'circle' },
{ key: 'name', name: 'Name' },
{ key: 'thumb', name: 'Preview', skeleton: 'square' },
{ key: 'role', name: 'Role' }
];
<template>
<div class='demo-stack demo-stack--wide'>
<Table
@columns={{columns}}
@items={{(array)}}
@isLoading={{true}}
@skeletonRows={{4}}
/>
</div>
</template>
Because circle and square reuse Avatar's size scale, a placeholder in an
@size="md" table is the same 32px as the <Avatar @size="md"> it stands in
for.
skeleton sets the shape and nothing else. For anything richer — cells that
stack an icon, a name and a chip, or per-column widths — use bodyTop with the
yielded columns and render Skeleton yourself for each piece of content.
If neither the built-in skeleton rows nor a custom bodyTop layout fit —
for example, an overlay that needs to sit over content that is already on
screen — use the loading named block described next.
Use the loading named block for custom indicators:
| Product | Price | Category |
|---|---|---|
| Laptop | 999.99 | Electronics |
| Mouse | 29.99 | Accessories |
| Keyboard | 79.99 | Accessories |
|
|
||
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Table, type ColumnConfig } from 'frontile';
import { products, type Product } from 'site/components/table-demo-data';
import { Button } from 'frontile';
import { Spinner } from 'frontile';
export default class DemoComponent extends Component {
@tracked isLoading = true;
columns = [
{ key: 'name', name: 'Product' },
{ key: 'price', name: 'Price' },
{ key: 'category', name: 'Category' }
] as const satisfies ColumnConfig<Product>[];
@action toggleLoading() {
this.isLoading = !this.isLoading;
}
<template>
<div class='demo-stack demo-stack--wide'>
<div class='space-y-4'>
<Button @onPress={{this.toggleLoading}} @size='sm' @variant='outline'>
{{if this.isLoading 'Stop Loading' 'Start Loading'}}
</Button>
<div class='relative'>
<Table
@columns={{this.columns}}
@items={{products}}
@isLoading={{this.isLoading}}
>
<:loading>
<div
class='absolute inset-0 z-10 bg-surface-canvas/80 backdrop-blur-sm flex items-center justify-center'
>
<Spinner @size='lg' />
</div>
</:loading>
</Table>
</div>
</div>
</div>
</template>
}
Display custom content when there are no items:
| ID | Name | |
|---|---|---|
No Users FoundGet started by adding your first user. |
||
import Component from '@glimmer/component';
import { Table, type ColumnConfig } from 'frontile';
import { type User } from 'site/components/table-demo-data';
import { Button } from 'frontile';
export default class DemoComponent extends Component {
columns = [
{ key: 'id', name: 'ID' },
{ key: 'name', name: 'Name' },
{ key: 'email', name: 'Email' }
] as const satisfies ColumnConfig<User>[];
emptyItems: User[] = [];
<template>
<div class='demo-stack demo-stack--wide'>
<Table @columns={{this.columns}} @items={{this.emptyItems}}>
<:empty>
<div class='text-center py-8'>
<h3 class='text-lg font-medium mb-2'>No Users Found</h3>
<p class='text-muted mb-4'>Get started by adding your first user.</p>
<Button @color='primary' @size='sm'>Add User</Button>
</div>
</:empty>
</Table>
</div>
</template>
}
Use @emptyContent for plain text messages:
| ID | Name | |
|---|---|---|
| No users available | ||
import Component from '@glimmer/component';
import { Table, type ColumnConfig } from 'frontile';
import { type User } from 'site/components/table-demo-data';
export default class DemoComponent extends Component {
columns = [
{ key: 'id', name: 'ID' },
{ key: 'name', name: 'Name' },
{ key: 'email', name: 'Email' }
] as const satisfies ColumnConfig<User>[];
emptyItems: User[] = [];
<template>
<div class='demo-stack demo-stack--wide'>
<Table
@columns={{this.columns}}
@items={{this.emptyItems}}
@emptyContent='No users available'
/>
</div>
</template>
}
Use the :cell block for custom cell content. Alternatively, you can define a Cell component in the column configuration for reusable cell rendering.
| Name | Role | Status | |
|---|---|---|---|
|
|
john@example.com | admin |
active
|
|
|
jane@example.com | user |
active
|
|
|
bob@example.com | editor |
inactive
|
import Component from '@glimmer/component';
import { Table, type ColumnConfig } from 'frontile';
import { users, type User } from 'site/components/table-demo-data';
import { Avatar } from 'frontile';
import { Chip } from 'frontile';
export default class DemoComponent extends Component {
columns = [
{ key: 'name', name: 'Name' },
{ key: 'email', name: 'Email' },
{ key: 'role', name: 'Role' },
{ key: 'status', name: 'Status' }
] as const satisfies ColumnConfig<User>[];
<template>
<div class='demo-stack demo-stack--wide'>
<Table @columns={{this.columns}} @items={{users}}>
<:cell as |c|>
<c.For @key='name'>
<div class='flex items-center space-x-2'>
<Avatar
@name={{c.value}}
@size='sm'
@src='https://i.pravatar.cc/150?img={{c.value}}'
/>
<span class='font-medium'>{{c.value}}</span>
</div>
</c.For>
<c.For @key='status'>
<Chip
@size='sm'
@variant='outline'
@color='{{if (eq c.value "active") "success" "danger"}}'
@withDot={{true}}
>
{{c.value}}
</Chip>
</c.For>
<c.Default>
{{c.value}}
</c.Default>
</:cell>
</Table>
</div>
</template>
}
function eq(a: string | undefined, b: string) {
return a === b;
}
Context:
c.column - Column configurationc.row - Row datac.value - Computed cell valuec.For - Render for specific column keyc.Default - Fallback for unmatched columnsCustomize column headers with the header block:
|
Full Name
|
Role | |
|---|---|---|
| John Doe | john@example.com | admin |
| Jane Smith | jane@example.com | user |
| Bob Johnson | bob@example.com | editor |
import Component from '@glimmer/component';
import { Table, type ColumnConfig } from 'frontile';
import { users, type User } from 'site/components/table-demo-data';
import { UserIcon } from 'site/components/icons';
export default class DemoComponent extends Component {
columns = [
{ key: 'name', name: 'Full Name' },
{ key: 'email', name: 'Email' },
{ key: 'role', name: 'Role' }
] as const satisfies ColumnConfig<User>[];
<template>
<div class='demo-stack demo-stack--wide'>
<Table @columns={{this.columns}} @items={{users}}>
<:header as |h|>
{{#if (eq h.column.key 'name')}}
<div class='flex items-center space-x-2'>
<UserIcon />
<span>{{h.column.name}}</span>
</div>
{{else}}
{{h.column.name}}
{{/if}}
</:header>
</Table>
</div>
</template>
}
function eq(a: string, b: string) {
return a === b;
}
Add custom rows at the top or bottom of the table body:
| Product | Price |
|---|---|
| Order Summary | |
| Laptop | $999.99 |
| Mouse | $29.99 |
| Keyboard | $79.99 |
| Total: $1109.97 | |
import Component from '@glimmer/component';
import { Table, type ColumnConfig } from 'frontile';
import { products, type Product } from 'site/components/table-demo-data';
export default class DemoComponent extends Component {
columns = [
{ key: 'name', name: 'Product' },
{ key: 'price', name: 'Price', value: (ctx) => `$${ctx.row.data.price}` }
] as const satisfies ColumnConfig<Product>[];
get total() {
return products.reduce((sum, p) => sum + p.price, 0).toFixed(2);
}
<template>
<div class='demo-stack demo-stack--wide'>
<Table @columns={{this.columns}} @items={{products}}>
<:bodyTop>
<tr>
<td colspan='2' class='bg-muted/30 px-4 py-2 font-medium'>
Order Summary
</td>
</tr>
</:bodyTop>
<:bodyBottom>
<tr>
<td colspan='2' class='bg-muted/50 px-4 py-3 flex justify-between'>
<span>Total:</span>
<span class='font-semibold'>${{this.total}}</span>
</td>
</tr>
</:bodyBottom>
</Table>
</div>
</template>
}
bodyTop / bodyBottombodyTop and bodyBottom yield the columns the table is actually rendering,
along with style-bound Row and Cell components. Use these instead of
hand-written <tr>/<td> so your rows stay aligned when a column is hidden
via ColumnVisibility and when @selectionMode="multiple" adds its checkbox
column.
| Name | |
|---|---|
import { array } from '@ember/helper';
import { Table, Skeleton } from 'frontile';
const columns = [
{ key: 'name', name: 'Name' },
{ key: 'email', name: 'Email' }
];
<template>
<div class='demo-stack demo-stack--wide'>
<Table @columns={{columns}} @items={{(array)}}>
<:bodyTop as |b|>
<b.Row>
{{#each b.columns as |column|}}
<b.Cell data-column={{column.key}}>
<Skeleton />
</b.Cell>
{{/each}}
</b.Row>
</:bodyTop>
</Table>
</div>
</template>
b.columns is the rendered column list, not the @columns argument you passed
in. Treat b.columns as read-only. It is the table's live column list, not a
copy — mutating it in place (sort, push, splice) will corrupt the header
and the rendered rows. Copy it first if you need a different order. b.Row
and b.Cell carry the table's resolved @size padding, sticky handling, and
@classes overrides.
The loading block yields { columns } only — it renders inside a single
spanning cell, so Row and Cell would not be valid there. Use it for an
overlay or spinner over a table that already has content; use bodyTop for
rows that stand in for content that has not arrived yet.
These are a supported contract, stable across minor versions:
| Attribute | Element | Value |
|---|---|---|
data-key |
<th> |
the column's key |
data-column |
<td> |
the column's key |
data-key |
<tr> |
the row's key, from @getKey |
Use them for column-targeted styling and test selectors.
Every element rendering one of Table's styled parts also carries a stable
data-part attribute (kebab-cased from the slot name — e.g. wrapper,
table, thead, tbody, tr, th, td, sort-button, skeleton-row),
and the outermost wrapping <div> carries data-component="table" (it is
the element that encloses everything Table renders, including the optional
toolbar, so it is the one stable anchor for [data-component="table"] ...
scoping). These mirror the slot names accepted by @classes and are safe
to use as test selectors, e.g. [data-part="tr"].
Enable users to show/hide columns with the toolbar:
| ID | Name | Role |
|---|---|---|
| 1 | John Doe | admin |
| 2 | Jane Smith | user |
| 3 | Bob Johnson | editor |
import Component from '@glimmer/component';
import { Table, type ColumnConfig } from 'frontile';
import { users, type User } from 'site/components/table-demo-data';
export default class DemoComponent extends Component {
columns = [
{ key: 'id', name: 'ID', isVisible: true },
{ key: 'name', name: 'Name', isVisible: true },
{ key: 'email', name: 'Email', isVisible: false },
{ key: 'role', name: 'Role', isVisible: true }
] as const satisfies ColumnConfig<User>[];
<template>
<div class='demo-stack demo-stack--wide'>
<Table @columns={{this.columns}} @items={{users}}>
<:toolbar as |t|>
<div class='flex items-center justify-between mb-4'>
<h3 class='font-semibold'>User Management</h3>
<t.ColumnVisibility />
</div>
</:toolbar>
</Table>
</div>
</template>
}
Initial State: Set isVisible: false in column config to hide by default.
The <:toolbar> block's own wrapping element is styled through the
toolbar key on @classes (e.g. @classes={{hash toolbar='...'}}), the
same way wrapper and table are.
Enable column sorting with isSortable and @onSort:
| Role | ||
|---|---|---|
| Charlie | charlie@example.com | user |
| Alice | alice@example.com | admin |
| Bob | bob@example.com | user |
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { Table, type ColumnConfig, type SortItem } from 'frontile';
import { type User } from 'site/components/table-demo-data';
export default class DemoComponent extends Component {
@tracked items: User[] = [
{ id: '1', name: 'Charlie', email: 'charlie@example.com', role: 'user' },
{ id: '2', name: 'Alice', email: 'alice@example.com', role: 'admin' },
{ id: '3', name: 'Bob', email: 'bob@example.com', role: 'user' }
];
columns = [
{ key: 'name', name: 'Name', isSortable: true },
{ key: 'email', name: 'Email', isSortable: true },
{ key: 'role', name: 'Role' }
] as const satisfies ColumnConfig<User>[];
handleSort = (items: User[], sort: SortItem<User>) => {
if (sort.direction === 'none') return items;
return [...items].sort((a, b) => {
const aVal = a[sort.property];
const bVal = b[sort.property];
if (!aVal || !bVal) return 0;
if (sort.direction === 'ascending') {
return aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
}
return aVal > bVal ? -1 : aVal < bVal ? 1 : 0;
});
};
<template>
<div class='demo-stack demo-stack--wide'>
<Table
@columns={{this.columns}}
@items={{this.items}}
@onSort={{this.handleSort}}
/>
</div>
</template>
}
Features:
sortProperty to sort by a different field@initialSortEnable row selection with @selectionMode for single or multiple row selection.
Use checkboxes for multi-select with selectionMode="multiple":
Selected: 0 row(s)
| Name | Role | ||
|---|---|---|---|
| Alice | alice@example.com | admin | |
| Bob | bob@example.com | user | |
| Charlie | charlie@example.com | user |
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { Table, type ColumnConfig } from 'frontile';
import { type User } from 'site/components/table-demo-data';
export default class DemoComponent extends Component {
@tracked selectedKeys = new Set<string>();
items: User[] = [
{ id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' },
{ id: '2', name: 'Bob', email: 'bob@example.com', role: 'user' },
{ id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'user' }
];
columns = [
{ key: 'name', name: 'Name' },
{ key: 'email', name: 'Email' },
{ key: 'role', name: 'Role' }
] as const satisfies ColumnConfig<User>[];
handleSelectionChange = (keys: Set<string>) => {
this.selectedKeys = keys;
};
<template>
<div class='demo-stack demo-stack--wide'>
<div>
<p class='mb-4 text-sm'>
Selected:
{{this.selectedKeys.size}}
row(s)
</p>
<Table
@columns={{this.columns}}
@items={{this.items}}
@selectionMode='multiple'
@selectedKeys={{this.selectedKeys}}
@onSelectionChange={{this.handleSelectionChange}}
/>
</div>
</div>
</template>
}
Use row clicks for single selection with selectionMode="single":
Selected: None
| Name | Role | |
|---|---|---|
| Alice | alice@example.com | admin |
| Bob | bob@example.com | user |
| Charlie | charlie@example.com | user |
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { Table, type ColumnConfig } from 'frontile';
import { type User } from 'site/components/table-demo-data';
export default class DemoComponent extends Component {
@tracked selectedKeys = new Set<string>();
items: User[] = [
{ id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' },
{ id: '2', name: 'Bob', email: 'bob@example.com', role: 'user' },
{ id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'user' }
];
columns = [
{ key: 'name', name: 'Name' },
{ key: 'email', name: 'Email' },
{ key: 'role', name: 'Role' }
] as const satisfies ColumnConfig<User>[];
handleSelectionChange = (keys: Set<string>) => {
this.selectedKeys = keys;
};
get selectedUser() {
const key = [...this.selectedKeys][0];
return this.items.find((item) => item.id === key);
}
<template>
<div class='demo-stack demo-stack--wide'>
<div>
<p class='mb-4 text-sm'>
Selected:
{{if this.selectedUser this.selectedUser.name 'None'}}
</p>
<Table
@columns={{this.columns}}
@items={{this.items}}
@selectionMode='single'
@selectedKeys={{this.selectedKeys}}
@onSelectionChange={{this.handleSelectionChange}}
/>
</div>
</div>
</template>
}
Prevent specific rows from being selected with @disabledKeys:
Note: Admin users cannot be selected
| Name | Role | ||
|---|---|---|---|
| Alice | alice@example.com | admin | |
| Bob | bob@example.com | user | |
| Charlie | charlie@example.com | user |
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { Table, type ColumnConfig } from 'frontile';
import { type User } from 'site/components/table-demo-data';
export default class DemoComponent extends Component {
@tracked selectedKeys = new Set<string>();
items: User[] = [
{ id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' },
{ id: '2', name: 'Bob', email: 'bob@example.com', role: 'user' },
{ id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'user' }
];
columns = [
{ key: 'name', name: 'Name' },
{ key: 'email', name: 'Email' },
{ key: 'role', name: 'Role' }
] as const satisfies ColumnConfig<User>[];
disabledKeys = ['1'];
handleSelectionChange = (keys: Set<string>) => {
this.selectedKeys = keys;
};
<template>
<div class='demo-stack demo-stack--wide'>
<div>
<p class='mb-4 text-sm'>
Note: Admin users cannot be selected
</p>
<Table
@columns={{this.columns}}
@items={{this.items}}
@selectionMode='multiple'
@selectedKeys={{this.selectedKeys}}
@onSelectionChange={{this.handleSelectionChange}}
@disabledKeys={{this.disabledKeys}}
/>
</div>
</div>
</template>
}
Provide a custom function to extract unique keys from items:
| SKU | Product | Price | |
|---|---|---|---|
| ABC-123 | Widget | 29.99 | |
| DEF-456 | Gadget | 49.99 | |
| GHI-789 | Doohickey | 19.99 |
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { Table, type ColumnConfig } from 'frontile';
interface Product {
sku: string;
name: string;
price: number;
}
export default class DemoComponent extends Component {
@tracked selectedKeys = new Set<string>();
items: Product[] = [
{ sku: 'ABC-123', name: 'Widget', price: 29.99 },
{ sku: 'DEF-456', name: 'Gadget', price: 49.99 },
{ sku: 'GHI-789', name: 'Doohickey', price: 19.99 }
];
columns = [
{ key: 'sku', name: 'SKU' },
{ key: 'name', name: 'Product' },
{ key: 'price', name: 'Price' }
] as const satisfies ColumnConfig<Product>[];
getItemKey = (item: Product) => item.sku;
handleSelectionChange = (keys: Set<string>) => {
this.selectedKeys = keys;
};
<template>
<div class='demo-stack demo-stack--wide'>
<Table
@columns={{this.columns}}
@items={{this.items}}
@selectionMode='multiple'
@selectedKeys={{this.selectedKeys}}
@onSelectionChange={{this.handleSelectionChange}}
@getKey={{this.getItemKey}}
/>
</div>
</template>
}
The Table supports uncontrolled selection, where internal state is managed automatically. Omit @selectedKeys and provide @onSelectionChange to monitor selections:
| Name | Role | ||
|---|---|---|---|
| Alice | alice@example.com | admin | |
| Bob | bob@example.com | user | |
| Charlie | charlie@example.com | user |
import Component from '@glimmer/component';
import { Table, type ColumnConfig } from 'frontile';
import { type User } from 'site/components/table-demo-data';
export default class DemoComponent extends Component {
items: User[] = [
{ id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' },
{ id: '2', name: 'Bob', email: 'bob@example.com', role: 'user' },
{ id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'user' }
];
columns = [
{ key: 'name', name: 'Name' },
{ key: 'email', name: 'Email' },
{ key: 'role', name: 'Role' }
] as const satisfies ColumnConfig<User>[];
handleSelectionChange = (keys: Set<string>) => {
console.log('Selected keys:', Array.from(keys));
};
<template>
<div class='demo-stack demo-stack--wide'>
<Table
@columns={{this.columns}}
@items={{this.items}}
@selectionMode='multiple'
@onSelectionChange={{this.handleSelectionChange}}
/>
</div>
</template>
}
When selection is enabled, rows follow the WAI-ARIA grid pattern:
tabindex, so exactly one row is tabbable at a time. That is the row
focus was last on, else the first selected row, else the first row.tabindex="0" along with focus. Focus wraps: down past the last row lands on
the first, up past the first lands on the last.@disabledKeys) are marked aria-disabled="true". Arrow keys,
Home and End skip them entirely, and they never hold the tab stop — a
table whose first row is disabled hands it to the first enabled row instead.
They can still be focused with the pointer, but not selected.This is the shared
rovingFocus
utility in vertical,
manual-activation mode — arrows move focus only, and selection waits for Space or Enter.
Focus a row and press Space or Enter to select it
| Name | Role | ||
|---|---|---|---|
| Alice | alice@example.com | admin | |
| Bob | bob@example.com | user | |
| Charlie | charlie@example.com | user |
import Component from '@glimmer/component';
import { Table, type ColumnConfig } from 'frontile';
import { type User } from 'site/components/table-demo-data';
export default class DemoComponent extends Component {
items: User[] = [
{ id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' },
{ id: '2', name: 'Bob', email: 'bob@example.com', role: 'user' },
{ id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'user' }
];
columns = [
{ key: 'name', name: 'Name' },
{ key: 'email', name: 'Email' },
{ key: 'role', name: 'Role' }
] as const satisfies ColumnConfig<User>[];
<template>
<div class='demo-stack demo-stack--wide'>
<div>
<p class='mb-4 text-sm text-neutral-strong'>
Focus a row and press Space or Enter to select it
</p>
<Table
@columns={{this.columns}}
@items={{this.items}}
@selectionMode='multiple'
/>
</div>
</div>
</template>
}
Customize the selection highlight color with @selectionColor. Available colors: default, primary, success, warning, danger:
| Name | Role | ||
|---|---|---|---|
| Alice | alice@example.com | admin | |
| Bob | bob@example.com | user | |
| Charlie | charlie@example.com | user |
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Table, type ColumnConfig } from 'frontile';
import { type User } from 'site/components/table-demo-data';
import { Select } from 'frontile';
export default class DemoComponent extends Component {
@tracked selectedKeys = new Set(['1', '2']);
@tracked selectionColor = 'primary';
items: User[] = [
{ id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' },
{ id: '2', name: 'Bob', email: 'bob@example.com', role: 'user' },
{ id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'user' }
];
columns = [
{ key: 'name', name: 'Name' },
{ key: 'email', name: 'Email' },
{ key: 'role', name: 'Role' }
] as const satisfies ColumnConfig<User>[];
colorOptions = [
{ key: 'default', name: 'Default' },
{ key: 'primary', name: 'Primary' },
{ key: 'success', name: 'Success' },
{ key: 'warning', name: 'Warning' },
{ key: 'danger', name: 'Danger' }
];
handleSelectionChange = (keys: Set<string>) => {
this.selectedKeys = keys;
};
@action updateSelectionColor(color: string) {
this.selectionColor = color;
}
<template>
<div class='demo-stack demo-stack--wide'>
<div class='space-y-4'>
<div class='flex items-end justify-center'>
<Select
@inputSize='sm'
@label='Selection Color'
@items={{this.colorOptions}}
@selectedKey={{this.selectionColor}}
@onSelectionChange={{this.updateSelectionColor}}
class='w-40'
/>
</div>
<Table
@columns={{this.columns}}
@items={{this.items}}
@selectionMode='multiple'
@selectedKeys={{this.selectedKeys}}
@onSelectionChange={{this.handleSelectionChange}}
@selectionColor={{this.selectionColor}}
/>
</div>
</div>
</template>
}
Features:
@selectedKeys for controlled, omit for uncontrolled@disabledKeys@getKey for non-standard key extraction@selectionColor (default, primary, success, warning, danger)@showSelectAll={{false}}Table builds on
SimpleTable
, so it inherits real table markup and
scope="col" header cells — structure and navigation come from the browser rather than ARIA.
On top of that:
| Feature | What it exposes |
|---|---|
| Sortable column | aria-sort on the header cell, tracking none / ascending / descending |
| Sort control | A real <button> inside the header, so it is focusable and activates on Enter and Space |
| Sort direction icon | aria-hidden="true" — the chevron is decoration, aria-sort carries the meaning |
| Row selection | A checkbox per row labelled "Select row", and "Select all rows" in the header |
| Disabled row | aria-disabled="true", which is also what keyboard navigation reads to skip it |
| Skeleton rows | aria-hidden="true" while loading, so placeholder rows are not announced as data |
aria-sort is present on every header cell, including non-sortable ones, where it reads
none. So the attribute's presence is not a reliable signal of whether a column can be
sorted; data-sortable is.
Two things to supply yourself:
<caption>, or aria-labelledby pointing at
the heading above it.Sticky headers and footers are positioned with CSS and do not change the reading order.
Element: HTMLTableElement
| Name | Type | Default | Description |
|---|---|---|---|
columns
*
|
Array
|
- | Array of column configurations for automatic table generation |
items
*
|
Array
|
- | Array of data items to display in the table |
classes
|
SlotsToClasses<'table' | 'tbody' | 'td' | 'tfoot' | 'th' | 'thead' | 'tr' | 'separator' | 'wrapper' | 'empty' | 'toolbar' | 'sortButton' | 'sortIcon' | 'columnVisibilityButton' | 'columnVisibilityIcon' | 'skeleton' | 'skeletonRow'>
|
- | Custom CSS classes for different table elements (wrapper, table, th, td, etc.) |
disabledKeys
|
Array
|
- | Array of keys that should be disabled from selection |
emptyContent
|
enum
|
- | Content to display when no data items are provided |
footerColumns
|
Array
|
- | Array of column configurations for automatic footer generation |
getKey
|
function
|
- | Function to extract unique key from an item. Defaults to using keyAndLabelForItem helper. |
initialSort
|
SortItem<T>
|
- | Initial sort descriptor to apply on mount. Only used for initial render, subsequent changes are ignored. |
isLoading
|
boolean
|
- | Enable loading state styling and behavior |
isScrollable
|
boolean
|
- | Enable scrolling for the table container |
isStickyFooter
|
boolean
|
- | Make the table footer sticky during vertical scrolling |
isStickyHeader
|
boolean
|
- | Make the table header sticky during vertical scrolling |
isStriped
|
boolean
|
- | Enable striped rows (alternating background colors) |
layout
|
enum
|
'auto'
|
Table layout algorithm - 'auto' sizes columns by content, 'fixed' uses first row for sizing. |
loadingColor
|
enum
|
'default'
|
Color variant for loading animation. |
onSelectionChange
|
function
|
- | Callback when selection changes |
onSort
|
function
|
- | Function to sort items when sorting is triggered. Receives the items array and sort descriptor. Returns sorted items. |
selectedKeys
|
any
|
- | Set of selected item keys |
selectionColor
|
enum
|
'primary'
|
Color variant for selection highlight. |
selectionMode
|
enum
|
'none'
|
Selection mode - 'none' (default), 'single' (row clicks only), or 'multiple' (with checkboxes). |
showSelectAll
|
boolean
|
true
|
Show "select all" checkbox in header (only for multiple selection mode). |
size
|
enum
|
'md'
|
Size variant for table cells and headers. |
skeletonRows
|
number
|
- | Number of placeholder rows to render while loading with no items. Omit or 0 for no skeleton. |
stickyKeys
|
Array
|
- | Array of item keys that should be sticky during vertical scrolling |
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
never
|
- | |
toolbar
*
|
Array
|
- | |
cell
*
|
Array
|
- | |
empty
*
|
Array
|
- | |
header
*
|
Array
|
- | |
loading
*
|
Array
|
- | |
bodyTop
*
|
Array
|
- | |
bodyBottom
*
|
Array
|
- |
interface ColumnConfig<T = unknown> {
key: string;
name: string;
value?: (ctx: CellContext<T>) => ContentValue;
isSticky?: boolean;
stickyPosition?: 'left' | 'right';
isVisible?: boolean;
isSortable?: boolean;
sortProperty?: string;
Cell?: ComponentLike<CellSignature<T>>;
}
interface CellSignature<T> {
Args: {
row: { data: T };
column: ColumnConfig<T>;
value?: ContentValue;
};
}