GitHub

Table

A powerful component for displaying structured data with features like sticky headers, sorting, column visibility, and scrollable containers.

Key Features:

  • Automatic rendering with type-safe column definitions
  • Row selection (single or multiple with checkboxes)
  • Sticky elements (headers, footers, columns, rows)
  • Scrollable containers for large datasets
  • Column sorting and visibility controls
  • Custom cell and header rendering
  • Loading and empty states

For manual composition and custom layouts, use SimpleTable instead.

Import

import { Table, type ColumnConfig } from 'frontile';

Usage

Define columns and items to render a table automatically:

ID Name Email 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><Table @columns={{this.columns}} @items={{users}} /></template>
}

Column Configuration

Custom Value Functions

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><Table @columns={{this.columns}} @items={{users}} /></template>
}

Column-Level Cell Components

Define reusable Cell components in your column configuration:

Name Email 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'
    @appearance='outlined'
    @intent='{{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><Table @columns={{this.columns}} @items={{users}} /></template>
}

function eq(a: string | undefined, b: string) {
  return a === b;
}

Styling

Control table appearance with size, striping, and layout options:

Name Email 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>
    <Table
      @columns={{this.columns}}
      @items={{users}}
      @size='sm'
      @isStriped={{true}}
      @classes={{hash wrapper='shadow-lg rounded-xl'}}
    />
  </template>
}

Options:

  • @size - sm, md (default), lg
  • @isStriped - Alternating row colors
  • @layout - auto (default), fixed
  • @classes - Custom CSS classes

Scrollable Tables

Enable scrolling with fixed heights or widths:

ID Name Email 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>
    <Table
      @columns={{this.columns}}
      @items={{employees}}
      @isScrollable={{true}}
      @classes={{hash wrapper='h-48'}}
    />
  </template>
}

Sticky Elements

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 Email 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>
    <Table
      @columns={{this.columns}}
      @items={{this.moreUsers}}
      @isStickyHeader={{true}}
      @isScrollable={{true}}
      @classes={{hash wrapper='h-48'}}
    />
  </template>
}

Sticky Columns

Pin columns to the left or right during horizontal scrolling:

ID Name Email 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>
    <Table
      @columns={{this.columns}}
      @items={{employees}}
      @isScrollable={{true}}
      @classes={{hash wrapper='max-w-2xl'}}
    />
  </template>
}

Sticky Rows

Freeze specific rows by their keys:

ID Name Email 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>
    <Table
      @columns={{this.columns}}
      @items={{this.items}}
      @stickyKeys={{array 'admin' 'guest'}}
      @isScrollable={{true}}
      @classes={{hash wrapper='h-48'}}
    />
  </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>
    <Table
      @columns={{this.columns}}
      @items={{products}}
      @footerColumns={{this.footerColumns}}
    />
  </template>
}

Sticky Footer: Add @isStickyFooter={{true}} to keep the footer visible.

Loading State

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='space-y-4'>
      <div class='flex items-end space-x-4 justify-center'>
        <Button
          @onPress={{this.toggleLoading}}
          @size='sm'
          @appearance='outlined'
          @intent={{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>
  </template>
}

The hairline under the header shown above is deliberately subtle — it is meant 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.

Built-in skeleton rows

@skeletonRows renders placeholder rows while the table is loading and has no items. It is opt-in, and the number is required rather than defaulted: the row count is the one thing the table cannot infer, and a wrong default promises ten rows and delivers three.

Load the data below to watch the placeholders hand off to real rows.

Name Email 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='w-full space-y-3'>
      <div class='flex gap-2'>
        <Button @size='sm' @intent='primary' {{on 'click' this.load}}>
          Load data
        </Button>
        <Button @size='sm' @appearance='outlined' {{on 'click' this.reset}}>
          Back to loading
        </Button>
      </div>

      <Table
        @columns={{this.columns}}
        @items={{this.items}}
        @isLoading={{this.isLoading}}
        @skeletonRows={{5}}
      />
    </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 — past that the last row would sit blank longer than many requests take — 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.

Shaping a column's placeholder

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>
  <Table
    @columns={{columns}}
    @items={{(array)}}
    @isLoading={{true}}
    @skeletonRows={{4}}
  />
</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.

This is deliberately only a shape. 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.

Custom Loading Indicator

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='space-y-4'>
      <Button @onPress={{this.toggleLoading}} @size='sm' @appearance='outlined'>
        {{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>
  </template>
}

Empty State

Custom Empty Content

Display custom content when there are no items:

ID Name Email

No Users Found

Get 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>
    <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 @intent='primary' @size='sm'>Add User</Button>
        </div>
      </:empty>
    </Table>
  </template>
}

Simple Text

Use @emptyContent for plain text messages:

ID Name Email
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>
    <Table
      @columns={{this.columns}}
      @items={{this.emptyItems}}
      @emptyContent='No users available'
    />
  </template>
}

Custom Cell Rendering

Use the :cell block for custom cell content. Alternatively, you can define a Cell component in the column configuration for reusable cell rendering.

Name Email Role Status
John Doe
john@example.com admin
active
Jane Smith
jane@example.com user
active
Bob Johnson
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>
    <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'
            @appearance='outlined'
            @intent='{{if (eq c.value "active") "success" "danger"}}'
            @withDot={{true}}
          >
            {{c.value}}
          </Chip>

        </c.For>

        <c.Default>
          {{c.value}}
        </c.Default>
      </:cell>
    </Table>
  </template>
}

function eq(a: string | undefined, b: string) {
  return a === b;
}

Context:

  • c.column - Column configuration
  • c.row - Row data
  • c.value - Computed cell value
  • c.For - Render for specific column key
  • c.Default - Fallback for unmatched columns

Custom Header Rendering

Customize column headers with the header block:

Full Name
Email 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>
    <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>
  </template>
}

function eq(a: string, b: string) {
  return a === b;
}

Body Sections

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>
    <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>
  </template>
}

Column-aligned rows in bodyTop / bodyBottom

bodyTop 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 Email
import { array } from '@ember/helper';
import { Table, Skeleton } from 'frontile';

const columns = [
  { key: 'name', name: 'Name' },
  { key: 'email', name: 'Email' }
];

<template>
  <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>
</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.

Data attributes

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.

Column Visibility

Enable users to show/hide columns with the toolbar:

User Management

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>
    <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>
  </template>
}

Initial State: Set isVisible: false in column config to hide by default.

Sorting

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>
    <Table
      @columns={{this.columns}}
      @items={{this.items}}
      @onSort={{this.handleSort}}
    />
  </template>
}

Features:

  • Tri-state sorting: descending → ascending → none
  • Custom sort property: Use sortProperty to sort by a different field
  • Initial sort: Set with @initialSort

Row Selection

Enable row selection with @selectionMode for single or multiple row selection.

Multiple Selection

Use checkboxes for multi-select with selectionMode="multiple":

Selected: 0 row(s)

Name Email 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>
      <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>
  </template>
}

Single Selection

Use row clicks for single selection with selectionMode="single":

Selected: None

Name Email 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>
      <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>
  </template>
}

Disabled Rows

Prevent specific rows from being selected with @disabledKeys:

Note: Admin users cannot be selected

Name Email 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>
      <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>
  </template>
}

Custom Key Extraction

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>
    <Table
      @columns={{this.columns}}
      @items={{this.items}}
      @selectionMode='multiple'
      @selectedKeys={{this.selectedKeys}}
      @onSelectionChange={{this.handleSelectionChange}}
      @getKey={{this.getItemKey}}
    />
  </template>
}

Uncontrolled Selection

The Table supports uncontrolled selection where internal state is managed automatically. Simply omit @selectedKeys and provide @onSelectionChange to monitor selections:

Name Email 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>
    <Table
      @columns={{this.columns}}
      @items={{this.items}}
      @selectionMode='multiple'
      @onSelectionChange={{this.handleSelectionChange}}
    />
  </template>
}

Keyboard Navigation

When selection is enabled, rows can be selected using keyboard:

  • Space or Enter: Toggle selection (multiple mode) or select row (single mode)
  • Rows are focusable with tabindex="0" for keyboard accessibility
  • Disabled rows cannot be selected via keyboard

Focus a row and press Space or Enter to select it

Name Email 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>
      <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>
  </template>
}

Selection Color

Customize the selection highlight color with @selectionColor. Available colors: default, primary, success, warning, danger:

Name Email 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='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>
  </template>
}

Features:

  • Multiple selection: Checkbox column with select all/none and indeterminate state
  • Single selection: Row clicks, no checkboxes
  • Controlled/Uncontrolled modes: Provide @selectedKeys for controlled, omit for uncontrolled
  • Keyboard navigation: Use Space or Enter keys to select rows
  • Disabled rows: Prevent selection with @disabledKeys
  • Custom keys: Use @getKey for non-standard key extraction
  • Selection colors: Customize highlight with @selectionColor (default, primary, success, warning, danger)
  • Sticky selection column: Auto-sticky on horizontal scroll
  • Select all control: Hide with @showSelectAll={{false}}

Accessibility

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
Skeleton rows aria-hidden="true" while loading, so placeholder rows are not announced as data

aria-sort comes from the underlying table library's header-cell modifier, which applies it to every header cell — including non-sortable ones, where it reads none. That is harmless but means the attribute's presence is not a reliable signal of whether a column can be sorted; data-sortable is.

Two things to supply yourself:

  • An accessible name for the table. Add a <caption>, or aria-labelledby pointing at the heading above it.
  • Better selection labels when rows are identifiable. Every row checkbox is labelled "Select row", which is unambiguous only when a screen reader user is already inside the row. If your rows have a natural name, render your own checkbox in a cell with a label that includes it.

Sticky headers and footers are positioned with CSS and do not change the reading order.

API

Table

Element: <span class="hljs-title class_">HTMLTableElement</span>

Arguments

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<'base' | 'table' | 'tbody' | 'td' | 'tfoot' | 'th' | 'thead' | 'tr' | 'separator' | 'wrapper' | 'sortButton' | 'sortIcon' | 'columnVisibilityButton' | 'columnVisibilityIcon' | 'empty' | '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

Blocks

Name Type Default Description
default * never -
toolbar * Array -
cell * Array -
empty * Array -
header * Array -
loading * Array -
bodyTop * Array -
bodyBottom * Array -

ColumnConfig

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>>;
}

CellSignature

interface CellSignature<T> {
  Args: {
    row: { data: T };
    column: ColumnConfig<T>;
    value?: ContentValue;
  };
}
Released under MIT License - Created by Josemar Luedke